From 59b29e4a7f88d5ceca141e9b179310404c5fd54f Mon Sep 17 00:00:00 2001
From: Travis Ralston <travpc@gmail.com>
Date: Fri, 27 Sep 2019 21:08:31 -0600
Subject: [PATCH] Add an option to hide image previews

Applies to images, stickers, and URL previews.

Fixes https://github.com/vector-im/riot-web/issues/10735
---
 res/css/views/messages/_MImageBody.scss       | 15 +++++
 res/css/views/messages/_MStickerBody.scss     | 11 ++++
 src/components/views/messages/MImageBody.js   | 62 ++++++++++++++-----
 src/components/views/messages/MStickerBody.js | 17 ++++-
 .../views/rooms/LinkPreviewWidget.js          |  4 ++
 .../settings/tabs/user/LabsUserSettingsTab.js |  1 +
 src/i18n/strings/en_EN.json                   |  3 +
 src/settings/Settings.js                      |  5 ++
 8 files changed, 102 insertions(+), 16 deletions(-)

diff --git a/res/css/views/messages/_MImageBody.scss b/res/css/views/messages/_MImageBody.scss
index 86bc022829..e5c29ba9e0 100644
--- a/res/css/views/messages/_MImageBody.scss
+++ b/res/css/views/messages/_MImageBody.scss
@@ -19,6 +19,21 @@ limitations under the License.
     margin-right: 34px;
 }
 
+.mx_MImageBody_hidden {
+    position: absolute;
+    left: 0;
+    top: 0;
+    bottom: 0;
+    right: 0;
+    text-align: center;
+    border: 1px dashed $input-border-color;
+
+    // To center the text in the middle of the frame
+    display: flex;
+    align-items: center;
+    justify-content: center;
+}
+
 .mx_MImageBody_thumbnail {
     position: absolute;
     width: 100%;
diff --git a/res/css/views/messages/_MStickerBody.scss b/res/css/views/messages/_MStickerBody.scss
index e4977bcc34..162ee7da86 100644
--- a/res/css/views/messages/_MStickerBody.scss
+++ b/res/css/views/messages/_MStickerBody.scss
@@ -22,3 +22,14 @@ limitations under the License.
     position: absolute;
     top: 50%;
 }
+
+.mx_MStickerBody_hidden {
+    max-width: 220px;
+    text-decoration: none;
+    text-align: center;
+
+    // To center the text in the middle of the frame
+    display: flex;
+    align-items: center;
+    justify-content: center;
+}
diff --git a/src/components/views/messages/MImageBody.js b/src/components/views/messages/MImageBody.js
index de19d0026f..4f165ea7bd 100644
--- a/src/components/views/messages/MImageBody.js
+++ b/src/components/views/messages/MImageBody.js
@@ -64,6 +64,7 @@ export default class MImageBody extends React.Component {
             imgLoaded: false,
             loadedImageDimensions: null,
             hover: false,
+            showImage: SettingsStore.getValue("showImages"),
         };
     }
 
@@ -86,9 +87,19 @@ export default class MImageBody extends React.Component {
         }
     }
 
