From b28ed6075bbf65fdb78bd09e4773ab2a6f25a936 Mon Sep 17 00:00:00 2001
From: Luke Barnard <luke@matrix.org>
Date: Thu, 17 May 2018 18:15:34 +0100
Subject: [PATCH 01/12] Implement slightly hacky CSS soln. to thumbnail sizing

As the slightly nicer alternative to fixupHeight being applied once
we actually have a timelineWidth.

The niceness comes from not needing timelineWidth, which means we can
implement at render time with CSS. (Despite still calculating aspect
ratios when we render.)
---
 res/css/views/messages/_MImageBody.scss     | 13 +++-
 src/components/views/messages/MImageBody.js | 66 ++++++---------------
 2 files changed, 29 insertions(+), 50 deletions(-)

diff --git a/res/css/views/messages/_MImageBody.scss b/res/css/views/messages/_MImageBody.scss
index 1c809f0743..9f0e77f765 100644
--- a/res/css/views/messages/_MImageBody.scss
+++ b/res/css/views/messages/_MImageBody.scss
@@ -20,5 +20,14 @@ limitations under the License.
 }
 
 .mx_MImageBody_thumbnail {
-    max-width: 100%;
-}
\ No newline at end of file
+    position: absolute;
+    width: 100%;
+    height: 100%;
+    left: 0;
+    top: 0;
+}
+
+.mx_MImageBody_thumbnail_container {
+    overflow: hidden;
+    position: relative;
+}
diff --git a/src/components/views/messages/MImageBody.js b/src/components/views/messages/MImageBody.js
index 6cc492acf8..87fe8d906c 100644
--- a/src/components/views/messages/MImageBody.js
+++ b/src/components/views/messages/MImageBody.js
@@ -21,15 +21,15 @@ import PropTypes from 'prop-types';
 import { MatrixClient } from 'matrix-js-sdk';
 
 import MFileBody from './MFileBody';
-import ImageUtils from '../../../ImageUtils';
 import Modal from '../../../Modal';
 import sdk from '../../../index';
-import dis from '../../../dispatcher';
 import { decryptFile } from '../../../utils/DecryptFile';
 import Promise from 'bluebird';
 import { _t } from '../../../languageHandler';
 import SettingsStore from "../../../settings/SettingsStore";
 
