From e85d4506dec0f82af8b9a2966ff528cae4881690 Mon Sep 17 00:00:00 2001 From: Andreas Traczyk <andreas.traczyk@savoirfairelinux.com> Date: Mon, 27 Sep 2021 10:29:07 -0400 Subject: [PATCH] chatview: implement side-by-side styling + fix multiline richtext + add SBS msg bubbles + limit timestamp visibility + render link previews + render data transfer interactions Gitlab: #467 Change-Id: I80b6db33d786180d479730213855b9816bea4793 --- qml.qrc | 3 + .../icons/baseline_error_outline_24dp.svg | 1 - resources/icons/close_black_24dp.svg | 1 + resources/icons/download_black_24dp.svg | 1 + resources/icons/error_outline_black_24dp.svg | 1 + .../AccountMigrationDialog.qml | 2 +- src/commoncomponents/MessageBubble.qml | 46 ++ src/commoncomponents/MessageDelegate.qml | 617 ++++++++++++++---- src/commoncomponents/SBSMessageBase.qml | 115 ++++ src/constant/JamiTheme.qml | 2 +- src/constant/MsgSeq.qml | 9 + src/mainview/components/MessageListView.qml | 122 +++- src/messagesadapter.cpp | 95 ++- src/messagesadapter.h | 12 +- src/qmlregister.cpp | 1 + 15 files changed, 888 insertions(+), 140 deletions(-) delete mode 100644 resources/icons/baseline_error_outline_24dp.svg create mode 100644 resources/icons/close_black_24dp.svg create mode 100644 resources/icons/download_black_24dp.svg create mode 100644 resources/icons/error_outline_black_24dp.svg create mode 100644 src/commoncomponents/MessageBubble.qml create mode 100644 src/commoncomponents/SBSMessageBase.qml create mode 100644 src/constant/MsgSeq.qml diff --git a/qml.qrc b/qml.qrc index 8597566ea..67fb8da2c 100644 --- a/qml.qrc +++ b/qml.qrc @@ -163,5 +163,8 @@ <file>src/mainview/components/ReadOnlyFooter.qml</file> <file>src/commoncomponents/MessageDelegate.qml</file> <file>src/mainview/components/MessageListView.qml</file> + <file>src/commoncomponents/MessageBubble.qml</file> + <file>src/constant/MsgSeq.qml</file> + <file>src/commoncomponents/SBSMessageBase.qml</file> </qresource> </RCC> diff --git a/resources/icons/baseline_error_outline_24dp.svg b/resources/icons/baseline_error_outline_24dp.svg deleted file mode 100644 index 39062fa75..000000000 --- a/resources/icons/baseline_error_outline_24dp.svg +++ /dev/null @@ -1 +0,0 @@ -<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path fill="none" d="M0 0h24v24H0V0z"/><path d="M11 15h2v2h-2zm0-8h2v6h-2zm.99-5C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zM12 20c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8z"/></svg> \ No newline at end of file diff --git a/resources/icons/close_black_24dp.svg b/resources/icons/close_black_24dp.svg new file mode 100644 index 000000000..5f1267d71 --- /dev/null +++ b/resources/icons/close_black_24dp.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 0 24 24" width="24px" fill="#000000"><path d="M0 0h24v24H0V0z" fill="none"/><path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12 19 6.41z"/></svg> \ No newline at end of file diff --git a/resources/icons/download_black_24dp.svg b/resources/icons/download_black_24dp.svg new file mode 100644 index 000000000..aef604333 --- /dev/null +++ b/resources/icons/download_black_24dp.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 0 24 24" width="24px" fill="#000000"><path d="M0 0h24v24H0V0z" fill="none"/><path d="M19 9h-4V3H9v6H5l7 7 7-7zm-8 2V5h2v6h1.17L12 13.17 9.83 11H11zm-6 7h14v2H5z"/></svg> \ No newline at end of file diff --git a/resources/icons/error_outline_black_24dp.svg b/resources/icons/error_outline_black_24dp.svg new file mode 100644 index 000000000..a0481c0d6 --- /dev/null +++ b/resources/icons/error_outline_black_24dp.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 0 24 24" width="24px" fill="#000000"><path d="M11 15h2v2h-2v-2zm0-8h2v6h-2V7zm.99-5C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zM12 20c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8z"/></svg> \ No newline at end of file diff --git a/src/commoncomponents/AccountMigrationDialog.qml b/src/commoncomponents/AccountMigrationDialog.qml index 04db7cb1b..04ddf73b8 100644 --- a/src/commoncomponents/AccountMigrationDialog.qml +++ b/src/commoncomponents/AccountMigrationDialog.qml @@ -518,7 +518,7 @@ Window { import \"qrc:/src/constant/\"; Image { anchors.fill: parent; - source: JamiResources.baseline_error_outline_24dp_svg; + source: JamiResources.error_outline_black_24dp_svg; mipmap: true;}", spinnerLabel) break } diff --git a/src/commoncomponents/MessageBubble.qml b/src/commoncomponents/MessageBubble.qml new file mode 100644 index 000000000..621769160 --- /dev/null +++ b/src/commoncomponents/MessageBubble.qml @@ -0,0 +1,46 @@ +/* + * Copyright (C) 2021 by Savoir-faire Linux + * Author: Andreas Traczyk <andreas.traczyk@savoirfairelinux.com> + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see <https://www.gnu.org/licenses/>. + */ + +import QtQuick 2.15 +import QtGraphicalEffects 1.0 + +import net.jami.Constants 1.1 + +Rectangle { + id: root + + property bool out: true + property int type: MsgSeq.single + + Rectangle { + id: mask + + visible: type !== MsgSeq.single + z: -1 + radius: 2 + color: root.color + + anchors { + fill: parent + leftMargin: out ? root.width - root.radius : 0 + rightMargin: out ? 0 : root.width - root.radius + topMargin: type === MsgSeq.first ? root.height - root.radius : 0 + bottomMargin: type === MsgSeq.last ? root.height - root.radius : 0 + } + } +} diff --git a/src/commoncomponents/MessageDelegate.qml b/src/commoncomponents/MessageDelegate.qml index fca345efa..210530e95 100644 --- a/src/commoncomponents/MessageDelegate.qml +++ b/src/commoncomponents/MessageDelegate.qml @@ -1,6 +1,26 @@ -import QtQuick 2.15 +/* + * Copyright (C) 2021 by Savoir-faire Linux + * Author: Trevor Tabah <trevor.tabah@savoirfairelinux.com> + * Author: Andreas Traczyk <andreas.traczyk@savoirfairelinux.com> + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see <https://www.gnu.org/licenses/>. + */ + +import QtQuick 2.15 import QtQuick.Controls 2.15 import QtQuick.Layouts 1.15 +import QtGraphicalEffects 1.15 import QtWebEngine 1.10 import net.jami.Models 1.1 @@ -10,33 +30,213 @@ import net.jami.Constants 1.1 Control { id: root + readonly property ListView listView: ListView.view + readonly property bool isGenerated: Type === Interaction.Type.CALL || Type === Interaction.Type.CONTACT readonly property string author: Author + readonly property var body: Body readonly property var timestamp: Timestamp readonly property bool isOutgoing: model.Author === "" readonly property var formattedTime: MessagesAdapter.getFormattedTime(Timestamp) - readonly property bool isImage: MessagesAdapter.isImage(Body) - readonly property bool isAnimatedImage: MessagesAdapter.isAnimatedImage(Body) - readonly property var linkPreviewInfo: LinkPreviewInfo + readonly property var linkInfo: LinkPreviewInfo + property var mediaInfo - readonly property var body: Body - readonly property real msgMargin: 64 + readonly property real senderMargin: 64 + readonly property real avatarSize: 32 + readonly property real msgRadius: 18 + readonly property real hMargin: 12 + + property bool showTime: false + property int seq: MsgSeq.single width: parent ? parent.width : 0 height: loader.height + // message interaction + property string hoveredLink + MouseArea { + id: itemMouseArea + anchors.fill: parent + acceptedButtons: Qt.LeftButton + onClicked: { + if (root.hoveredLink) + Qt.openUrlExternally(root.hoveredLink) + } + } + Loader { id: loader - property alias isOutgoing: root.isOutgoing - property alias isGenerated: root.isGenerated - readonly property var author: Author - readonly property var body: Body + width: root.width + height: sourceComponent.height + + sourceComponent: { + switch (Type) { + case Interaction.Type.TEXT: return textMsgComp + case Interaction.Type.CALL: + case Interaction.Type.CONTACT: return generatedMsgComp + case Interaction.Type.DATA_TRANSFER: + if (Status === Interaction.Status.TRANSFER_FINISHED) { + mediaInfo = MessagesAdapter.getMediaInfo(Body) + if (Object.keys(mediaInfo).length !== 0) + return localMediaMsgComp + } + return dataTransferMsgComp + default: + // if this happens, adjust FilteredMsgListModel + console.warn("Invalid message type has not been filtered.") + return null + } + } + } - sourceComponent: isGenerated ? - generatedMsgComp : - userMsgComp + Component { + id: textMsgComp + + SBSMessageBase { + property real maxMsgWidth: root.width - senderMargin - 2 * hMargin - avatarBlockWidth + property bool isRemoteImage + isOutgoing: root.isOutgoing + showTime: root.showTime + seq: root.seq + author: root.author + formattedTime: root.formattedTime + extraHeight: extraContent.active && !isRemoteImage ? msgRadius : -isRemoteImage + innerContent.children: [ + TextEdit { + padding: 10 + anchors.right: isOutgoing ? parent.right : undefined + text: '<span style="white-space: pre-wrap">' + body + '</span>' + width: { + if (extraContent.active) + Math.max(extraContent.width, + Math.min(implicitWidth - avatarBlockWidth, + extraContent.minSize) - senderMargin) + else + Math.min(implicitWidth, innerContent.width - senderMargin) + } + height: implicitHeight + wrapMode: Label.WrapAtWordBoundaryOrAnywhere + selectByMouse: true + font.pointSize: 11 + font.hintingPreference: Font.PreferNoHinting + renderType: Text.NativeRendering + textFormat: TextEdit.RichText + onLinkHovered: root.hoveredLink = hoveredLink + onLinkActivated: Qt.openUrlExternally(hoveredLink) + readOnly: true + color: isOutgoing ? + JamiTheme.messageOutTxtColor : + JamiTheme.messageInTxtColor + }, + Loader { + id: extraContent + width: sourceComponent.width + height: sourceComponent.height + anchors.right: isOutgoing ? parent.right : undefined + property real minSize: 192 + property real maxSize: 320 + active: linkInfo.url !== undefined + sourceComponent: ColumnLayout { + id: previewContent + spacing: 12 + Component.onCompleted: { + isRemoteImage = MessagesAdapter.isRemoteImage(linkInfo.url) + } + HoverHandler { + target: previewContent + onHoveredChanged: { + root.hoveredLink = hovered ? linkInfo.url : "" + } + cursorShape: Qt.PointingHandCursor + } + AnimatedImage { + id: img + cache: true + source: isRemoteImage ? + linkInfo.url : + (hasImage ? linkInfo.image : "") + fillMode: Image.PreserveAspectCrop + mipmap: true + antialiasing: true + autoTransform: true + asynchronous: true + readonly property bool hasImage: linkInfo.image !== null + property real aspectRatio: implicitWidth / implicitHeight + property real adjustedWidth: Math.min(extraContent.maxSize, + Math.max(extraContent.minSize, + maxMsgWidth)) + Layout.preferredWidth: adjustedWidth + Layout.preferredHeight: Math.ceil(adjustedWidth / aspectRatio) + Rectangle { + color: JamiTheme.previewImageBackgroundColor + z: -1 + anchors.fill: parent + } + layer.enabled: isRemoteImage + layer.effect: OpacityMask { + maskSource: MessageBubble { + Rectangle { height: msgRadius; width: parent.width } + out: isOutgoing + type: seq + width: img.width + height: img.height + radius: msgRadius + } + } + } + Column { + opacity: img.status !== Image.Loading + visible: !isRemoteImage + Layout.preferredWidth: img.width - 2 * hMargin + Layout.leftMargin: hMargin + Layout.rightMargin: hMargin + spacing: 6 + Label { + width: parent.width + font.pointSize: 10 + font.hintingPreference: Font.PreferNoHinting + wrapMode: Label.WrapAtWordBoundaryOrAnywhere + renderType: Text.NativeRendering + textFormat: TextEdit.RichText + color: JamiTheme.previewTitleColor + visible: linkInfo.title !== null + text: linkInfo.title + } + Label { + width: parent.width + font.pointSize: 11 + font.hintingPreference: Font.PreferNoHinting + wrapMode: Label.WrapAtWordBoundaryOrAnywhere + renderType: Text.NativeRendering + textFormat: TextEdit.RichText + color: JamiTheme.previewSubtitleColor + visible: linkInfo.description !== null + text: '<a href=" " style="text-decoration: ' + + ( hoveredLink ? 'underline' : 'none') + ';"' + + '>' + linkInfo.description + '</a>' + } + Label { + width: parent.width + font.pointSize: 10 + font.hintingPreference: Font.PreferNoHinting + wrapMode: Label.WrapAtWordBoundaryOrAnywhere + renderType: Text.NativeRendering + textFormat: TextEdit.RichText + color: JamiTheme.previewSubtitleColor + text: linkInfo.domain + } + } + } + } + ] + Component.onCompleted: { + if (!Linkified) { + MessagesAdapter.parseMessageUrls(Id, Body) + } + } + } } Component { @@ -45,13 +245,14 @@ Control { Column { width: root.width spacing: 2 + topPadding: 12 + bottomPadding: 12 - TextArea { + Label { width: parent.width text: body horizontalAlignment: Qt.AlignHCenter - readOnly: true - font.pointSize: 11 + font.pointSize: 12 color: JamiTheme.chatviewTextColor } @@ -61,131 +262,303 @@ Control { width: parent.width height: childrenRect.height - Component.onCompleted: children = timestampLabel - } + Label { + text: formattedTime + color: JamiTheme.timestampColor + visible: showTime || seq === MsgSeq.last + height: visible * implicitHeight + font.pointSize: 9 - bottomPadding: 12 + anchors.horizontalCenter: parent.horizontalCenter + } + } } } Component { - id: userMsgComp - - GridLayout { - id: gridLayout - - width: root.width - - columns: 2 - rows: 2 - - columnSpacing: 2 - rowSpacing: 2 - - Column { - id: msgCell - - Layout.column: isOutgoing ? 0 : 1 - Layout.row: 0 - Layout.fillWidth: true - Layout.maximumWidth: 640 - Layout.preferredHeight: childrenRect.height - Layout.alignment: isOutgoing ? Qt.AlignRight : Qt.AlignLeft - Layout.leftMargin: isOutgoing ? msgMargin : 0 - Layout.rightMargin: isOutgoing ? 0 : msgMargin - - Control { - id: msgBlock - - width: parent.width - - contentItem: Column { - id: msgContent - - property real txtWidth: ta.contentWidth + 3 * ta.padding - - TextArea { - id: ta - width: parent.width - text: body - padding: 10 + id: dataTransferMsgComp + + SBSMessageBase { + id: dataTransferItem + property var transferStats: MessagesAdapter.getTransferStats(Id, Status) + property bool canOpen: Status === Interaction.Status.TRANSFER_FINISHED || isOutgoing + property real maxMsgWidth: root.width - senderMargin - + 2 * hMargin - avatarBlockWidth + - buttonsLoader.width - 24 - 6 - 24 + isOutgoing: root.isOutgoing + showTime: root.showTime + seq: root.seq + author: root.author + formattedTime: root.formattedTime + extraHeight: progressBar.visible ? 18 : 0 + innerContent.children: [ + RowLayout { + id: transferItem + spacing: 6 + anchors.right: isOutgoing ? parent.right : undefined + HoverHandler { + target: parent + enabled: canOpen + onHoveredChanged: { + root.hoveredLink = enabled && hovered ? + ("file:///" + body) : + "" + } + cursorShape: enabled ? + Qt.PointingHandCursor : + Qt.ArrowCursor + } + Loader { + id: buttonsLoader + + property string iconSourceA + property string iconSourceB + + Layout.margins: 12 + + sourceComponent: { + switch (Status) { + case Interaction.Status.TRANSFER_CANCELED: + case Interaction.Status.TRANSFER_ERROR: + case Interaction.Status.TRANSFER_UNJOINABLE_PEER: + case Interaction.Status.TRANSFER_TIMEOUT_EXPIRED: + iconSourceA = JamiResources.error_outline_black_24dp_svg + return terminatedComp + case Interaction.Status.TRANSFER_CREATED: + case Interaction.Status.TRANSFER_FINISHED: + iconSourceA = JamiResources.link_black_24dp_svg + return terminatedComp + case Interaction.Status.TRANSFER_AWAITING_HOST: + iconSourceA = JamiResources.download_black_24dp_svg + iconSourceB = JamiResources.close_black_24dp_svg + return optionsComp + case Interaction.Status.TRANSFER_ONGOING: + iconSourceA = JamiResources.close_black_24dp_svg + return optionsComp + default: + iconSourceA = JamiResources.error_outline_black_24dp_svg + return terminatedComp + } + } + Component { + id: terminatedComp + ResponsiveImage { + source: buttonsLoader.iconSourceA + Layout.leftMargin: 12 + Layout.preferredWidth: 24 + Layout.preferredHeight: 24 + } + } + Component { + id: optionsComp + ColumnLayout { + Layout.leftMargin: 12 + PushButton { + source: buttonsLoader.iconSourceA + normalColor: JamiTheme.chatviewBgColor + imageColor: JamiTheme.chatviewButtonColor + onClicked: { + switch (Status) { + case Interaction.Status.TRANSFER_ONGOING: + return MessagesAdapter.cancelFile(Id) + case Interaction.Status.TRANSFER_AWAITING_HOST: + return MessagesAdapter.acceptFile(Id) + default: break + } + } + } + PushButton { + visible: !CurrentConversation.isSwarm + height: visible * implicitHeight + source: buttonsLoader.iconSourceB + normalColor: JamiTheme.chatviewBgColor + imageColor: JamiTheme.chatviewButtonColor + onClicked: { + switch (Status) { + case Interaction.Status.TRANSFER_AWAITING_HOST: + return MessagesAdapter.cancelFile(Id) + default: break + } + } + } + } + } + } + Column { + Layout.rightMargin: 24 + spacing: 6 + TextEdit { + id: transferName + width: Math.min(implicitWidth, maxMsgWidth) + topPadding: 10 + text: CurrentConversation.isSwarm ? + TransferName : + body + wrapMode: Label.WrapAtWordBoundaryOrAnywhere + font.weight: Font.DemiBold font.pointSize: 11 - font.hintingPreference: Font.PreferNoHinting renderType: Text.NativeRendering - textFormat: TextEdit.RichText - wrapMode: Text.WrapAtWordBoundaryOrAnywhere - transform: Translate { x: bg.x } - rightPadding: isOutgoing ? padding * 1.5 : 0 + readOnly: true color: isOutgoing ? JamiTheme.messageOutTxtColor : JamiTheme.messageInTxtColor + MouseArea { + anchors.fill: parent + cursorShape: canOpen ? + Qt.PointingHandCursor : + Qt.ArrowCursor + onClicked: if(canOpen) itemMouseArea.clicked(mouse) + } + } + Label { + id: transferInfo + width: Math.min(implicitWidth, maxMsgWidth) + bottomPadding: 10 + text: { + var res = formattedTime + " - " + if (transferStats.totalSize !== undefined) { + if (transferStats.progress !== 0 && + transferStats.progress !== transferStats.totalSize) { + res += UtilsAdapter.humanFileSize(transferStats.progress) + " / " + } + res += UtilsAdapter.humanFileSize(transferStats.totalSize) + } + return res + " - " + MessagesAdapter.getStatusString(Status) + } + wrapMode: Label.WrapAtWordBoundaryOrAnywhere + font.pointSize: 10 + renderType: Text.NativeRendering + color: Qt.lighter((isOutgoing ? + JamiTheme.messageOutTxtColor : + JamiTheme.messageInTxtColor), 1.5) } - } - background: Rectangle { - id: bg - - anchors.right: isOutgoing ? msgContent.right : undefined - width: msgContent.txtWidth - radius: 18 - color: isOutgoing ? - JamiTheme.messageOutBgColor : - JamiTheme.messageInBgColor } } - } - Item { - id: infoCell - - Layout.column: isOutgoing ? 0 : 1 - Layout.row: 1 - Layout.fillWidth: true - Layout.preferredHeight: childrenRect.height - - Component.onCompleted: children = timestampLabel - } - Item { - id: avatarCell - - Layout.column: isOutgoing ? 1 : 0 - Layout.row: 0 - Layout.preferredWidth: isOutgoing ? 16 : avatar.width - Layout.preferredHeight: msgCell.height - Layout.leftMargin: isOutgoing ? 0 : 6 - Layout.rightMargin: Layout.leftMargin - Avatar { - id: avatar - visible: !isOutgoing - anchors.bottom: parent.bottom - width: 32 - height: 32 - imageId: author - showPresenceIndicator: false - mode: Avatar.Mode.Contact + ,ProgressBar { + id: progressBar + visible: Status === Interaction.Status.TRANSFER_ONGOING + height: visible * implicitHeight + value: transferStats.progress / transferStats.totalSize + width: transferItem.width + anchors.right: isOutgoing ? parent.right : undefined } - } + ] } } - Label { - id: timestampLabel - - text: formattedTime - color: JamiTheme.timestampColor - - anchors.right: isGenerated || !isOutgoing ? undefined : parent.right - anchors.rightMargin: 6 - anchors.left: isGenerated || isOutgoing ? undefined : parent.left - anchors.leftMargin: 6 - anchors.horizontalCenter: isGenerated ? parent.horizontalCenter : undefined + Component { + id: localMediaMsgComp + + SBSMessageBase { + isOutgoing: root.isOutgoing + showTime: root.showTime + seq: root.seq + author: root.author + formattedTime: root.formattedTime + bubble.visible: false + innerContent.children: [ + Loader { + id: localMediaCompLoader + anchors.right: isOutgoing ? parent.right : undefined + width: sourceComponent.width + height: sourceComponent.height + sourceComponent: mediaInfo.isImage !== undefined ? + imageComp : + avComp + Component { + id: avComp + WebEngineView { + id: wev + anchors.right: isOutgoing ? parent.right : undefined + readonly property real minSize: 192 + readonly property real maxSize: 256 + readonly property real aspectRatio: 1 / .75 + readonly property real adjustedWidth: Math.min(maxSize, + Math.max(minSize, + innerContent.width - senderMargin)) + width: isFullScreen ? parent.width : adjustedWidth + height: mediaInfo.isVideo ? + isFullScreen ? + parent.height : + Math.ceil(adjustedWidth / aspectRatio) : + 54 + settings.fullScreenSupportEnabled: mediaInfo.isVideo + settings.javascriptCanOpenWindows: false + Component.onCompleted: loadHtml(mediaInfo.html, 'file://') + layer.enabled: parent !== appContainer && !appWindow.isFullScreen + layer.effect: OpacityMask { + maskSource: MessageBubble { + out: isOutgoing + type: seq + width: wev.width + height: wev.height + radius: msgRadius + } + } + onFullScreenRequested: function(request) { + if (JamiQmlUtils.callIsFullscreen) + return + if (request.toggleOn && !appWindow.isFullScreen) { + parent = appContainer + appWindow.toggleFullScreen() + } else if (!request.toggleOn && appWindow.isFullScreen) { + parent = localMediaCompLoader + appWindow.toggleFullScreen() + } + request.accept() + } + } + } + Component { + id: imageComp + AnimatedImage { + id: img + anchors.right: isOutgoing ? parent.right : undefined + property real minSize: 192 + property real maxSize: 256 + cache: true + fillMode: Image.PreserveAspectCrop + mipmap: true + antialiasing: true + autoTransform: false + asynchronous: true + source: "file:///" + body + property real aspectRatio: implicitWidth / implicitHeight + property real adjustedWidth: Math.min(maxSize, + Math.max(minSize, + innerContent.width - senderMargin)) + width: adjustedWidth + height: Math.ceil(adjustedWidth / aspectRatio) + 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 + } + } + HoverHandler { + target : parent + onHoveredChanged: { + root.hoveredLink = hovered ? img.source : "" + } + cursorShape: Qt.PointingHandCursor + } + } + } + } + ] + } } opacity: 0 Behavior on opacity { NumberAnimation { duration: 40 } } - - Component.onCompleted: { - opacity = 1 - if (!Linkified && !isImage && !isAnimatedImage) { - MessagesAdapter.parseMessageUrls(Id, Body) - } - } + Component.onCompleted: opacity = 1 } diff --git a/src/commoncomponents/SBSMessageBase.qml b/src/commoncomponents/SBSMessageBase.qml new file mode 100644 index 000000000..0c9cafa72 --- /dev/null +++ b/src/commoncomponents/SBSMessageBase.qml @@ -0,0 +1,115 @@ +/* + * Copyright (C) 2021 by Savoir-faire Linux + * Author: Andreas Traczyk <andreas.traczyk@savoirfairelinux.com> + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see <https://www.gnu.org/licenses/>. + */ + +import QtQuick 2.15 +import QtQuick.Controls 2.15 +import QtQuick.Layouts 1.15 +import QtGraphicalEffects 1.15 + +import net.jami.Models 1.1 +import net.jami.Adapters 1.1 +import net.jami.Constants 1.1 + +ColumnLayout { + id: root + + property alias avatarBlockWidth: avatarBlock.width + property alias innerContent: innerContent + property alias bubble: bubble + property real extraHeight: 0 + + // these MUST be set but we won't use the 'required' keyword yet + property bool isOutgoing + property bool showTime + property int seq + property string author + property string formattedTime + + readonly property real senderMargin: 64 + readonly property real avatarSize: 32 + readonly property real msgRadius: 18 + readonly property real hMargin: 12 + + anchors.left: parent.left + anchors.right: parent.right + anchors.leftMargin: hMargin + anchors.rightMargin: hMargin + spacing: 2 + + RowLayout { + Layout.preferredHeight: innerContent.height + root.extraHeight + Layout.topMargin: (seq === MsgSeq.first || seq === MsgSeq.single) ? 6 : 0 + spacing: 0 + Item { + id: avatarBlock + Layout.preferredWidth: isOutgoing ? 0 : avatar.width + hMargin + Layout.preferredHeight: isOutgoing ? 0 : bubble.height + Avatar { + id: avatar + visible: !isOutgoing && (seq === MsgSeq.last || seq === MsgSeq.single) + anchors.bottom: parent.bottom + width: avatarSize + height: avatarSize + imageId: author + showPresenceIndicator: false + mode: Avatar.Mode.Contact + } + } + Item { + Layout.fillWidth: true + Layout.fillHeight: true + Column { + id: innerContent + width: parent.width + // place actual content here + } + MessageBubble { + id: bubble + z:-1 + out: isOutgoing + type: seq + color: isOutgoing ? + JamiTheme.messageOutBgColor : + JamiTheme.messageInBgColor + radius: msgRadius + anchors.right: isOutgoing ? parent.right : undefined + width: innerContent.childrenRect.width + height: innerContent.childrenRect.height + (visible ? root.extraHeight : 0) + } + } + } + Item { + id: infoCell + + Layout.preferredWidth: parent.width + Layout.preferredHeight: childrenRect.height + + Label { + text: formattedTime + color: JamiTheme.timestampColor + visible: showTime || seq === MsgSeq.last + height: visible * implicitHeight + font.pointSize: 9 + + anchors.right: !isOutgoing ? undefined : parent.right + anchors.rightMargin: 8 + anchors.left: isOutgoing ? undefined : parent.left + anchors.leftMargin: avatarBlockWidth + 6 + } + } +} diff --git a/src/constant/JamiTheme.qml b/src/constant/JamiTheme.qml index a16114497..6048a129c 100644 --- a/src/constant/JamiTheme.qml +++ b/src/constant/JamiTheme.qml @@ -148,7 +148,7 @@ Item { property color jamiLightBlue: darkTheme ? "#003b4e" : Qt.rgba(59, 193, 211, 0.3) property color jamiDarkBlue: darkTheme ? "#28b1ed" : "#003b4e" property color chatviewTextColor: darkTheme ? "#f0f0f0" : "#353637" - property color timestampColor: darkTheme ? "#bbb" : "#333" + property color timestampColor: darkTheme ? "#bbb" : "#777" property color messageOutBgColor: darkTheme ? "#28b1ed" : "#cfd8dc" property color messageOutTxtColor: chatviewTextColor property color messageInBgColor: darkTheme? "#616161" : "#cfebf5" diff --git a/src/constant/MsgSeq.qml b/src/constant/MsgSeq.qml new file mode 100644 index 000000000..de25c238d --- /dev/null +++ b/src/constant/MsgSeq.qml @@ -0,0 +1,9 @@ +pragma Singleton +import QtQml 2.15 +QtObject { + //readonly property int unknown: -1 + readonly property int single: 0 + readonly property int first: 1 + readonly property int middle: 2 + readonly property int last: 3 +} diff --git a/src/mainview/components/MessageListView.qml b/src/mainview/components/MessageListView.qml index 2bc9a68fb..2224edc14 100644 --- a/src/mainview/components/MessageListView.qml +++ b/src/mainview/components/MessageListView.qml @@ -60,8 +60,10 @@ ListView { anchors.centerIn: parent height: parent.height width: parent.width - displayMarginBeginning: 2048 - displayMarginEnd: 2048 + // this offscreen caching is pretty huge + // displayMarginEnd may be removed + displayMarginBeginning: 4096 + displayMarginEnd: 4096 maximumFlickVelocity: 2048 verticalLayoutDirection: ListView.BottomToTop clip: true @@ -72,7 +74,121 @@ ListView { model: MessagesAdapter.messageListModel - delegate: MessageDelegate {} + delegate: MessageDelegate { + // sequencing/timestamps (2-sided style) + function computeTimestampVisibility() { + if (listView === undefined) + return + var nItem = listView.itemAtIndex(index - 1) + if (nItem && index !== listView.count - 1) { + showTime = (nItem.timestamp - timestamp) > 60 && + nItem.formattedTime !== formattedTime + } else { + showTime = true + var pItem = listView.itemAtIndex(index + 1) + if (pItem) { + pItem.showTime = (timestamp - pItem.timestamp) > 60 && + pItem.formattedTime !== formattedTime + } + } + } + + function computeSequencing() { + if (listView === undefined) + return + var cItem = { + 'author': author, + 'isGenerated': isGenerated, + 'showTime': showTime + } + var pItem = listView.itemAtIndex(index + 1) + var nItem = listView.itemAtIndex(index - 1) + + let isSeq = (item0, item1) => + item0.author === item1.author && + !(item0.isGenerated || item1.isGenerated) && + !item0.showTime + + let setSeq = function (newSeq, item) { + if (item === undefined) + seq = isGenerated ? MsgSeq.single : newSeq + else + item.seq = item.isGenerated ? MsgSeq.single : newSeq + } + + let rAdjustSeq = function (item) { + if (item.seq === MsgSeq.last) + item.seq = MsgSeq.middle + else if (item.seq === MsgSeq.single) + setSeq(MsgSeq.first, item) + } + + let adjustSeq = function (item) { + if (item.seq === MsgSeq.first) + item.seq = MsgSeq.middle + else if (item.seq === MsgSeq.single) + setSeq(MsgSeq.last, item) + } + + if (pItem && !nItem) { + if (!isSeq(pItem, cItem)) { + seq = MsgSeq.single + } else { + seq = MsgSeq.last + rAdjustSeq(pItem) + } + } else if (nItem && !pItem) { + if (!isSeq(cItem, nItem)) { + seq = MsgSeq.single + } else { + setSeq(MsgSeq.first) + adjustSeq(nItem) + } + } else if (!nItem && !pItem) { + seq = MsgSeq.single + } else { + if (isSeq(pItem, nItem)) { + if (isSeq(pItem, cItem)) { + seq = MsgSeq.middle + } else { + seq = MsgSeq.single + + if (pItem.seq === MsgSeq.first) + pItem.seq = MsgSeq.single + else if (item.seq === MsgSeq.middle) + pItem.seq = MsgSeq.last + + if (nItem.seq === MsgSeq.last) + nItem.seq = MsgSeq.single + else if (nItem.seq === MsgSeq.middle) + nItem.seq = MsgSeq.first + } + } else { + if (!isSeq(pItem, cItem)) { + seq = MsgSeq.first + adjustSeq(pItem) + } else { + seq = MsgSeq.last + rAdjustSeq(nItem) + } + } + } + + if (seq === MsgSeq.last) { + showTime = true + } + } + + Component.onCompleted: { + if (index) { + computeTimestampVisibility() + computeSequencing() + } else { + Qt.callLater(computeTimestampVisibility) + Qt.callLater(computeSequencing) + } + } + } function getDistanceToBottom() { const scrollDiff = ScrollBar.vertical.position - diff --git a/src/messagesadapter.cpp b/src/messagesadapter.cpp index fb6d7ef2b..15e33daa7 100644 --- a/src/messagesadapter.cpp +++ b/src/messagesadapter.cpp @@ -196,7 +196,7 @@ MessagesAdapter::acceptFile(const QString& interactionId) } void -MessagesAdapter::refuseFile(const QString& interactionId) +MessagesAdapter::cancelFile(const QString& interactionId) { const auto convUid = lrcInstance_->get_selectedConvUid(); lrcInstance_->getCurrentConversationModel()->cancelTransfer(convUid, interactionId); @@ -240,6 +240,50 @@ MessagesAdapter::onPaste() } } +QString +MessagesAdapter::getStatusString(int status) +{ + switch (static_cast<interaction::Status>(status)) { + case interaction::Status::SENDING: + return QObject::tr("Sending"); + case interaction::Status::FAILURE: + return QObject::tr("Failure"); + case interaction::Status::SUCCESS: + return QObject::tr("Sent"); + case interaction::Status::TRANSFER_CREATED: + return QObject::tr("Connecting"); + case interaction::Status::TRANSFER_ACCEPTED: + return QObject::tr("Accept"); + case interaction::Status::TRANSFER_CANCELED: + return QObject::tr("Canceled"); + case interaction::Status::TRANSFER_ERROR: + case interaction::Status::TRANSFER_UNJOINABLE_PEER: + return QObject::tr("Unable to make contact"); + case interaction::Status::TRANSFER_ONGOING: + return QObject::tr("Ongoing"); + case interaction::Status::TRANSFER_AWAITING_PEER: + return QObject::tr("Waiting for contact"); + case interaction::Status::TRANSFER_AWAITING_HOST: + return QObject::tr("Incoming transfer"); + case interaction::Status::TRANSFER_TIMEOUT_EXPIRED: + return QObject::tr("Timed out waiting for contact"); + case interaction::Status::TRANSFER_FINISHED: + return QObject::tr("Finished"); + default: + return {}; + } +} + +QVariantMap +MessagesAdapter::getTransferStats(const QString& msgId, int status) +{ + Q_UNUSED(status) + auto convModel = lrcInstance_->getCurrentConversationModel(); + lrc::api::datatransfer::Info info = {}; + convModel->getTransferInfo(lrcInstance_->get_selectedConvUid(), msgId, info); + return {{"totalSize", qint64(info.totalSize)}, {"progress", qint64(info.progress)}}; +} + void MessagesAdapter::userIsComposing(bool isComposing) { @@ -371,18 +415,53 @@ MessagesAdapter::onMessageLinkified(const QString& messageId, const QString& lin } bool -MessagesAdapter::isImage(const QString& message) +MessagesAdapter::isLocalImage(const QString& msg) { - QRegularExpression pattern("[^\\s]+(.*?)\\.(jpg|jpeg|png)$", - QRegularExpression::CaseInsensitiveOption); - QRegularExpressionMatch match = pattern.match(message); - return match.hasMatch(); + QImageReader reader; + reader.setDecideFormatFromContent(true); + reader.setFileName(msg); + return !reader.read().isNull(); +} + +QVariantMap +MessagesAdapter::getMediaInfo(const QString& msg) +{ + auto filePath = QFileInfo(msg).absoluteFilePath(); + static const QString html + = "<body style='margin:0;padding:0;'>" + "<%1 style='width:100%;height:%2;outline:none;background-color:#f1f3f4;" + "object-fit:cover;' " + "controls controlsList='nodownload' src='file://%3' type='%4'/></body>"; + if (isLocalImage(msg)) { + return {{"isImage", true}}; + } + QRegularExpression vPattern("[^\\s]+(.*?)\\.(avi|mov|webm|webp|rmvb)$", + QRegularExpression::CaseInsensitiveOption); + QString type = vPattern.match(filePath).captured(2); + if (!type.isEmpty()) { + return { + {"isVideo", true}, + {"html", html.arg("video", "100%", filePath, "video/" + type)}, + }; + } else { + QRegularExpression aPattern("[^\\s]+(.*?)\\.(ogg|flac|wav|mpeg|mp3)$", + QRegularExpression::CaseInsensitiveOption); + type = aPattern.match(filePath).captured(2); + if (!type.isEmpty()) { + return { + {"isVideo", false}, + {"html", html.arg("audio", "54px", filePath, "audio/" + type)}, + }; + } + } + return {}; } bool -MessagesAdapter::isAnimatedImage(const QString& msg) +MessagesAdapter::isRemoteImage(const QString& msg) { - QRegularExpression pattern("[^\\s]+(.*?)\\.(gif|apng|webp|avif|flif)$", + // TODO: test if all these open in the AnimatedImage component + QRegularExpression pattern("[^\\s]+(.*?)\\.(jpg|jpeg|png|gif|apng|webp|avif|flif)$", QRegularExpression::CaseInsensitiveOption); QRegularExpressionMatch match = pattern.match(msg); return match.hasMatch(); diff --git a/src/messagesadapter.h b/src/messagesadapter.h index fd27ac9b1..062743332 100644 --- a/src/messagesadapter.h +++ b/src/messagesadapter.h @@ -44,7 +44,8 @@ public: { auto index = sourceModel()->index(sourceRow, 0, sourceParent); auto type = sourceModel()->data(index, MessageList::Role::Type).toInt(); - return static_cast<interaction::Type>(type) != interaction::Type::MERGE; + auto hasBody = !sourceModel()->data(index, MessageList::Role::Body).toString().isEmpty(); + return static_cast<interaction::Type>(type) != interaction::Type::MERGE && hasBody; }; bool lessThan(const QModelIndex& left, const QModelIndex& right) const override @@ -92,18 +93,21 @@ protected: Q_INVOKABLE void sendMessage(const QString& message); Q_INVOKABLE void sendFile(const QString& message); Q_INVOKABLE void acceptFile(const QString& arg); - Q_INVOKABLE void refuseFile(const QString& arg); + Q_INVOKABLE void cancelFile(const QString& arg); Q_INVOKABLE void openUrl(const QString& url); Q_INVOKABLE void openFile(const QString& arg); Q_INVOKABLE void retryInteraction(const QString& interactionId); Q_INVOKABLE void deleteInteraction(const QString& interactionId); Q_INVOKABLE void copyToDownloads(const QString& interactionId, const QString& displayName); Q_INVOKABLE void userIsComposing(bool isComposing); - Q_INVOKABLE bool isImage(const QString& msg); - Q_INVOKABLE bool isAnimatedImage(const QString& msg); + Q_INVOKABLE bool isLocalImage(const QString& msg); + Q_INVOKABLE QVariantMap getMediaInfo(const QString& msg); + Q_INVOKABLE bool isRemoteImage(const QString& msg); Q_INVOKABLE QString getFormattedTime(const quint64 timestamp); Q_INVOKABLE void parseMessageUrls(const QString& messageId, const QString& msg); Q_INVOKABLE void onPaste(); + Q_INVOKABLE QString getStatusString(int status); + Q_INVOKABLE QVariantMap getTransferStats(const QString& messageId, int); // Run corrsponding js functions, c++ to qml. void setMessagesImageContent(const QString& path, bool isBased64 = false); diff --git a/src/qmlregister.cpp b/src/qmlregister.cpp index 31cdc5271..6954d3988 100644 --- a/src/qmlregister.cpp +++ b/src/qmlregister.cpp @@ -177,6 +177,7 @@ registerTypes(QQmlEngine* engine, QML_REGISTERSINGLETONTYPE_URL(NS_MODELS, "qrc:/src/constant/JamiQmlUtils.qml", JamiQmlUtils); QML_REGISTERSINGLETONTYPE_URL(NS_CONSTANTS, "qrc:/src/constant/JamiStrings.qml", JamiStrings); QML_REGISTERSINGLETONTYPE_URL(NS_CONSTANTS, "qrc:/src/constant/JamiResources.qml", JamiResources); + QML_REGISTERSINGLETONTYPE_URL(NS_CONSTANTS, "qrc:/src/constant/MsgSeq.qml", MsgSeq); QML_REGISTERSINGLETONTYPE_POBJECT(NS_CONSTANTS, screenInfo, "ScreenInfo") QML_REGISTERSINGLETONTYPE_POBJECT(NS_CONSTANTS, lrcInstance, "LRCInstance") -- GitLab