From 32a44c9820ce22f82f6afbc4f5d72b977d656978 Mon Sep 17 00:00:00 2001
From: Andreas Hatziiliou <andreas.hatziiliou@savoirfairelinux.com>
Date: Tue, 24 Dec 2024 14:08:34 -0500
Subject: [PATCH] chatview: image scaling algorithm

Revise the image scaling algorithm to deal with images
whose aspect ratio was too large or small. Deals with
oversized images as well as images which are too small.

GitLab: #1437
Change-Id: I454e64972ccde1415d80182a2aa89db9656fec1b
---
 .../DataTransferMessageDelegate.qml           | 222 ++++++++----------
 src/app/net/jami/Constants/JamiTheme.qml      |   1 +
 2 files changed, 98 insertions(+), 125 deletions(-)

diff --git a/src/app/commoncomponents/DataTransferMessageDelegate.qml b/src/app/commoncomponents/DataTransferMessageDelegate.qml
index a87ee3447..31fc01143 100644
--- a/src/app/commoncomponents/DataTransferMessageDelegate.qml
+++ b/src/app/commoncomponents/DataTransferMessageDelegate.qml
@@ -22,7 +22,6 @@ import QtQuick
 import QtQuick.Controls
 import QtQuick.Layouts
 import Qt5Compat.GraphicalEffects
-
 import net.jami.Models 1.1
 import net.jami.Constants 1.1
 import net.jami.Adapters 1.1