+const THUMBNAIL_MAX_HEIGHT = 600;
+
 export default class extends React.Component {
     displayName: 'MImageBody'
 
@@ -48,14 +48,12 @@ export default class extends React.Component {
     constructor(props) {
         super(props);
 
-        this.onAction = this.onAction.bind(this);
         this.onImageError = this.onImageError.bind(this);
         this.onImageLoad = this.onImageLoad.bind(this);
         this.onImageEnter = this.onImageEnter.bind(this);
         this.onImageLeave = this.onImageLeave.bind(this);
         this.onClientSync = this.onClientSync.bind(this);
         this.onClick = this.onClick.bind(this);
-        this.fixupHeight = this.fixupHeight.bind(this);
         this._isGif = this._isGif.bind(this);
 
         this.state = {
@@ -140,7 +138,6 @@ export default class extends React.Component {
     }
 
     onImageLoad() {
-        this.fixupHeight();
         this.props.onWidgetLoad();
     }
 
@@ -176,7 +173,6 @@ export default class extends React.Component {
     }
 
     componentDidMount() {
-        this.dispatcherRef = dis.register(this.onAction);
         const content = this.props.mxEvent.getContent();
         if (content.file !== undefined && this.state.decryptedUrl === null) {
             let thumbnailPromise = Promise.resolve(null);
@@ -217,7 +213,6 @@ export default class extends React.Component {
 
     componentWillUnmount() {
         this.unmounted = true;
-        dis.unregister(this.dispatcherRef);
         this.context.matrixClient.removeListener('sync', this.onClientSync);
         this._afterComponentWillUnmount();
 
@@ -234,50 +229,25 @@ export default class extends React.Component {
     _afterComponentWillUnmount() {
     }
 
-    onAction(payload) {
-        if (payload.action === "timeline_resize") {
-            this.fixupHeight();
-        }
-    }
-
-    fixupHeight() {
-        if (!this.refs.image) {
-            console.warn(`Refusing to fix up height on ${this.displayName} with no image element`);
-            return;
-        }
-
-        const content = this.props.mxEvent.getContent();
-        const timelineWidth = this.refs.body.offsetWidth;
-        const maxHeight = 600; // let images take up as much width as they can so long as the height doesn't exceed 600px.
-        // the alternative here would be 600*timelineWidth/800; to scale them down to fit inside a 4:3 bounding box
-
-        // FIXME: this will break on clientside generated thumbnails (as per e2e rooms)
-        // which may well be much smaller than the 800x600 bounding box.
-
-        // FIXME: It will also break really badly for images with broken or missing thumbnails
-
-        // FIXME: Because we don't know what size of thumbnail the server's actually going to send
-        // us, we can't even really layout the page nicely for it.  Instead we have to assume
-        // it'll target 800x600 and we'll downsize if needed to make things fit.
-
-        // console.log("trying to fit image into timelineWidth of " + this.refs.body.offsetWidth + " or " + this.refs.body.clientWidth);
-        let thumbHeight = null;
-        if (content.info) {
-            thumbHeight = ImageUtils.thumbHeight(content.info.w, content.info.h, timelineWidth, maxHeight);
-        }
-        this.refs.image.style.height = thumbHeight + "px";
-        // console.log("Image height now", thumbHeight);
-    }
-
     _messageContent(contentUrl, thumbUrl, content) {
+        const maxHeight = Math.min(THUMBNAIL_MAX_HEIGHT, content.info.h);
+        const maxWidth = content.info.w * maxHeight / content.info.h;
         const thumbnail = (
             <a href={contentUrl} onClick={this.onClick}>
-                <img className="mx_MImageBody_thumbnail" src={thumbUrl} ref="image"
-                    alt={content.body}
-                    onError={this.onImageError}
-                    onLoad={this.onImageLoad}
-                    onMouseEnter={this.onImageEnter}
-                    onMouseLeave={this.onImageLeave} />
+                <div className="mx_MImageBody_thumbnail_container" style={{ "max-height": maxHeight + "px" }} >
+                    { /* Calculate aspect ratio, using %padding will size _container correctly */ }
+                    <div style={{ paddingBottom: (100 * content.info.h / content.info.w) + '%' }}></div>
+
+                    { /* Thumbnail CSS class resizes to exactly container size with inline CSS
+                         to restrict width */ }
+                    <img className="mx_MImageBody_thumbnail" src={thumbUrl} ref="image"
+                        style={{ "max-width": maxWidth + "px" }}
+                        alt={content.body}
+                        onError={this.onImageError}
+                        onLoad={this.onImageLoad}
+                        onMouseEnter={this.onImageEnter}
+                        onMouseLeave={this.onImageLeave} />
+                </div>
             </a>
         );
 

From bbcf2fea53d81cb344e36e21221c298be46e30d5 Mon Sep 17 00:00:00 2001
From: Luke Barnard <luke@matrix.org>
Date: Fri, 18 May 2018 09:47:49 +0100
Subject: [PATCH 02/12] Fix e2e image thumbnail spinner containing box correct
 size

---
 src/components/views/messages/MImageBody.js | 55 ++++++++++-----------
 1 file changed, 26 insertions(+), 29 deletions(-)

diff --git a/src/components/views/messages/MImageBody.js b/src/components/views/messages/MImageBody.js
index 87fe8d906c..f1454d1ac5 100644
--- a/src/components/views/messages/MImageBody.js
+++ b/src/components/views/messages/MImageBody.js
@@ -232,7 +232,29 @@ export default class extends React.Component {
     _messageContent(contentUrl, thumbUrl, content) {
         const maxHeight = Math.min(THUMBNAIL_MAX_HEIGHT, content.info.h);
         const maxWidth = content.info.w * maxHeight / content.info.h;
-        const thumbnail = (
+
+        let img = null;
+        // e2e image hasn't been decrypted yet
+        if (content.file !== undefined && this.state.decryptedUrl === null) {
+            img = <div className="mx_MImageBody_thumbnail" ref="image" style={{
+                "display": "flex",
+                "alignItems": "center",
+                "width": "100%",
+            }}>
+                <img src="img/spinner.gif" alt={content.body} width="32" height="32" style={{
+                    "margin": "auto",
+                }} />
+            </div>;
+        } else if (thumbUrl && !this.state.imgError) {
+            img = <img className="mx_MImageBody_thumbnail" src={thumbUrl} ref="image"
+                style={{ "max-width": maxWidth + "px" }}
+                alt={content.body}
+                onError={this.onImageError}
+                onLoad={this.onImageLoad}
+                onMouseEnter={this.onImageEnter}
+                onMouseLeave={this.onImageLeave} />;
+        }
+        const thumbnail = img ?
             <a href={contentUrl} onClick={this.onClick}>
                 <div className="mx_MImageBody_thumbnail_container" style={{ "max-height": maxHeight + "px" }} >
                     { /* Calculate aspect ratio, using %padding will size _container correctly */ }
@@ -240,20 +262,13 @@ export default class extends React.Component {
 
                     { /* Thumbnail CSS class resizes to exactly container size with inline CSS
                          to restrict width */ }
-                    <img className="mx_MImageBody_thumbnail" src={thumbUrl} ref="image"
-                        style={{ "max-width": maxWidth + "px" }}
-                        alt={content.body}
-                        onError={this.onImageError}
-                        onLoad={this.onImageLoad}
-                        onMouseEnter={this.onImageEnter}
-                        onMouseLeave={this.onImageLeave} />
+                    { img }
                 </div>
-            </a>
-        );
+            </a> : null;
 
         return (
             <span className="mx_MImageBody" ref="body">
-                { thumbUrl && !this.state.imgError ? thumbnail : '' }
+                { thumbnail }
                 <MFileBody {...this.props} decryptedBlob={this.state.decryptedBlob} />
             </span>
       );
@@ -271,24 +286,6 @@ export default class extends React.Component {
             );
         }
 
-        if (content.file !== undefined && this.state.decryptedUrl === null) {
-            // Need to decrypt the attachment
-            // The attachment is decrypted in componentDidMount.
-            // For now add an img tag with a spinner.
-            return (
-                <span className="mx_MImageBody" ref="body">
-                    <div className="mx_MImageBody_thumbnail" ref="image" style={{
-                        "display": "flex",
-                        "alignItems": "center",
-                        "width": "100%",
-                    }}>
-                        <img src="img/spinner.gif" alt={content.body} width="32" height="32" style={{
-                            "margin": "auto",
-                        }} />
-                    </div>
-                </span>
-            );
-        }
 
         const contentUrl = this._getContentUrl();
         let thumbUrl;

From b41b9aa4facd2e4767d993f1d173ec9fdbc03c37 Mon Sep 17 00:00:00 2001
From: Luke Barnard <luke@matrix.org>
Date: Fri, 18 May 2018 09:58:52 +0100
Subject: [PATCH 03/12] Remove fixupHeight call from MStickerBody

---
 src/components/views/messages/MStickerBody.js | 3 ---
 1 file changed, 3 deletions(-)

diff --git a/src/components/views/messages/MStickerBody.js b/src/components/views/messages/MStickerBody.js
index 3a412fc2e2..cdb60f0074 100644
--- a/src/components/views/messages/MStickerBody.js
+++ b/src/components/views/messages/MStickerBody.js
@@ -40,7 +40,6 @@ export default class MStickerBody extends MImageBody {
     }
 
     _onImageLoad() {
-        this.fixupHeight();
         this.setState({
             placeholderClasses: 'mx_MStickerBody_placeholder_invisible',
         });
@@ -110,8 +109,6 @@ export default class MStickerBody extends MImageBody {
         // The pixel size of sticker images is generally larger than their intended display
         // size so they render at native reolution on HiDPI displays. We therefore need to
         // explicity set the size so they render at the intended size.
-        // XXX: This will be clobberred when we run fixupHeight(), but we need to do it
-        // here otherwise the stickers are momentarily displayed at the pixel size.
         const imageStyle = {
             height: content.info.h,
             // leave the browser the calculate the width automatically

From d11442de0434282c70052327420ab359de9bd161 Mon Sep 17 00:00:00 2001
From: Luke Barnard <luke@matrix.org>
Date: Fri, 18 May 2018 10:15:59 +0100
Subject: [PATCH 04/12] Adjust comment

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

diff --git a/src/components/views/messages/MImageBody.js b/src/components/views/messages/MImageBody.js
index f1454d1ac5..f98432a166 100644
--- a/src/components/views/messages/MImageBody.js
+++ b/src/components/views/messages/MImageBody.js
@@ -260,8 +260,7 @@ export default class extends React.Component {
                     { /* Calculate aspect ratio, using %padding will size _container correctly */ }
                     <div style={{ paddingBottom: (100 * content.info.h / content.info.w) + '%' }}></div>
 
-                    { /* Thumbnail CSS class resizes to exactly container size with inline CSS
-                         to restrict width */ }
+                    { /* mx_MImageBody_thumbnail resizes img to exactly container size */ }
                     { img }
                 </div>
             </a> : null;

From 7e7e2a747313d2d9d45d67bb5019b1fc0b7ef20a Mon Sep 17 00:00:00 2001
From: Luke Barnard <luke@matrix.org>
Date: Fri, 18 May 2018 10:27:22 +0100
Subject: [PATCH 05/12] Add more comments to explain thumbnail sizing

---
 res/css/views/messages/_MImageBody.scss     | 4 ++++
 src/components/views/messages/MImageBody.js | 5 +++++
 2 files changed, 9 insertions(+)

diff --git a/res/css/views/messages/_MImageBody.scss b/res/css/views/messages/_MImageBody.scss
index 9f0e77f765..9667337f5a 100644
--- a/res/css/views/messages/_MImageBody.scss
+++ b/res/css/views/messages/_MImageBody.scss
@@ -28,6 +28,10 @@ limitations under the License.
 }
 
 .mx_MImageBody_thumbnail_container {
+    // Prevent the padding-bottom (added inline in MImageBody.js) from
+    // effecting elements below the container.
     overflow: hidden;
+
+    // Make sure the _thumbnail is positioned relative to the _container
     position: relative;
 }
diff --git a/src/components/views/messages/MImageBody.js b/src/components/views/messages/MImageBody.js
index f98432a166..656bd02840 100644
--- a/src/components/views/messages/MImageBody.js
+++ b/src/components/views/messages/MImageBody.js
@@ -230,7 +230,10 @@ export default class extends React.Component {
     }
 
     _messageContent(contentUrl, thumbUrl, content) {
+        // The maximum height of the thumbnail as it is rendered as an <img>
         const maxHeight = Math.min(THUMBNAIL_MAX_HEIGHT, content.info.h);
+        // The maximum width of the thumbnail, as dictated by it's natural
+        // maximum height.
         const maxWidth = content.info.w * maxHeight / content.info.h;
 
         let img = null;
@@ -246,6 +249,8 @@ export default class extends React.Component {
                 }} />
             </div>;
         } else if (thumbUrl && !this.state.imgError) {
+            // Restrict the width of the thumbnail here, otherwise it will fill the container
+            // which has the same width as the timeline
             img = <img className="mx_MImageBody_thumbnail" src={thumbUrl} ref="image"
                 style={{ "max-width": maxWidth + "px" }}
                 alt={content.body}

From 6699c4faed2cb5e9bf43afecfac8b34f86b20a52 Mon Sep 17 00:00:00 2001
From: Luke Barnard <luke@matrix.org>
Date: Fri, 18 May 2018 11:29:30 +0100
Subject: [PATCH 06/12] Spelling/grammar

---
 res/css/views/messages/_MImageBody.scss | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/res/css/views/messages/_MImageBody.scss b/res/css/views/messages/_MImageBody.scss
index 9667337f5a..5eef236b26 100644
--- a/res/css/views/messages/_MImageBody.scss
+++ b/res/css/views/messages/_MImageBody.scss
@@ -29,7 +29,7 @@ limitations under the License.
 
 .mx_MImageBody_thumbnail_container {
     // Prevent the padding-bottom (added inline in MImageBody.js) from
-    // effecting elements below the container.
+    // affecting elements below the container.
     overflow: hidden;
 
     // Make sure the _thumbnail is positioned relative to the _container

From 015093b371074c1318fa140c166585c4c68b6778 Mon Sep 17 00:00:00 2001
From: Luke Barnard <luke@matrix.org>
Date: Fri, 18 May 2018 11:33:33 +0100
Subject: [PATCH 07/12] Move inline style to stylesheet

---
 res/css/views/messages/_MImageBody.scss     | 10 ++++++++++
 src/components/views/messages/MImageBody.js | 10 ++--------
 2 files changed, 12 insertions(+), 8 deletions(-)

diff --git a/res/css/views/messages/_MImageBody.scss b/res/css/views/messages/_MImageBody.scss
index 5eef236b26..64821434dd 100644
--- a/res/css/views/messages/_MImageBody.scss
+++ b/res/css/views/messages/_MImageBody.scss
@@ -35,3 +35,13 @@ limitations under the License.
     // Make sure the _thumbnail is positioned relative to the _container
     position: relative;
 }
+
+.mx_MImageBody_thumbnail_spinner {
+    display: flex;
+    align-items: center;
+    width: 100%;
+}
+
+.mx_MImageBody_thumbnail_spinner img {
+    margin: auto;
+}
diff --git a/src/components/views/messages/MImageBody.js b/src/components/views/messages/MImageBody.js
index 656bd02840..d9108a2fe1 100644
--- a/src/components/views/messages/MImageBody.js
+++ b/src/components/views/messages/MImageBody.js
@@ -239,14 +239,8 @@ export default class extends React.Component {
         let img = null;
         // e2e image hasn't been decrypted yet
         if (content.file !== undefined && this.state.decryptedUrl === null) {
-            img = <div className="mx_MImageBody_thumbnail" ref="image" style={{
-                "display": "flex",
-                "alignItems": "center",
-                "width": "100%",
-            }}>
-                <img src="img/spinner.gif" alt={content.body} width="32" height="32" style={{
-                    "margin": "auto",
-                }} />
+            img = <div className="mx_MImageBody_thumbnail mx_MImageBody_thumbnail_spinner" ref="image">
+                <img src="img/spinner.gif" alt={content.body} width="32" height="32" />
             </div>;
         } else if (thumbUrl && !this.state.imgError) {
             // Restrict the width of the thumbnail here, otherwise it will fill the container

From 836dc8b0ef2b3d086747c85fbcdd885d7102304c Mon Sep 17 00:00:00 2001
From: Luke Barnard <luke@matrix.org>
Date: Mon, 21 May 2018 16:59:13 +0100
Subject: [PATCH 08/12] Factor out all shared logic between MStickerBody and
 MImageBody

The benefits of this:
 - One code path for determining spinner/placeholder and it's position
   for loading images/stickers. This includes spinner used in e2e
   decryption of images.
 - Very small definition for MStickerBody, only overriding the minimal
   differences is has from MImageBody.

The disadvantages:
 - Slightly more complicated MImageBody, but hopefully not less
   readable.
---
 res/css/views/messages/_MImageBody.scss       |  11 +-
 res/css/views/messages/_MStickerBody.scss     |  32 +---
 src/components/views/messages/MImageBody.js   |  89 ++++++++--
 src/components/views/messages/MStickerBody.js | 162 ++++--------------
 4 files changed, 111 insertions(+), 183 deletions(-)

diff --git a/res/css/views/messages/_MImageBody.scss b/res/css/views/messages/_MImageBody.scss
index 64821434dd..4c763c5991 100644
--- a/res/css/views/messages/_MImageBody.scss
+++ b/res/css/views/messages/_MImageBody.scss
@@ -37,11 +37,12 @@ limitations under the License.
 }
 
 .mx_MImageBody_thumbnail_spinner {
-    display: flex;
-    align-items: center;
-    width: 100%;
+    position: absolute;
+    left: 50%;
+    top: 50%;
 }
 
-.mx_MImageBody_thumbnail_spinner img {
-    margin: auto;
+// Inner img and TintableSvg should be centered around 0, 0
+.mx_MImageBody_thumbnail_spinner > * {
+    transform: translate(-50%, -50%);
 }
diff --git a/res/css/views/messages/_MStickerBody.scss b/res/css/views/messages/_MStickerBody.scss
index 3e6bbe5aa4..e4977bcc34 100644
--- a/res/css/views/messages/_MStickerBody.scss
+++ b/res/css/views/messages/_MStickerBody.scss
@@ -14,33 +14,11 @@ See the License for the specific language governing permissions and
 limitations under the License.
 */
 
-.mx_MStickerBody {
-  display: block;
-  margin-right: 34px;
-  min-height: 110px;
-  padding: 20px 0;
+.mx_MStickerBody_wrapper {
+    padding: 20px 0px;
 }
 
-.mx_MStickerBody_image_container {
-  display: inline-block;
-  position: relative;
-}
-
-.mx_MStickerBody_image {
-  max-width: 100%;
-  opacity: 0;
-}
-
-.mx_MStickerBody_image_visible {
-  opacity: 1;
-}
-
-.mx_MStickerBody_placeholder {
-  position: absolute;
-  opacity: 1;
-}
-
-.mx_MStickerBody_placeholder_invisible {
-  transition: 500ms;
-  opacity: 0;
+.mx_MStickerBody_tooltip {
+    position: absolute;
+    top: 50%;
 }
diff --git a/src/components/views/messages/MImageBody.js b/src/components/views/messages/MImageBody.js
index d9108a2fe1..03dad5e439 100644
--- a/src/components/views/messages/MImageBody.js
+++ b/src/components/views/messages/MImageBody.js
@@ -62,6 +62,8 @@ export default class extends React.Component {
             decryptedBlob: null,
             error: null,
             imgError: false,
+            imgLoaded: false,
+            hover: false,
         };
     }
 
@@ -116,6 +118,8 @@ export default class extends React.Component {
     }
 
     onImageEnter(e) {
+        this.setState({ hover: true });
+
         if (!this._isGif() || SettingsStore.getValue("autoplayGifsAndVideos")) {
             return;
         }
@@ -124,6 +128,8 @@ export default class extends React.Component {
     }
 
     onImageLeave(e) {
+        this.setState({ hover: false });
+
         if (!this._isGif() || SettingsStore.getValue("autoplayGifsAndVideos")) {
             return;
         }
@@ -139,6 +145,7 @@ export default class extends React.Component {
 
     onImageLoad() {
         this.props.onWidgetLoad();
+        this.setState({ imgLoaded: true });
     }
 
     _getContentUrl() {
@@ -237,14 +244,22 @@ export default class extends React.Component {
         const maxWidth = content.info.w * maxHeight / content.info.h;
 
         let img = null;
+        let placeholder = null;
+
         // e2e image hasn't been decrypted yet
         if (content.file !== undefined && this.state.decryptedUrl === null) {
-            img = <div className="mx_MImageBody_thumbnail mx_MImageBody_thumbnail_spinner" ref="image">
-                <img src="img/spinner.gif" alt={content.body} width="32" height="32" />
-            </div>;
-        } else if (thumbUrl && !this.state.imgError) {
+            placeholder = <img src="img/spinner.gif" alt={content.body} width="32" height="32" />;
+        } else if (!this.state.imgLoaded) {
+            // Deliberately, getSpinner is left unimplemented here, MStickerBody overides
+            placeholder = this.getPlaceholder();
+        }
+
+        const showPlaceholder = Boolean(placeholder);
+
+        if (thumbUrl && !this.state.imgError) {
             // Restrict the width of the thumbnail here, otherwise it will fill the container
             // which has the same width as the timeline
+            // mx_MImageBody_thumbnail resizes img to exactly container size
             img = <img className="mx_MImageBody_thumbnail" src={thumbUrl} ref="image"
                 style={{ "max-width": maxWidth + "px" }}
                 alt={content.body}
@@ -253,23 +268,54 @@ export default class extends React.Component {
                 onMouseEnter={this.onImageEnter}
                 onMouseLeave={this.onImageLeave} />;
         }
-        const thumbnail = img ?
-            <a href={contentUrl} onClick={this.onClick}>
-                <div className="mx_MImageBody_thumbnail_container" style={{ "max-height": maxHeight + "px" }} >
-                    { /* Calculate aspect ratio, using %padding will size _container correctly */ }
-                    <div style={{ paddingBottom: (100 * content.info.h / content.info.w) + '%' }}></div>
 
-                    { /* mx_MImageBody_thumbnail resizes img to exactly container size */ }
+        const thumbnail = (
+            <div className="mx_MImageBody_thumbnail_container" style={{ "max-height": maxHeight + "px" }} >
+                { /* Calculate aspect ratio, using %padding will size _container correctly */ }
+                <div style={{ paddingBottom: (100 * content.info.h / content.info.w) + '%' }}></div>
+
+                <div className="mx_MImageBody_thumbnail" style={{
+                    "display": showPlaceholder ? undefined : 'none',
+                    // Constrain width here so that spinner appears central to the loaded thumbnail
+                    "max-width": content.info.w + "px",
+                }}>
+                    <div className="mx_MImageBody_thumbnail_spinner">
+                        { placeholder }
+                    </div>
+                </div>
+
+                <div style={{display: !showPlaceholder ? undefined : 'none'}}>
                     { img }
                 </div>
-            </a> : null;
 
-        return (
-            <span className="mx_MImageBody" ref="body">
-                { thumbnail }
-                <MFileBody {...this.props} decryptedBlob={this.state.decryptedBlob} />
-            </span>
-      );
+                { this.state.hover && this.getTooltip() }
+            </div>
+        );
+
+        return this.wrapImage(contentUrl, thumbnail);
+    }
+
+    // Overidden by MStickerBody
+    wrapImage(contentUrl, children) {
+        return <a href={contentUrl} onClick={this.onClick}>
+            {children}
+        </a>;
+    }
+
+    // Overidden by MStickerBody
+    getPlaceholder() {
+        // MImageBody doesn't show a placeholder whilst the image loads, (but it could do)
+        return null;
+    }
+
+    // Overidden by MStickerBody
+    getTooltip() {
+        return null;
+    }
+
+    // Overidden by MStickerBody
+    getFileBody() {
+        return <MFileBody {...this.props} decryptedBlob={this.state.decryptedBlob} />;
     }
 
     render() {
@@ -284,7 +330,6 @@ export default class extends React.Component {
             );
         }
 
-
         const contentUrl = this._getContentUrl();
         let thumbUrl;
         if (this._isGif() && SettingsStore.getValue("autoplayGifsAndVideos")) {
@@ -293,6 +338,12 @@ export default class extends React.Component {
           thumbUrl = this._getThumbUrl();
         }
 
-        return this._messageContent(contentUrl, thumbUrl, content);
+        const thumbnail = this._messageContent(contentUrl, thumbUrl, content);
+        const fileBody = this.getFileBody();
+
+        return <span className="mx_MImageBody" ref="body">
+            { thumbnail }
+            { fileBody }
+        </span>;
     }
 }
diff --git a/src/components/views/messages/MStickerBody.js b/src/components/views/messages/MStickerBody.js
index cdb60f0074..d9ed668e42 100644
--- a/src/components/views/messages/MStickerBody.js
+++ b/src/components/views/messages/MStickerBody.js
@@ -18,141 +18,39 @@ limitations under the License.
 
 import MImageBody from './MImageBody';
 import sdk from '../../../index';
-import TintableSVG from '../elements/TintableSvg';
 
 export default class MStickerBody extends MImageBody {
-    displayName: 'MStickerBody'
-
-    constructor(props) {
-      super(props);
-
-      this._onMouseEnter = this._onMouseEnter.bind(this);
-      this._onMouseLeave = this._onMouseLeave.bind(this);
-      this._onImageLoad = this._onImageLoad.bind(this);
-    }
-
-    _onMouseEnter() {
-        this.setState({showTooltip: true});
-    }
-
-    _onMouseLeave() {
-        this.setState({showTooltip: false});
-    }
-
-    _onImageLoad() {
-        this.setState({
-            placeholderClasses: 'mx_MStickerBody_placeholder_invisible',
-        });
-        const hidePlaceholderTimer = setTimeout(() => {
-            this.setState({
-                placeholderVisible: false,
-                imageClasses: 'mx_MStickerBody_image_visible',
-            });
-        }, 500);
-        this.setState({hidePlaceholderTimer});
-        if (this.props.onWidgetLoad) {
-            this.props.onWidgetLoad();
-        }
-    }
-
-    _afterComponentDidMount() {
-        if (this.refs.image.complete) {
-            // Image already loaded
-            this.setState({
-                placeholderVisible: false,
-                placeholderClasses: '.mx_MStickerBody_placeholder_invisible',
-                imageClasses: 'mx_MStickerBody_image_visible',
-            });
-        } else {
-            // Image not already loaded
-            this.setState({
-                placeholderVisible: true,
-                placeholderClasses: '',
-                imageClasses: '',
-            });
-        }
-    }
-
-    _afterComponentWillUnmount() {
-        if (this.state.hidePlaceholderTimer) {
-            clearTimeout(this.state.hidePlaceholderTimer);
-            this.setState({hidePlaceholderTimer: null});
-        }
-    }
-
-    _messageContent(contentUrl, thumbUrl, content) {
-        let tooltip;
-        const tooltipBody = (
-            this.props.mxEvent &&
-            this.props.mxEvent.getContent() &&
-            this.props.mxEvent.getContent().body) ?
-            this.props.mxEvent.getContent().body : null;
-        if (this.state.showTooltip && tooltipBody) {
-            const RoomTooltip = sdk.getComponent('rooms.RoomTooltip');
-            tooltip = <RoomTooltip
-                className='mx_RoleButton_tooltip'
-                label={tooltipBody} />;
-        }
-
-        const gutterSize = 0;
-        let placeholderSize = 75;
-        let placeholderFixupHeight = '100px';
-        let placeholderTop = 0;
-        let placeholderLeft = 0;
-
-        if (content.info) {
-            placeholderTop = Math.floor((content.info.h/2) - (placeholderSize/2)) + 'px';
-            placeholderLeft = Math.floor((content.info.w/2) - (placeholderSize/2) + gutterSize) + 'px';
-            placeholderFixupHeight = content.info.h + 'px';
-        }
-
-        // The pixel size of sticker images is generally larger than their intended display
-        // size so they render at native reolution on HiDPI displays. We therefore need to
-        // explicity set the size so they render at the intended size.
-        const imageStyle = {
-            height: content.info.h,
-            // leave the browser the calculate the width automatically
-        };
-
-        placeholderSize = placeholderSize + 'px';
-
-        // Body 'ref' required by MImageBody
-        return (
-            <span className='mx_MStickerBody' ref='body'
-                style={{
-                    height: placeholderFixupHeight,
-                }}>
-                <div className={'mx_MStickerBody_image_container'}>
-                  { this.state.placeholderVisible &&
-                    <div
-                        className={'mx_MStickerBody_placeholder ' + this.state.placeholderClasses}
-                        style={{
-                            top: placeholderTop,
-                            left: placeholderLeft,
-                        }}
-                    >
-                        <TintableSVG
-                            src={'img/icons-show-stickers.svg'}
-                            width={placeholderSize}
-                            height={placeholderSize} />
-                    </div> }
-                    <img
-                        className={'mx_MStickerBody_image ' + this.state.imageClasses}
-                        src={contentUrl}
-                        style={imageStyle}
-                        ref='image'
-                        alt={content.body}
-                        onLoad={this._onImageLoad}
-                        onMouseEnter={this._onMouseEnter}
-                        onMouseLeave={this._onMouseLeave}
-                    />
-                    { tooltip }
-                </div>
-            </span>
-        );
-    }
-
     // Empty to prevent default behaviour of MImageBody
     onClick() {
     }
+
+    // MStickerBody doesn't need a wrapping `<a href=...>`, but it does need extra padding
+    // which is added by mx_MStickerBody_wrapper
+    wrapImage(contentUrl, children) {
+        return <div className="mx_MStickerBody_wrapper"> { children } </div>;
+    }
+
+    // Placeholder to show in place of the sticker image if
+    // img onLoad hasn't fired yet.
+    getPlaceholder() {
+        const TintableSVG = sdk.getComponent('elements.TintableSvg');
+        return <TintableSVG src="img/icons-show-stickers.svg" width="75" height="75" />;
+    }
+
+    // Tooltip to show on mouse over
+    getTooltip() {
+        const content = this.props.mxEvent && this.props.mxEvent.getContent();
+
+        if (!content || !content.body || !content.info || !content.info.w) return null;
+
+        const RoomTooltip = sdk.getComponent('rooms.RoomTooltip');
+        return <div style={{left: content.info.w + 'px'}} className="mx_MStickerBody_tooltip">
+            <RoomTooltip label={content.body} />
+        </div>;
+    }
+
+    // Don't show "Download this_file.png ..."
+    getFileBody() {
+        return null;
+    }
 }

From e4f8c09c32d9a03567de9a1e9a1954928915e29b Mon Sep 17 00:00:00 2001
From: Luke Barnard <luke@matrix.org>
Date: Tue, 22 May 2018 10:43:16 +0100
Subject: [PATCH 09/12] Only include placeholder in DOM when necessary

---
 src/components/views/messages/MImageBody.js | 17 +++++++++--------
 1 file changed, 9 insertions(+), 8 deletions(-)

diff --git a/src/components/views/messages/MImageBody.js b/src/components/views/messages/MImageBody.js
index f27124238e..e2316b2fcc 100644
--- a/src/components/views/messages/MImageBody.js
+++ b/src/components/views/messages/MImageBody.js
@@ -276,15 +276,16 @@ export default class extends React.Component {
                 { /* Calculate aspect ratio, using %padding will size _container correctly */ }
                 <div style={{ paddingBottom: (100 * content.info.h / content.info.w) + '%' }}></div>
 
-                <div className="mx_MImageBody_thumbnail" style={{
-                    "display": showPlaceholder ? undefined : 'none',
-                    // Constrain width here so that spinner appears central to the loaded thumbnail
-                    "max-width": content.info.w + "px",
-                }}>
-                    <div className="mx_MImageBody_thumbnail_spinner">
-                        { placeholder }
+                { showPlaceholder &&
+                    <div className="mx_MImageBody_thumbnail" style={{
+                        // Constrain width here so that spinner appears central to the loaded thumbnail
+                        "max-width": content.info.w + "px",
+                    }}>
+                        <div className="mx_MImageBody_thumbnail_spinner">
+                            { placeholder }
+                        </div>
                     </div>
-                </div>
+                }
 
                 <div style={{display: !showPlaceholder ? undefined : 'none'}}>
                     { img }

From fb5dd4a410e25e8ea145d9f6cd7ebae74e67f3d9 Mon Sep 17 00:00:00 2001
From: Luke Barnard <luke@matrix.org>
Date: Tue, 22 May 2018 10:46:10 +0100
Subject: [PATCH 10/12] Remove spurious fixupHeight

---
 src/components/views/messages/MImageBody.js | 1 -
 1 file changed, 1 deletion(-)

diff --git a/src/components/views/messages/MImageBody.js b/src/components/views/messages/MImageBody.js
index e2316b2fcc..083dc342db 100644
--- a/src/components/views/messages/MImageBody.js
+++ b/src/components/views/messages/MImageBody.js
@@ -211,7 +211,6 @@ export default class extends React.Component {
                 });
             }).done();
         }
-        this.fixupHeight();
         this._afterComponentDidMount();
     }
 

From c249bee9b53f4718319f7bcc9128059709e55260 Mon Sep 17 00:00:00 2001
From: Luke Barnard <luke@matrix.org>
Date: Tue, 22 May 2018 16:09:54 +0100
Subject: [PATCH 11/12] Grammar

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

diff --git a/src/components/views/messages/MImageBody.js b/src/components/views/messages/MImageBody.js
index 083dc342db..c210c64318 100644
--- a/src/components/views/messages/MImageBody.js
+++ b/src/components/views/messages/MImageBody.js
@@ -240,7 +240,7 @@ export default class extends React.Component {
     _messageContent(contentUrl, thumbUrl, content) {
         // The maximum height of the thumbnail as it is rendered as an <img>
         const maxHeight = Math.min(THUMBNAIL_MAX_HEIGHT, content.info.h);
-        // The maximum width of the thumbnail, as dictated by it's natural
+        // The maximum width of the thumbnail, as dictated by its natural
         // maximum height.
         const maxWidth = content.info.w * maxHeight / content.info.h;
 

From 538979a4ee7b6f9e4d92e57749e35a2bcc5d8689 Mon Sep 17 00:00:00 2001
From: Luke Barnard <luke@matrix.org>
Date: Tue, 22 May 2018 17:13:45 +0100
Subject: [PATCH 12/12] Fix MVideoBody spinner

---
 src/components/views/messages/MVideoBody.js | 7 +------
 1 file changed, 1 insertion(+), 6 deletions(-)

diff --git a/src/components/views/messages/MVideoBody.js b/src/components/views/messages/MVideoBody.js
index 5365daee03..37fc94d1ed 100644
--- a/src/components/views/messages/MVideoBody.js
+++ b/src/components/views/messages/MVideoBody.js
@@ -147,12 +147,7 @@ module.exports = React.createClass({
             // For now add an img tag with a spinner.
             return (
                 <span className="mx_MVideoBody" ref="body">
-                    <div className="mx_MImageBody_thumbnail" ref="image" style={{
-                        "display": "flex",
-                        "align-items": "center",
-                        "justify-items": "center",
-                        "width": "100%",
-                    }}>
+                    <div className="mx_MImageBody_thumbnail mx_MImageBody_thumbnail_spinner" ref="image">
                         <img src="img/spinner.gif" alt={content.body} width="16" height="16" />
                     </div>
                 </span>