+    showImage() {
+        localStorage.setItem("mx_ShowImage_" + this.props.mxEvent.getId(), "true");
+        this.setState({showImage: true});
+    }
+
     onClick(ev) {
         if (ev.button === 0 && !ev.metaKey) {
             ev.preventDefault();
+            if (!this.state.showImage) {
+                this.showImage();
+                return;
+            }
+
             const content = this.props.mxEvent.getContent();
             const httpUrl = this._getContentUrl();
             const ImageView = sdk.getComponent("elements.ImageView");
@@ -120,7 +131,7 @@ export default class MImageBody extends React.Component {
     onImageEnter(e) {
         this.setState({ hover: true });
 
-        if (!this._isGif() || SettingsStore.getValue("autoplayGifsAndVideos")) {
+        if (!this.state.showImage || !this._isGif() || SettingsStore.getValue("autoplayGifsAndVideos")) {
             return;
         }
         const imgElement = e.target;
@@ -130,7 +141,7 @@ export default class MImageBody extends React.Component {
     onImageLeave(e) {
         this.setState({ hover: false });
 
-        if (!this._isGif() || SettingsStore.getValue("autoplayGifsAndVideos")) {
+        if (!this.state.showImage || !this._isGif() || SettingsStore.getValue("autoplayGifsAndVideos")) {
             return;
         }
         const imgElement = e.target;
@@ -280,6 +291,12 @@ export default class MImageBody extends React.Component {
                 });
             }).done();
         }
+
+        // Remember that the user wanted to show this particular image
+        if (!this.state.showImage && localStorage.getItem("mx_ShowImage_" + this.props.mxEvent.getId()) === "true") {
+            this.setState({showImage: true});
+        }
+
         this._afterComponentDidMount();
     }
 
@@ -321,13 +338,21 @@ export default class MImageBody extends React.Component {
             // By doing this, the image "pops" into the timeline, but is still restricted
             // by the same width and height logic below.
             if (!this.state.loadedImageDimensions) {
-                return this.wrapImage(contentUrl,
+                let imageElement = (
                     <img style={{display: 'none'}} src={thumbUrl} ref="image"
-                        alt={content.body}
-                        onError={this.onImageError}
-                        onLoad={this.onImageLoad}
-                    />,
+                         alt={content.body}
+                         onError={this.onImageError}
+                         onLoad={this.onImageLoad}
+                    />
                 );
+                if (!this.state.showImage) {
+                    imageElement = (
+                        <div className="mx_MImageBody_hidden">
+                            <span>{_t("Click to show image")}</span>
+                        </div>
+                    );
+                }
+                return this.wrapImage(contentUrl, imageElement);
             }
             infoWidth = this.state.loadedImageDimensions.naturalWidth;
             infoHeight = this.state.loadedImageDimensions.naturalHeight;
@@ -362,13 +387,22 @@ export default class MImageBody extends React.Component {
             // 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={{ maxWidth: maxWidth + "px" }}
-                alt={content.body}
-                onError={this.onImageError}
-                onLoad={this.onImageLoad}
-                onMouseEnter={this.onImageEnter}
-                onMouseLeave={this.onImageLeave} />;
+            img = (
+                <img className="mx_MImageBody_thumbnail" src={thumbUrl} ref="image"
+                     style={{ maxWidth: maxWidth + "px" }}
+                     alt={content.body}
+                     onError={this.onImageError}
+                     onLoad={this.onImageLoad}
+                     onMouseEnter={this.onImageEnter}
+                     onMouseLeave={this.onImageLeave} />
+            );
+            if (!this.state.showImage) {
+                img = (
+                    <div style={{ maxWidth: maxWidth + "px" }} className="mx_MImageBody_hidden">
+                        <span>{_t("Click to show image")}</span>
+                    </div>
+                );
+            }
         }
 
         if (this._isGif() && !SettingsStore.getValue("autoplayGifsAndVideos") && !this.state.hover) {
diff --git a/src/components/views/messages/MStickerBody.js b/src/components/views/messages/MStickerBody.js
index 6a4128dfa7..c3e734a385 100644
--- a/src/components/views/messages/MStickerBody.js
+++ b/src/components/views/messages/MStickerBody.js
@@ -19,10 +19,15 @@ limitations under the License.
 import React from 'react';
 import MImageBody from './MImageBody';
 import sdk from '../../../index';
+import {_t} from "../../../languageHandler";
 
 export default class MStickerBody extends MImageBody {
-    // Empty to prevent default behaviour of MImageBody
-    onClick() {
+    // Mostly empty to prevent default behaviour of MImageBody
+    onClick(ev) {
+        ev.preventDefault();
+        if (!this.state.showImage) {
+            this.showImage();
+        }
     }
 
     // MStickerBody doesn't need a wrapping `<a href=...>`, but it does need extra padding
@@ -34,6 +39,14 @@ export default class MStickerBody extends MImageBody {
     // Placeholder to show in place of the sticker image if
     // img onLoad hasn't fired yet.
     getPlaceholder() {
+        if (!this.state.showImage) {
+            return (
+                <a className="mx_MStickerBody_hidden" onClick={this.onClick} href="#">
+                    <span>{_t("Click to show sticker")}</span>
+                </a>
+            );
+        }
+
         const TintableSVG = sdk.getComponent('elements.TintableSvg');
         return <TintableSVG src={require("../../../../res/img/icons-show-stickers.svg")} width="75" height="75" />;
     }
diff --git a/src/components/views/rooms/LinkPreviewWidget.js b/src/components/views/rooms/LinkPreviewWidget.js
index cfa096a763..d93fe76b46 100644
--- a/src/components/views/rooms/LinkPreviewWidget.js
+++ b/src/components/views/rooms/LinkPreviewWidget.js
@@ -18,6 +18,7 @@ import React from 'react';
 import PropTypes from 'prop-types';
 import createReactClass from 'create-react-class';
 import { linkifyElement } from '../../../HtmlUtils';
+import SettingsStore from "../../../settings/SettingsStore";
 
 const sdk = require('../../../index');
 const MatrixClientPeg = require('../../../MatrixClientPeg');
@@ -102,6 +103,9 @@ module.exports = createReactClass({
 
         // FIXME: do we want to factor out all image displaying between this and MImageBody - especially for lightboxing?
         let image = p["og:image"];
+        if (!SettingsStore.getValue("showImages")) {
+            image = null; // Don't render a button to show the image, just hide it outright
+        }
         const imageMaxWidth = 100; const imageMaxHeight = 100;
         if (image && image.startsWith("mxc://")) {
             image = MatrixClientPeg.get().mxcUrlToHttp(image, imageMaxWidth, imageMaxHeight);
diff --git a/src/components/views/settings/tabs/user/LabsUserSettingsTab.js b/src/components/views/settings/tabs/user/LabsUserSettingsTab.js
index 07a2bf722a..55bd821f95 100644
--- a/src/components/views/settings/tabs/user/LabsUserSettingsTab.js
+++ b/src/components/views/settings/tabs/user/LabsUserSettingsTab.js
@@ -55,6 +55,7 @@ export default class LabsUserSettingsTab extends React.Component {
                     <SettingsFlag name={"showHiddenEventsInTimeline"} level={SettingLevel.DEVICE} />
                     <SettingsFlag name={"lowBandwidth"} level={SettingLevel.DEVICE} />
                     <SettingsFlag name={"sendReadReceipts"} level={SettingLevel.ACCOUNT} />
+                    <SettingsFlag name={"showImages"} level={SettingLevel.ACCOUNT} />
                 </div>
             </div>
         );
diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json
index c23cd6d324..2f496eef00 100644
--- a/src/i18n/strings/en_EN.json
+++ b/src/i18n/strings/en_EN.json
@@ -369,6 +369,7 @@
     "Low bandwidth mode": "Low bandwidth mode",
     "Allow fallback call assist server turn.matrix.org when your homeserver does not offer one (your IP address would be shared during a call)": "Allow fallback call assist server turn.matrix.org when your homeserver does not offer one (your IP address would be shared during a call)",
     "Send read receipts for messages (requires compatible homeserver to disable)": "Send read receipts for messages (requires compatible homeserver to disable)",
+    "Show previews/thumbnails for images": "Show previews/thumbnails for images",
     "Collecting app version information": "Collecting app version information",
     "Collecting logs": "Collecting logs",
     "Uploading report": "Uploading report",
@@ -1032,7 +1033,9 @@
     "Decrypt %(text)s": "Decrypt %(text)s",
     "Download %(text)s": "Download %(text)s",
     "Invalid file%(extra)s": "Invalid file%(extra)s",
+    "Click to show image": "Click to show image",
     "Error decrypting image": "Error decrypting image",
+    "Click to show sticker": "Click to show sticker",
     "Error decrypting video": "Error decrypting video",
     "Agree": "Agree",
     "Disagree": "Disagree",
diff --git a/src/settings/Settings.js b/src/settings/Settings.js
index e0ff16c538..efbbcf214f 100644
--- a/src/settings/Settings.js
+++ b/src/settings/Settings.js
@@ -409,4 +409,9 @@ export const SETTINGS = {
         ),
         default: true,
     },
+    "showImages": {
+        supportedLevels: LEVELS_ACCOUNT_SETTINGS,
+        displayName: _td("Show previews/thumbnails for images"),
+        default: true,
+    },
 };