@@ -44,12 +43,12 @@ Loader {
     property int transferStatus: TransferStatus
     onTidChanged: {
         if (tid === "") {
-            sourceComponent = deletedMsgComp
+            sourceComponent = deletedMsgComp;
         }
     }
     onTransferStatusChanged: {
         if (tid === "") {
-            sourceComponent = deletedMsgComp
+            sourceComponent = deletedMsgComp;
             return;
         } else if (transferStatus === Interaction.TransferStatus.TRANSFER_FINISHED) {
             mediaInfo = MessagesAdapter.getMediaInfo(root.body);
@@ -64,7 +63,11 @@ Loader {
     width: ListView.view ? ListView.view.width : 0
 
     opacity: 0
-    Behavior on opacity { NumberAnimation { duration: 100 } }
+    Behavior on opacity {
+        NumberAnimation {
+            duration: 100
+        }
+    }
     onLoaded: opacity = 1
 
     Component {
@@ -93,9 +96,9 @@ Loader {
                     bottomPadding: 6
                     topPadding: 6
                     leftPadding: 10
-                    text: UtilsAdapter.getBestNameForUri(CurrentAccount.id, Author) + " " + JamiStrings.deletedMedia ;
+                    text: UtilsAdapter.getBestNameForUri(CurrentAccount.id, Author) + " " + JamiStrings.deletedMedia
                     horizontalAlignment: Text.AlignLeft
-                    width:  Math.min((2 / 3) * parent.width, implicitWidth + 18, innerContent.width - senderMargin + 18)
+                    width: Math.min((2 / 3) * parent.width, implicitWidth + 18, innerContent.width - senderMargin + 18)
 
                     font.pointSize: JamiTheme.smallFontSize
                     font.hintingPreference: Font.PreferNoHinting
@@ -107,8 +110,8 @@ Loader {
                     opacity: 0.5
 
                     function getBaseColor() {
-                        bubble.isDeleted = true
-                        return UtilsAdapter.luma(bubble.color) ? "white" : "dark"
+                        bubble.isDeleted = true;
+                        return UtilsAdapter.luma(bubble.color) ? "white" : "dark";
                     }
                 }
             ]
@@ -124,9 +127,7 @@ Loader {
             transferId: Id
             property var transferStats: MessagesAdapter.getTransferStats(transferId, root.transferStatus)
             property bool canOpen: root.transferStatus === Interaction.TransferStatus.TRANSFER_FINISHED || isOutgoing
-            property real maxMsgWidth: root.width - senderMargin -
-                                       2 * hPadding - avatarBlockWidth
-                                       - buttonsLoader.width - 24 - 6 - 24
+            property real maxMsgWidth: root.width - senderMargin - 2 * hPadding - avatarBlockWidth - buttonsLoader.width - 24 - 6 - 24
 
             isOutgoing: Author === CurrentAccount.uri
             showTime: root.showTime
@@ -150,14 +151,12 @@ Loader {
                         enabled: canOpen
                         onHoveredChanged: {
                             if (enabled && hovered) {
-                                dataTransferItem.hoveredLink = UtilsAdapter.urlFromLocalPath(location)
+                                dataTransferItem.hoveredLink = UtilsAdapter.urlFromLocalPath(location);
                             } else {
-                                dataTransferItem.hoveredLink = ""
+                                dataTransferItem.hoveredLink = "";
                             }
                         }
-                        cursorShape: enabled ?
-                                         Qt.PointingHandCursor :
-                                         Qt.ArrowCursor
+                        cursorShape: enabled ? Qt.PointingHandCursor : Qt.ArrowCursor
                     }
                     Loader {
                         id: buttonsLoader
@@ -171,21 +170,21 @@ Loader {
                             switch (root.transferStatus) {
                             case Interaction.TransferStatus.TRANSFER_CREATED:
                             case Interaction.TransferStatus.TRANSFER_FINISHED:
-                                iconSource = JamiResources.link_black_24dp_svg
-                                return terminatedComp
+                                iconSource = JamiResources.link_black_24dp_svg;
+                                return terminatedComp;
                             case Interaction.TransferStatus.TRANSFER_CANCELED:
                             case Interaction.TransferStatus.TRANSFER_ERROR:
                             case Interaction.TransferStatus.TRANSFER_UNJOINABLE_PEER:
                             case Interaction.TransferStatus.TRANSFER_TIMEOUT_EXPIRED:
                             case Interaction.TransferStatus.TRANSFER_AWAITING_HOST:
-                                iconSource = JamiResources.download_black_24dp_svg
-                                return optionsComp
+                                iconSource = JamiResources.download_black_24dp_svg;
+                                return optionsComp;
                             case Interaction.TransferStatus.TRANSFER_ONGOING:
-                                iconSource = JamiResources.close_black_24dp_svg
-                                return optionsComp
+                                iconSource = JamiResources.close_black_24dp_svg;
+                                return optionsComp;
                             default:
-                                iconSource = JamiResources.error_outline_black_24dp_svg
-                                return terminatedComp
+                                iconSource = JamiResources.error_outline_black_24dp_svg;
+                                return terminatedComp;
                             }
                         }
                         Component {
@@ -216,9 +215,9 @@ Loader {
                                 imageColor: JamiTheme.chatviewButtonColor
                                 onClicked: {
                                     if (root.transferStatus === Interaction.TransferStatus.TRANSFER_ONGOING) {
-                                        return MessagesAdapter.cancelFile(transferId)
+                                        return MessagesAdapter.cancelFile(transferId);
                                     } else {
-                                        return MessagesAdapter.acceptFile(transferId)
+                                        return MessagesAdapter.acceptFile(transferId);
                                     }
                                 }
                             }
@@ -230,27 +229,21 @@ Loader {
                         TextEdit {
                             width: Math.min(implicitWidth, maxMsgWidth)
                             topPadding: 10
-                            text: CurrentConversation.isSwarm ?
-                                      transferName :
-                                      location
+                            text: CurrentConversation.isSwarm ? transferName : location
                             wrapMode: Label.WrapAtWordBoundaryOrAnywhere
                             font.pointSize: 11
                             renderType: Text.NativeRendering
                             readOnly: true
-                            color: UtilsAdapter.luma(bubble.color)
-                                   ? JamiTheme.chatviewTextColorLight
-                                   : JamiTheme.chatviewTextColorDark
+                            color: UtilsAdapter.luma(bubble.color) ? JamiTheme.chatviewTextColorLight : JamiTheme.chatviewTextColorDark
                             MouseArea {
                                 anchors.fill: parent
-                                cursorShape: canOpen ?
-                                                 Qt.PointingHandCursor :
-                                                 Qt.ArrowCursor
+                                cursorShape: canOpen ? Qt.PointingHandCursor : Qt.ArrowCursor
                                 onClicked: function (mouse) {
                                     if (canOpen) {
-                                        dataTransferItem.hoveredLink = UtilsAdapter.urlFromLocalPath(location)
-                                        Qt.openUrlExternally(new URL(dataTransferItem.hoveredLink))
+                                        dataTransferItem.hoveredLink = UtilsAdapter.urlFromLocalPath(location);
+                                        Qt.openUrlExternally(new URL(dataTransferItem.hoveredLink));
                                     } else {
-                                        dataTransferItem.hoveredLink = ""
+                                        dataTransferItem.hoveredLink = "";
                                     }
                                 }
                             }
@@ -261,23 +254,20 @@ Loader {
                             width: Math.min(implicitWidth, maxMsgWidth)
                             bottomPadding: 10
                             text: {
-                                var res = ""
+                                var res = "";
                                 if (transferStats.totalSize !== undefined) {
-                                    if (transferStats.progress !== 0 &&
-                                            transferStats.progress !== transferStats.totalSize) {
-                                        res += UtilsAdapter.humanFileSize(transferStats.progress) + " / "
+                                    if (transferStats.progress !== 0 && transferStats.progress !== transferStats.totalSize) {
+                                        res += UtilsAdapter.humanFileSize(transferStats.progress) + " / ";
                                     }
-                                    var totalSize = transferStats.totalSize !== 0 ? transferStats.totalSize : TotalSize
-                                    res += UtilsAdapter.humanFileSize(totalSize)
+                                    var totalSize = transferStats.totalSize !== 0 ? transferStats.totalSize : TotalSize;
+                                    res += UtilsAdapter.humanFileSize(totalSize);
                                 }
-                                return res
+                                return res;
                             }
                             wrapMode: Label.WrapAtWordBoundaryOrAnywhere
                             font.pointSize: 10
                             renderType: Text.NativeRendering
-                            color: UtilsAdapter.luma(bubble.color)
-                                   ? JamiTheme.chatviewTextColorLight
-                                   : JamiTheme.chatviewTextColorDark
+                            color: UtilsAdapter.luma(bubble.color) ? JamiTheme.chatviewTextColorLight : JamiTheme.chatviewTextColorDark
                         }
                     }
                 },
@@ -316,23 +306,23 @@ Loader {
 
             Component.onCompleted: {
                 if (transferStats.totalSize !== undefined) {
-                    var totalSize = transferStats.totalSize !== 0 ? transferStats.totalSize : TotalSize
-                    var txt = UtilsAdapter.humanFileSize(totalSize)
+                    var totalSize = transferStats.totalSize !== 0 ? transferStats.totalSize : TotalSize;
+                    var txt = UtilsAdapter.humanFileSize(totalSize);
                 }
-                bubble.timestampItem.timeLabel.text += " - " + txt
-                bubble.color = "transparent"
+                bubble.timestampItem.timeLabel.text += " - " + txt;
+                bubble.color = "transparent";
                 if (mediaInfo.isImage)
-                    bubble.z = 1
+                    bubble.z = 1;
                 else
-                    timeUnderBubble = true
+                    timeUnderBubble = true;
             }
 
             onContentWidthChanged: {
                 if (bubble.timestampItem.timeLabel.width > contentWidth)
-                    timeUnderBubble = true
+                    timeUnderBubble = true;
                 else {
-                    bubble.timestampItem.timeColor = JamiTheme.whiteColor
-                    bubble.timestampItem.timeLabel.opacity = 1
+                    bubble.timestampItem.timeColor = JamiTheme.whiteColor;
+                    bubble.timestampItem.timeLabel.opacity = 1;
                 }
             }
 
@@ -346,10 +336,10 @@ Loader {
                     height: sourceComponent.height
                     sourceComponent: {
                         if (mediaInfo.isImage)
-                            return imageComp
+                            return imageComp;
                         if (mediaInfo.isAnimatedImage)
-                            return animatedImageComp
-                        return avComp
+                            return animatedImageComp;
+                        return avComp;
                     }
 
                     Component {
@@ -357,10 +347,11 @@ Loader {
 
                         Loader {
                             Component.onCompleted: {
-                                var qml = WITH_WEBENGINE ?
-                                            "qrc:/webengine/MediaPreviewBase.qml" :
-                                            "qrc:/nowebengine/MediaPreviewBase.qml"
-                                setSource( qml, { isVideo: mediaInfo.isVideo, html: mediaInfo.html } )
+                                var qml = WITH_WEBENGINE ? "qrc:/webengine/MediaPreviewBase.qml" : "qrc:/nowebengine/MediaPreviewBase.qml";
+                                setSource(qml, {
+                                        isVideo: mediaInfo.isVideo,
+                                        html: mediaInfo.html
+                                    });
                             }
                         }
                     }
@@ -381,9 +372,7 @@ Loader {
                             asynchronous: true
                             source: UtilsAdapter.urlFromLocalPath(Body)
                             property real aspectRatio: implicitWidth / implicitHeight
-                            property real adjustedWidth: Math.min(maxSize,
-                                                                  Math.max(minSize,
-                                                                           innerContent.width - senderMargin))
+                            property real adjustedWidth: Math.min(maxSize, Math.max(minSize, innerContent.width - senderMargin))
                             width: adjustedWidth
                             height: Math.ceil(adjustedWidth / aspectRatio)
                             Rectangle {
@@ -403,7 +392,7 @@ Loader {
                             }
 
                             onWidthChanged: {
-                                localMediaMsgItem.contentWidth = width
+                                localMediaMsgItem.contentWidth = width;
                             }
 
                             Component.onCompleted: localMediaMsgItem.bubble.imgSource = source
@@ -429,71 +418,54 @@ Loader {
                     Component {
                         id: imageComp
 
-                        Image {
-                            id: img
-
+                        Rectangle {
+                            border.color: img.useBox ? (JamiTheme.darkTheme ? "white" : JamiTheme.blackColor) : JamiTheme.transparentColor
+                            color: JamiTheme.transparentColor
                             anchors.right: isOutgoing ? parent.right : undefined
-                            cache: true
-                            fillMode: Image.PreserveAspectFit
-                            mipmap: true
-                            antialiasing: true
-                            autoTransform: true
-                            asynchronous: true
+                            border.width: 1
+                            radius: msgRadius
 
-                            Component.onCompleted: {
-                                source = UtilsAdapter.urlFromLocalPath(Body);
-                                localMediaMsgItem.bubble.imgSource = source;
+                            implicitWidth: img.width + (img.useBox ? 20 : 0)
+                            implicitHeight: img.height + (img.useBox ? 20 : 0)
+                            onWidthChanged: {
+                                localMediaMsgItem.contentWidth = width;
                             }
 
-                            // The sourceSize represents the maximum source dimensions.
-                            // This should not be a dynamic binding, as property changes
-                            // (resizing the chat view) here will trigger a reload of the image.
-                            sourceSize: Qt.size(256, 256)
-
-                            // Now we setup bindings for the destination image component size.
-                            // This based on the width available (width of the chat view), and
-                            // a restriction on the height.
-                            readonly property real aspectRatio: paintedWidth / paintedHeight
-                            readonly property real idealWidth: innerContent.width - senderMargin
-                            onStatusChanged: {
-                                if (img.status == Image.Ready && aspectRatio) {
-                                    height = Qt.binding(() => JamiQmlUtils.clamp(idealWidth / aspectRatio, 64, 256))
-                                    width = Qt.binding(() => height * aspectRatio)
-                                }
-                            }
+                            Image {
+                                id: img
 
-                            onWidthChanged: {
-                                localMediaMsgItem.contentWidth = width
-                            }
+                                anchors.centerIn: parent
+                                cache: true
+                                fillMode: Image.PreserveAspectFit
+                                mipmap: true
+                                antialiasing: true
+                                autoTransform: true
+                                asynchronous: true
 
-                            Rectangle {
-                                color: JamiTheme.previewImageBackgroundColor
-                                z: -1
-                                anchors.fill: parent
-                            }
-                            layer.enabled: true
-                            layer.effect: OpacityMask {
-                                maskSource: MessageBubble {
-                                    out: isOutgoing
-                                    type: seq
-                                    width: img.width
-                                    height: img.height
-                                    radius: msgRadius
+                                Component.onCompleted: {
+                                    source = UtilsAdapter.urlFromLocalPath(Body);
+                                    localMediaMsgItem.bubble.imgSource = source;
                                 }
-                            }
 
-                            LinearGradient {
-                                id: gradient
-                                anchors.fill: parent
-                                start: Qt.point(0, height / 3)
-                                gradient: Gradient {
-                                    GradientStop {
-                                        position: 0.0
-                                        color: JamiTheme.transparentColor
-                                    }
-                                    GradientStop {
-                                        position: 1.0
-                                        color: JamiTheme.darkGreyColorOpacityFade
+                                // Scale down the image if it's too wide or too tall.
+                                property real maxWidth: localMediaMsgItem.width - 170
+                                property bool xOverflow: sourceSize.width > maxWidth
+                                property bool yOverflow: sourceSize.height > JamiTheme.maxImageHeight
+                                property real scaleFactor: (xOverflow || yOverflow) ? Math.min(maxWidth / sourceSize.width, JamiTheme.maxImageHeight / sourceSize.height) : 1
+                                width: sourceSize.width * scaleFactor
+                                height: sourceSize.height * scaleFactor
+
+                                // Add a bounding box around the image if it's small (along at least one
+                                // dimension) to ensure that it's easy for users to see it and click on it.
+                                property bool useBox: (paintedWidth < 40) || (paintedHeight < 40)
+                                layer.enabled: !useBox
+                                layer.effect: OpacityMask {
+                                    maskSource: MessageBubble {
+                                        out: isOutgoing
+                                        type: seq
+                                        width: img.width
+                                        height: img.height
+                                        radius: msgRadius
                                     }
                                 }
                             }
diff --git a/src/app/net/jami/Constants/JamiTheme.qml b/src/app/net/jami/Constants/JamiTheme.qml
index 2f148072c..649ccc0d4 100644
--- a/src/app/net/jami/Constants/JamiTheme.qml
+++ b/src/app/net/jami/Constants/JamiTheme.qml
@@ -255,6 +255,7 @@ Item {
     property color messageWebViewFooterButtonImageColor: darkTheme ? "#838383" : "#656565"
     property color chatviewSecondaryInformationColor: "#A7A7A7"
     property color draftIconColor: "#707070"
+    property real maxImageHeight: 375
 
     // ChatView Footer
     property color chatViewFooterListColor: darkTheme ? blackColor : "#E5E5E5"
-- 
GitLab