From 2e67dc1bd8bd64e313c77c03283c5b6cfccf8c77 Mon Sep 17 00:00:00 2001 From: Trevor Tabah <trevor.tabah@savoirfairelinux.com> Date: Tue, 6 Jul 2021 10:20:46 -0400 Subject: [PATCH] chatview: replace web chat view with qml listview Introduces a primitive QML ListView based chat view lacking features present in the previous web chat view, that will be added in subsequent commits(styling, preview/media/link/file-transfer message type support, etc.). Gitlab: #467 Change-Id: Iedc40f6172a6cdacc48cda6f4187053fbf226713 --- CMakeLists.txt | 14 +- qml.qrc | 6 +- resources/misc/previewInterop.js | 54 +++ src/commoncomponents/MessageDelegate.qml | 191 +++++++++ src/constant/JamiTheme.qml | 26 +- src/conversationlistmodelbase.cpp | 12 +- src/currentconversation.cpp | 1 + src/currentconversation.h | 1 + src/mainapplication.cpp | 14 +- src/mainapplication.h | 6 +- src/mainview/MainView.qml | 42 +- src/mainview/components/ChatView.qml | 147 +++++++ ...geWebViewFooter.qml => ChatViewFooter.qml} | 6 +- src/mainview/components/MessageBar.qml | 54 +-- .../components/MessageBarTextArea.qml | 2 +- src/mainview/components/MessageListView.qml | 106 +++++ src/mainview/components/MessageWebView.qml | 287 ------------- .../components/MessageWebViewHeader.qml | 2 +- src/mainview/components/ReadOnlyFooter.qml | 2 +- src/messagesadapter.cpp | 389 +++++------------- src/messagesadapter.h | 107 ++--- src/previewengine.cpp | 94 +++++ src/previewengine.h | 64 +++ src/qmlregister.cpp | 18 +- src/qmlregister.h | 2 + src/utils.cpp | 2 +- src/webchathelpers.cpp | 159 ------- src/webchathelpers.h | 42 -- tests/qml/main.cpp | 3 + tests/qml/resources.qrc | 2 +- ...bViewFooter.qml => tst_ChatViewFooter.qml} | 4 +- tests/qml/src/tst_FilesToSendContainer.qml | 4 +- 32 files changed, 929 insertions(+), 934 deletions(-) create mode 100644 resources/misc/previewInterop.js create mode 100644 src/commoncomponents/MessageDelegate.qml create mode 100644 src/mainview/components/ChatView.qml rename src/mainview/components/{MessageWebViewFooter.qml => ChatViewFooter.qml} (96%) create mode 100644 src/mainview/components/MessageListView.qml delete mode 100644 src/mainview/components/MessageWebView.qml create mode 100644 src/previewengine.cpp create mode 100644 src/previewengine.h delete mode 100644 src/webchathelpers.cpp delete mode 100644 src/webchathelpers.h rename tests/qml/src/{tst_MessageWebViewFooter.qml => tst_ChatViewFooter.qml} (97%) diff --git a/CMakeLists.txt b/CMakeLists.txt index f1d575f74..fd4f38627 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -45,7 +45,6 @@ set(COMMON_SOURCES ${SRC_DIR}/networkmanager.cpp ${SRC_DIR}/runguard.cpp ${SRC_DIR}/updatemanager.cpp - ${SRC_DIR}/webchathelpers.cpp ${SRC_DIR}/main.cpp ${SRC_DIR}/smartlistmodel.cpp ${SRC_DIR}/utils.cpp @@ -87,7 +86,8 @@ set(COMMON_SOURCES ${SRC_DIR}/avatarregistry.cpp ${SRC_DIR}/currentconversation.cpp ${SRC_DIR}/currentaccount.cpp - ${SRC_DIR}/videodevices.cpp) + ${SRC_DIR}/videodevices.cpp + ${SRC_DIR}/previewengine.cpp) set(COMMON_HEADERS ${SRC_DIR}/avatarimageprovider.h @@ -99,7 +99,6 @@ set(COMMON_HEADERS ${SRC_DIR}/version.h ${SRC_DIR}/accountlistmodel.h ${SRC_DIR}/runguard.h - ${SRC_DIR}/webchathelpers.h ${SRC_DIR}/rendermanager.h ${SRC_DIR}/connectivitymonitor.h ${SRC_DIR}/jamiavatartheme.h @@ -144,7 +143,8 @@ set(COMMON_HEADERS ${SRC_DIR}/avatarregistry.h ${SRC_DIR}/currentconversation.h ${SRC_DIR}/currentaccount.h - ${SRC_DIR}/videodevices.h) + ${SRC_DIR}/videodevices.h + ${SRC_DIR}/previewengine.h) set(QML_LIBS Qt5::Quick @@ -155,7 +155,8 @@ set(QML_LIBS Qt5::Concurrent Qt5::QuickControls2 Qt5::WebEngine - Qt5::Core) + Qt5::Core + Qt5::WebEngineWidgets) set(QML_LIBS_LIST Core @@ -166,7 +167,8 @@ set(QML_LIBS_LIST Svg Sql QuickControls2 - WebEngine) + WebEngine + WebEngineWidgets) set(WINDOWS_SYS_LIBS Shell32.lib Ole32.lib diff --git a/qml.qrc b/qml.qrc index 4a6ff244c..91803d64b 100644 --- a/qml.qrc +++ b/qml.qrc @@ -94,7 +94,7 @@ <file>src/mainview/components/AboutPopUp.qml</file> <file>src/mainview/components/SidePanel.qml</file> <file>src/mainview/components/WelcomePage.qml</file> - <file>src/mainview/components/MessageWebView.qml</file> + <file>src/mainview/components/ChatView.qml</file> <file>src/mainview/components/MessageWebViewHeader.qml</file> <file>src/mainview/components/AccountComboBox.qml</file> <file>src/mainview/components/CallStackView.qml</file> @@ -143,7 +143,7 @@ <file>src/commoncomponents/contextmenu/GeneralMenuSeparator.qml</file> <file>src/mainview/components/ParticipantOverlayButton.qml</file> <file>src/mainview/components/ParticipantControlLayout.qml</file> - <file>src/mainview/components/MessageWebViewFooter.qml</file> + <file>src/mainview/components/ChatViewFooter.qml</file> <file>src/commoncomponents/emojipicker/EmojiPicker.qml</file> <file>src/commoncomponents/emojipicker/emojiPickerLoader.js</file> <file>src/commoncomponents/emojipicker/emojiPickerLoader.html</file> @@ -161,5 +161,7 @@ <file>src/commoncomponents/BackButton.qml</file> <file>src/commoncomponents/JamiSwitch.qml</file> <file>src/mainview/components/ReadOnlyFooter.qml</file> + <file>src/commoncomponents/MessageDelegate.qml</file> + <file>src/mainview/components/MessageListView.qml</file> </qresource> </RCC> diff --git a/resources/misc/previewInterop.js b/resources/misc/previewInterop.js new file mode 100644 index 000000000..4a8a30291 --- /dev/null +++ b/resources/misc/previewInterop.js @@ -0,0 +1,54 @@ +_ = new QWebChannel(qt.webChannelTransport, function (channel) { + window.jsbridge = channel.objects.jsbridge +}) + +function log(msg) { + window.jsbridge.log(msg) +} + +function getPreviewInfo(messageId, url) { + var title = null + var description = null + var image = null + if (!url.includes("http://") && !url.includes("https://")) { + url = "http://".concat(url) + } + fetch(url, { + mode: 'no-cors', + headers: {'Set-Cookie': 'SameSite=None; Secure'} + }).then(function (response) { + return response.text() + }).then(function (html) { + // create DOM from html string + var parser = new DOMParser() + var doc = parser.parseFromString(html, "text/html") + if (!url.includes("twitter.com")){ + title = getTitle(doc) + image = getImage(doc, url) + description = getDescription(doc) + var domain = (new URL(url)) + domain = (domain.hostname).replace("www.", "") + } else { + title = "Twitter. It's what's happening." + } + + window.jsbridge.infoReady(messageId, { + 'title': title, + 'image': image, + 'description': description, + 'url': url, + 'domain': domain, + }) + }).catch(function (err) { + log("Error occured while fetching document: " + err) + }) +} + +function parseMessage(messageId, message) { + var links = linkify.find(message) + if (links.length === 0) { + return + } + getPreviewInfo(messageId, links[0].href) + window.jsbridge.linkifyReady(messageId, linkifyStr(message)) +} diff --git a/src/commoncomponents/MessageDelegate.qml b/src/commoncomponents/MessageDelegate.qml new file mode 100644 index 000000000..fca345efa --- /dev/null +++ b/src/commoncomponents/MessageDelegate.qml @@ -0,0 +1,191 @@ +import QtQuick 2.15 +import QtQuick.Controls 2.15 +import QtQuick.Layouts 1.15 +import QtWebEngine 1.10 + +import net.jami.Models 1.1 +import net.jami.Adapters 1.1 +import net.jami.Constants 1.1 + +Control { + id: root + + readonly property bool isGenerated: Type === Interaction.Type.CALL || + Type === Interaction.Type.CONTACT + readonly property string author: Author + 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 body: Body + readonly property real msgMargin: 64 + + width: parent ? parent.width : 0 + height: loader.height + + Loader { + id: loader + + property alias isOutgoing: root.isOutgoing + property alias isGenerated: root.isGenerated + readonly property var author: Author + readonly property var body: Body + + sourceComponent: isGenerated ? + generatedMsgComp : + userMsgComp + } + + Component { + id: generatedMsgComp + + Column { + width: root.width + spacing: 2 + + TextArea { + width: parent.width + text: body + horizontalAlignment: Qt.AlignHCenter + readOnly: true + font.pointSize: 11 + color: JamiTheme.chatviewTextColor + } + + Item { + id: infoCell + + width: parent.width + height: childrenRect.height + + Component.onCompleted: children = timestampLabel + } + + bottomPadding: 12 + } + } + + 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 + 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 + color: isOutgoing ? + JamiTheme.messageOutTxtColor : + JamiTheme.messageInTxtColor + } + } + 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 + } + } + } + } + + 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 + } + + opacity: 0 + Behavior on opacity { NumberAnimation { duration: 40 } } + + Component.onCompleted: { + opacity = 1 + if (!Linkified && !isImage && !isAnimatedImage) { + MessagesAdapter.parseMessageUrls(Id, Body) + } + } +} diff --git a/src/constant/JamiTheme.qml b/src/constant/JamiTheme.qml index ca3bc230f..4f0c93ef7 100644 --- a/src/constant/JamiTheme.qml +++ b/src/constant/JamiTheme.qml @@ -147,12 +147,12 @@ Item { // Chatview property color jamiLightBlue: darkTheme ? "#003b4e" : Qt.rgba(59, 193, 211, 0.3) property color jamiDarkBlue: darkTheme ? "#28b1ed" : "#003b4e" - property color chatviewTextColor: textColor + property color chatviewTextColor: darkTheme ? "#f0f0f0" : "#353637" property color timestampColor: darkTheme ? "#bbb" : "#333" property color messageOutBgColor: darkTheme ? "#28b1ed" : "#cfd8dc" - property color messageOutTxtColor: textColor + property color messageOutTxtColor: chatviewTextColor property color messageInBgColor: darkTheme? "#616161" : "#cfebf5" - property color messageInTxtColor: textColor + property color messageInTxtColor: chatviewTextColor property color fileOutTimestampColor: darkTheme ? "#eee" : "#555" property color fileInTimestampColor: darkTheme ? "#999" : "#555" property color chatviewBgColor: darkTheme ? bgDarkMode_ : whiteColor @@ -271,17 +271,17 @@ Item { property real modalPopupDropShadowSamples: 16 // MessageWebView - property real messageWebViewHairLineSize: 1 + property real chatViewHairLineSize: 1 property real messageWebViewHeaderPreferredHeight: 64 - property real messageWebViewFooterContentMaximumWidth: 1000 - property real messageWebViewFooterPreferredHeight: 50 - property real messageWebViewFooterMaximumHeight: 280 - property real messageWebViewFooterRowSpacing: 1 - property real messageWebViewFooterButtonSize: 36 - property real messageWebViewFooterButtonIconSize: 48 - property real messageWebViewFooterButtonRadius: 5 - property real messageWebViewFooterFileContainerPreferredHeight: 150 - property real messageWebViewFooterTextAreaMaximumHeight: 130 + property real chatViewMaximumWidth: 900 + property real chatViewFooterPreferredHeight: 50 + property real chatViewFooterMaximumHeight: 280 + property real chatViewFooterRowSpacing: 1 + property real chatViewFooterButtonSize: 36 + property real chatViewFooterButtonIconSize: 48 + property real chatViewFooterButtonRadius: 5 + property real chatViewFooterFileContainerPreferredHeight: 150 + property real chatViewFooterTextAreaMaximumHeight: 130 // MessageWebView File Transfer Container property real filesToSendContainerSpacing: 5 diff --git a/src/conversationlistmodelbase.cpp b/src/conversationlistmodelbase.cpp index 25eb8b821..98df36e13 100644 --- a/src/conversationlistmodelbase.cpp +++ b/src/conversationlistmodelbase.cpp @@ -108,22 +108,22 @@ ConversationListModelBase::dataForItem(item_t item, int role) const case Role::UnreadMessagesCount: return QVariant(item.unreadMessages); case Role::LastInteractionTimeStamp: { - if (!item.interactions.empty()) { - auto ts = static_cast<qint32>(item.interactions.at(item.lastMessageUid).timestamp); + if (!item.interactions->empty()) { + auto ts = static_cast<qint32>(item.interactions->at(item.lastMessageUid).timestamp); return QVariant(ts); } break; } case Role::LastInteractionDate: { - if (!item.interactions.empty()) { + if (!item.interactions->empty()) { return QVariant( - Utils::formatTimeString(item.interactions.at(item.lastMessageUid).timestamp)); + Utils::formatTimeString(item.interactions->at(item.lastMessageUid).timestamp)); } break; } case Role::LastInteraction: { - if (!item.interactions.empty()) { - return QVariant(item.interactions.at(item.lastMessageUid).body); + if (!item.interactions->empty()) { + return QVariant(item.interactions->at(item.lastMessageUid).body); } break; } diff --git a/src/currentconversation.cpp b/src/currentconversation.cpp index e0ee604ac..1f7f279cb 100644 --- a/src/currentconversation.cpp +++ b/src/currentconversation.cpp @@ -60,6 +60,7 @@ CurrentConversation::updateData() set_needsSyncing(convInfo.needsSyncing); set_isSip(accInfo.profileInfo.type == profile::Type::SIP); set_callId(convInfo.getCallId()); + set_allMessagesLoaded(convInfo.allMessagesLoaded); if (accInfo.callModel->hasCall(callId_)) { auto call = accInfo.callModel->getCall(callId_); set_callState(call.status); diff --git a/src/currentconversation.h b/src/currentconversation.h index 1e0e14fae..e2464b8f6 100644 --- a/src/currentconversation.h +++ b/src/currentconversation.h @@ -44,6 +44,7 @@ class CurrentConversation final : public QObject QML_PROPERTY(bool, inCall) QML_PROPERTY(bool, isTemporary) QML_PROPERTY(bool, isContact) + QML_PROPERTY(bool, allMessagesLoaded) public: explicit CurrentConversation(LRCInstance* lrcInstance, QObject* parent = nullptr); diff --git a/src/mainapplication.cpp b/src/mainapplication.cpp index 70146ef4a..34cee61ca 100644 --- a/src/mainapplication.cpp +++ b/src/mainapplication.cpp @@ -1,4 +1,4 @@ -/*! +/* * Copyright (C) 2015-2020 by Savoir-faire Linux * Author: Edric Ladent Milaret <edric.ladent-milaret@savoirfairelinux.com> * Author: Andreas Traczyk <andreas.traczyk@savoirfairelinux.com> @@ -25,6 +25,7 @@ #include "appsettingsmanager.h" #include "connectivitymonitor.h" #include "systemtray.h" +#include "previewengine.h" #include <QAction> #include <QCommandLineParser> @@ -149,18 +150,12 @@ MainApplication::MainApplication(int& argc, char** argv) , connectivityMonitor_(new ConnectivityMonitor(this)) , settingsManager_(new AppSettingsManager(this)) , systemTray_(new SystemTray(settingsManager_.get(), this)) + , previewEngine_(new PreviewEngine(this)) { QObject::connect(this, &QApplication::aboutToQuit, [this] { cleanup(); }); } -MainApplication::~MainApplication() -{ - engine_.reset(); - systemTray_.reset(); - settingsManager_.reset(); - lrcInstance_.reset(); - connectivityMonitor_.reset(); -} +MainApplication::~MainApplication() {} bool MainApplication::init() @@ -414,6 +409,7 @@ MainApplication::initQmlLayer() systemTray_.get(), lrcInstance_.get(), settingsManager_.get(), + previewEngine_.get(), &screenInfo_, this); diff --git a/src/mainapplication.h b/src/mainapplication.h index 66c53d6ce..f7fbc82de 100644 --- a/src/mainapplication.h +++ b/src/mainapplication.h @@ -1,4 +1,4 @@ -/*! +/* * Copyright (C) 2020 by Savoir-faire Linux * Author: Edric Ladent Milaret <edric.ladent-milaret@savoirfairelinux.com> * Author: Andreas Traczyk <andreas.traczyk@savoirfairelinux.com> @@ -35,6 +35,7 @@ class ConnectivityMonitor; class AppSettingsManager; class SystemTray; class CallAdapter; +class PreviewEngine; // Provides information about the screen the app is displayed on class ScreenInfo : public QObject @@ -97,8 +98,7 @@ private: QScopedPointer<ConnectivityMonitor> connectivityMonitor_; QScopedPointer<AppSettingsManager> settingsManager_; QScopedPointer<SystemTray> systemTray_; + QScopedPointer<PreviewEngine> previewEngine_; ScreenInfo screenInfo_; - - CallAdapter* callAdapter_; }; diff --git a/src/mainview/MainView.qml b/src/mainview/MainView.qml index 97ffa3e2b..c3cb6f801 100644 --- a/src/mainview/MainView.qml +++ b/src/mainview/MainView.qml @@ -76,8 +76,8 @@ Rectangle { callStackView.needToCloseInCallConversationAndPotentialWindow() LRCInstance.deselectConversation() if (isPageInStack("callStackViewObject", sidePanelViewStack) || - isPageInStack("communicationPageMessageWebView", sidePanelViewStack) || - isPageInStack("communicationPageMessageWebView", mainViewStack) || + isPageInStack("chatView", sidePanelViewStack) || + isPageInStack("chatView", mainViewStack) || isPageInStack("callStackViewObject", mainViewStack)) { sidePanelViewStack.pop(StackView.Immediate) mainViewStack.pop(welcomePage, StackView.Immediate) @@ -98,10 +98,10 @@ Rectangle { function pushCommunicationMessageWebView() { if (sidePanelOnly) { sidePanelViewStack.pop(StackView.Immediate) - sidePanelViewStack.push(communicationPageMessageWebView, StackView.Immediate) + sidePanelViewStack.push(chatView, StackView.Immediate) } else { mainViewStack.pop(welcomePage, StackView.Immediate) - mainViewStack.push(communicationPageMessageWebView, StackView.Immediate) + mainViewStack.push(chatView, StackView.Immediate) } } @@ -164,24 +164,17 @@ Rectangle { } function setMainView(convId) { - if (!(communicationPageMessageWebView.jsLoaded)) { - communicationPageMessageWebView.jsLoadedChanged.connect( - function(convId) { - return function() { setMainView(convId) } - }(convId)) - return - } var item = ConversationsAdapter.getConvInfoMap(convId) if (item.convId === undefined) return - communicationPageMessageWebView.headerUserAliasLabelText = item.title - communicationPageMessageWebView.headerUserUserNameLabelText = item.bestId + chatView.headerUserAliasLabelText = item.title + chatView.headerUserUserNameLabelText = item.bestId if (item.callStackViewShouldShow) { if (inSettingsView) { toggleSettingsView() } MessagesAdapter.setupChatView(item) - callStackView.setLinkedWebview(communicationPageMessageWebView) + callStackView.setLinkedWebview(chatView) callStackView.responsibleAccountId = LRCInstance.currentAccountId callStackView.responsibleConvUid = convId callStackView.isAudioOnly = item.isAudioOnly @@ -201,13 +194,13 @@ Rectangle { callStackView.needToCloseInCallConversationAndPotentialWindow() MessagesAdapter.setupChatView(item) pushCommunicationMessageWebView() - communicationPageMessageWebView.focusMessageWebView() + chatView.focusChatView() currentConvUID = convId } else if (isPageInStack("callStackViewObject", sidePanelViewStack) || isPageInStack("callStackViewObject", mainViewStack)) { callStackView.needToCloseInCallConversationAndPotentialWindow() pushCommunicationMessageWebView() - communicationPageMessageWebView.focusMessageWebView() + chatView.focusChatView() } } } @@ -396,21 +389,12 @@ Rectangle { onSettingsBackArrowClicked: sidePanelViewStack.pop(StackView.Immediate) } - MessageWebView { - id: communicationPageMessageWebView - - objectName: "communicationPageMessageWebView" - - signal toSendMessageContentSaved(string arg) - signal toMessagesCleared - signal toMessagesLoaded + ChatView { + id: chatView + objectName: "chatView" visible: false - - Component.onCompleted: { - // Set qml MessageWebView object pointer to c++. - MessagesAdapter.setQmlObject(this) - } + Component.onCompleted: MessagesAdapter.setQmlObject(this) } onWidthChanged: { diff --git a/src/mainview/components/ChatView.qml b/src/mainview/components/ChatView.qml new file mode 100644 index 000000000..1336f1435 --- /dev/null +++ b/src/mainview/components/ChatView.qml @@ -0,0 +1,147 @@ +/* + * Copyright (C) 2020-2021 by Savoir-faire Linux + * Author: Mingrui Zhang <mingrui.zhang@savoirfairelinux.com> + * 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 net.jami.Models 1.1 +import net.jami.Adapters 1.1 +import net.jami.Constants 1.1 + +import "../../commoncomponents" +import "../js/pluginhandlerpickercreation.js" as PluginHandlerPickerCreation + +Rectangle { + id: root + + property string headerUserAliasLabelText: "" + property string headerUserUserNameLabelText: "" + + property bool allMessagesLoaded + + signal needToHideConversationInCall + signal messagesCleared + signal messagesLoaded + + function focusChatView() { + chatViewFooter.textInput.forceActiveFocus() + } + + color: JamiTheme.chatviewBgColor + + ColumnLayout { + anchors.fill: root + + spacing: 0 + + MessageWebViewHeader { + id: messageWebViewHeader + + Layout.alignment: Qt.AlignHCenter + Layout.fillWidth: true + Layout.preferredHeight: JamiTheme.messageWebViewHeaderPreferredHeight + Layout.maximumHeight: JamiTheme.messageWebViewHeaderPreferredHeight + + userAliasLabelText: headerUserAliasLabelText + userUserNameLabelText: headerUserUserNameLabelText + + DropArea { + anchors.fill: parent + onDropped: chatViewFooter.setFilePathsToSend(drop.urls) + } + + onBackClicked: { + mainView.showWelcomeView() + } + + onNeedToHideConversationInCall: { + root.needToHideConversationInCall() + } + + onPluginSelector: { + // Create plugin handler picker - PLUGINS + PluginHandlerPickerCreation.createPluginHandlerPickerObjects( + root, false) + PluginHandlerPickerCreation.calculateCurrentGeo(root.width / 2, + root.height / 2) + PluginHandlerPickerCreation.openPluginHandlerPicker() + } + } + + StackLayout { + id: chatViewStack + + Layout.alignment: Qt.AlignHCenter + Layout.fillWidth: true + Layout.maximumWidth: JamiTheme.chatViewMaximumWidth + Layout.fillHeight: true + Layout.topMargin: JamiTheme.chatViewHairLineSize + Layout.bottomMargin: JamiTheme.chatViewHairLineSize + + currentIndex: CurrentConversation.isRequest || + CurrentConversation.needsSyncing + + Loader { + active: CurrentConversation.id !== "" + sourceComponent: MessageListView { + DropArea { + anchors.fill: parent + onDropped: chatViewFooter.setFilePathsToSend(drop.urls) + } + } + } + + InvitationView { + id: invitationView + + Layout.fillWidth: true + Layout.fillHeight: true + } + } + + ReadOnlyFooter { + visible: CurrentConversation.readOnly + Layout.fillWidth: true + } + + ChatViewFooter { + id: chatViewFooter + + visible: { + if (CurrentConversation.needsSyncing || CurrentConversation.readOnly) + return false + else if (CurrentConversation.isSwarm && CurrentConversation.isRequest) + return false + return true + } + + Layout.alignment: Qt.AlignHCenter + Layout.fillWidth: true + Layout.preferredHeight: implicitHeight + Layout.maximumHeight: JamiTheme.chatViewFooterMaximumHeight + + DropArea { + anchors.fill: parent + onDropped: chatViewFooter.setFilePathsToSend(drop.urls) + } + } + } +} diff --git a/src/mainview/components/MessageWebViewFooter.qml b/src/mainview/components/ChatViewFooter.qml similarity index 96% rename from src/mainview/components/MessageWebViewFooter.qml rename to src/mainview/components/ChatViewFooter.qml index bc5ca7c18..b0f58069d 100644 --- a/src/mainview/components/MessageWebViewFooter.qml +++ b/src/mainview/components/ChatViewFooter.qml @@ -136,7 +136,7 @@ Rectangle { emojiPicker.y = Qt.binding(function() { var buttonY = JamiQmlUtils.audioRecordMessageButtonInMainViewPoint.y return buttonY - emojiPicker.height - messageBar.marginSize - - JamiTheme.messageWebViewHairLineSize + - JamiTheme.chatViewHairLineSize }) emojiPicker.openEmojiPicker() @@ -201,9 +201,9 @@ Rectangle { Layout.alignment: Qt.AlignHCenter Layout.preferredWidth: footerColumnLayout.width - Layout.maximumWidth: JamiTheme.messageWebViewFooterContentMaximumWidth + Layout.maximumWidth: JamiTheme.chatViewMaximumWidth Layout.preferredHeight: filesToSendCount ? - JamiTheme.messageWebViewFooterFileContainerPreferredHeight : 0 + JamiTheme.chatViewFooterFileContainerPreferredHeight : 0 } } } diff --git a/src/mainview/components/MessageBar.qml b/src/mainview/components/MessageBar.qml index 4b0f31883..b9a8c8628 100644 --- a/src/mainview/components/MessageBar.qml +++ b/src/mainview/components/MessageBar.qml @@ -48,9 +48,9 @@ ColumnLayout { id: messageBarHairLine Layout.alignment: Qt.AlignTop | Qt.AlignHCenter - Layout.preferredHeight: JamiTheme.messageWebViewHairLineSize + Layout.preferredHeight: JamiTheme.chatViewHairLineSize Layout.fillWidth: true - Layout.maximumWidth: JamiTheme.messageWebViewFooterContentMaximumWidth + Layout.maximumWidth: JamiTheme.chatViewMaximumWidth color: JamiTheme.tabbarBorderColor } @@ -60,20 +60,20 @@ ColumnLayout { Layout.alignment: Qt.AlignCenter Layout.fillWidth: true - Layout.maximumWidth: JamiTheme.messageWebViewFooterContentMaximumWidth + Layout.maximumWidth: JamiTheme.chatViewMaximumWidth - spacing: JamiTheme.messageWebViewFooterRowSpacing + spacing: JamiTheme.chatViewFooterRowSpacing PushButton { id: sendFileButton Layout.alignment: Qt.AlignVCenter Layout.leftMargin: marginSize - Layout.preferredWidth: JamiTheme.messageWebViewFooterButtonSize - Layout.preferredHeight: JamiTheme.messageWebViewFooterButtonSize + Layout.preferredWidth: JamiTheme.chatViewFooterButtonSize + Layout.preferredHeight: JamiTheme.chatViewFooterButtonSize - radius: JamiTheme.messageWebViewFooterButtonRadius - preferredSize: JamiTheme.messageWebViewFooterButtonIconSize - 6 + radius: JamiTheme.chatViewFooterButtonRadius + preferredSize: JamiTheme.chatViewFooterButtonIconSize - 6 toolTipText: JamiStrings.sendFile @@ -89,11 +89,11 @@ ColumnLayout { id: audioRecordMessageButton Layout.alignment: Qt.AlignVCenter - Layout.preferredWidth: JamiTheme.messageWebViewFooterButtonSize - Layout.preferredHeight: JamiTheme.messageWebViewFooterButtonSize + Layout.preferredWidth: JamiTheme.chatViewFooterButtonSize + Layout.preferredHeight: JamiTheme.chatViewFooterButtonSize - radius: JamiTheme.messageWebViewFooterButtonRadius - preferredSize: JamiTheme.messageWebViewFooterButtonIconSize + radius: JamiTheme.chatViewFooterButtonRadius + preferredSize: JamiTheme.chatViewFooterButtonIconSize toolTipText: JamiStrings.leaveAudioMessage @@ -111,11 +111,11 @@ ColumnLayout { id: videoRecordMessageButton Layout.alignment: Qt.AlignVCenter - Layout.preferredWidth: JamiTheme.messageWebViewFooterButtonSize - Layout.preferredHeight: JamiTheme.messageWebViewFooterButtonSize + Layout.preferredWidth: JamiTheme.chatViewFooterButtonSize + Layout.preferredHeight: JamiTheme.chatViewFooterButtonSize - radius: JamiTheme.messageWebViewFooterButtonRadius - preferredSize: JamiTheme.messageWebViewFooterButtonIconSize + radius: JamiTheme.chatViewFooterButtonRadius + preferredSize: JamiTheme.chatViewFooterButtonIconSize toolTipText: JamiStrings.leaveVideoMessage @@ -144,10 +144,10 @@ ColumnLayout { Layout.fillWidth: true Layout.margins: marginSize / 2 Layout.preferredHeight: { - return JamiTheme.messageWebViewFooterPreferredHeight - > contentHeight ? JamiTheme.messageWebViewFooterPreferredHeight : contentHeight + return JamiTheme.chatViewFooterPreferredHeight + > contentHeight ? JamiTheme.chatViewFooterPreferredHeight : contentHeight } - Layout.maximumHeight: JamiTheme.messageWebViewFooterTextAreaMaximumHeight + Layout.maximumHeight: JamiTheme.chatViewFooterTextAreaMaximumHeight - marginSize / 2 onSendMessagesRequired: root.sendMessageButtonClicked() @@ -158,11 +158,11 @@ ColumnLayout { Layout.alignment: Qt.AlignVCenter Layout.rightMargin: sendMessageButton.visible ? 0 : marginSize - Layout.preferredWidth: JamiTheme.messageWebViewFooterButtonSize - Layout.preferredHeight: JamiTheme.messageWebViewFooterButtonSize + Layout.preferredWidth: JamiTheme.chatViewFooterButtonSize + Layout.preferredHeight: JamiTheme.chatViewFooterButtonSize - radius: JamiTheme.messageWebViewFooterButtonRadius - preferredSize: JamiTheme.messageWebViewFooterButtonIconSize + radius: JamiTheme.chatViewFooterButtonRadius + preferredSize: JamiTheme.chatViewFooterButtonIconSize toolTipText: JamiStrings.addEmoji @@ -183,11 +183,11 @@ ColumnLayout { Layout.alignment: Qt.AlignVCenter Layout.rightMargin: visible ? marginSize : 0 - Layout.preferredWidth: scale * JamiTheme.messageWebViewFooterButtonSize - Layout.preferredHeight: JamiTheme.messageWebViewFooterButtonSize + Layout.preferredWidth: scale * JamiTheme.chatViewFooterButtonSize + Layout.preferredHeight: JamiTheme.chatViewFooterButtonSize - radius: JamiTheme.messageWebViewFooterButtonRadius - preferredSize: JamiTheme.messageWebViewFooterButtonIconSize - 6 + radius: JamiTheme.chatViewFooterButtonRadius + preferredSize: JamiTheme.chatViewFooterButtonIconSize - 6 toolTipText: JamiStrings.send diff --git a/src/mainview/components/MessageBarTextArea.qml b/src/mainview/components/MessageBarTextArea.qml index de2a6b699..8f96ca742 100644 --- a/src/mainview/components/MessageBarTextArea.qml +++ b/src/mainview/components/MessageBarTextArea.qml @@ -136,7 +136,7 @@ Flickable { // Shift + Enter -> Next Line Keys.onPressed: function (keyEvent) { if (keyEvent.matches(StandardKey.Paste)) { - MessagesAdapter.pasteKeyDetected() + MessagesAdapter.onPaste() keyEvent.accepted = true } else if (keyEvent.key === Qt.Key_Enter || keyEvent.key === Qt.Key_Return) { diff --git a/src/mainview/components/MessageListView.qml b/src/mainview/components/MessageListView.qml new file mode 100644 index 000000000..2bc9a68fb --- /dev/null +++ b/src/mainview/components/MessageListView.qml @@ -0,0 +1,106 @@ +/* + * 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 net.jami.Models 1.1 +import net.jami.Adapters 1.1 +import net.jami.Constants 1.1 + +import "../../commoncomponents" + +ListView { + id: root + + // fade-in mechanism + Component.onCompleted: fadeAnimation.start() + Rectangle { + id: overlay + anchors.fill: parent + color: JamiTheme.chatviewBgColor + visible: opacity !== 0 + SequentialAnimation { + id: fadeAnimation + NumberAnimation { + target: overlay; property: "opacity" + to: 1; duration: 0 + } + NumberAnimation { + target: overlay; property: "opacity" + to: 0; duration: 240 + } + } + } + Connections { + target: CurrentConversation + function onIdChanged() { fadeAnimation.start() } + } + + topMargin: 12 + bottomMargin: 6 + spacing: 2 + anchors.centerIn: parent + height: parent.height + width: parent.width + displayMarginBeginning: 2048 + displayMarginEnd: 2048 + maximumFlickVelocity: 2048 + verticalLayoutDirection: ListView.BottomToTop + clip: true + boundsBehavior: Flickable.StopAtBounds + currentIndex: -1 + + ScrollBar.vertical: ScrollBar {} + + model: MessagesAdapter.messageListModel + + delegate: MessageDelegate {} + + function getDistanceToBottom() { + const scrollDiff = ScrollBar.vertical.position - + (1.0 - ScrollBar.vertical.size) + return Math.abs(scrollDiff) * contentHeight + } + + onAtYBeginningChanged: loadMoreMsgsIfNeeded() + + function loadMoreMsgsIfNeeded() { + if (atYBeginning && !CurrentConversation.allMessagesLoaded) + MessagesAdapter.loadMoreMessages() + } + + Connections { + target: MessagesAdapter + + function onNewInteraction() { + if (root.getDistanceToBottom() < 80 && + !root.atYEnd) { + Qt.callLater(root.positionViewAtBeginning) + } + } + + function onMoreMessagesLoaded() { + if (root.contentHeight < root.height) { + root.loadMoreMsgsIfNeeded() + } + } + } +} diff --git a/src/mainview/components/MessageWebView.qml b/src/mainview/components/MessageWebView.qml deleted file mode 100644 index a366e569b..000000000 --- a/src/mainview/components/MessageWebView.qml +++ /dev/null @@ -1,287 +0,0 @@ -/* - * Copyright (C) 2020 by Savoir-faire Linux - * Author: Mingrui Zhang <mingrui.zhang@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 QtWebEngine 1.10 -import QtWebChannel 1.15 - -import net.jami.Models 1.1 -import net.jami.Adapters 1.1 -import net.jami.Constants 1.1 - -import "../../commoncomponents" -import "../js/pluginhandlerpickercreation.js" as PluginHandlerPickerCreation - -Rectangle { - id: root - - property string headerUserAliasLabelText: "" - property string headerUserUserNameLabelText: "" - property bool jsLoaded: false - - signal needToHideConversationInCall - signal messagesCleared - signal messagesLoaded - - function setSendMessageContent(content) { - jsBridgeObject.setSendMessageContentRequest(content) - } - - function focusMessageWebView() { - messageWebViewFooter.textInput.forceActiveFocus() - } - - function webViewRunJavaScript(arg) { - messageWebView.runJavaScript(arg) - } - - function updateChatviewTheme() { - var theme = 'setTheme("\ - --svg-invert-percentage:' + JamiTheme.invertPercentageInDecimal + ';\ - --jami-light-blue:' + JamiTheme.jamiLightBlue + ';\ - --jami-dark-blue: ' + JamiTheme.jamiDarkBlue + ';\ - --text-color: ' + JamiTheme.chatviewTextColor + ';\ - --timestamp-color:' + JamiTheme.timestampColor + ';\ - --message-out-bg:' + JamiTheme.messageOutBgColor + ';\ - --message-out-txt:' + JamiTheme.messageOutTxtColor + ';\ - --message-in-bg:' + JamiTheme.messageInBgColor + ';\ - --message-in-txt:' + JamiTheme.messageInTxtColor + ';\ - --file-in-timestamp-color:' + JamiTheme.fileOutTimestampColor + ';\ - --file-out-timestamp-color:' + JamiTheme.fileInTimestampColor + ';\ - --bg-color:' + JamiTheme.chatviewBgColor + ';\ - --action-icon-color:' + JamiTheme.chatviewButtonColor + ';\ - --action-icon-hover-color:' + JamiTheme.hoveredButtonColor + ';\ - --action-icon-press-color:' + JamiTheme.pressedButtonColor + ';\ - --placeholder-text-color:' + JamiTheme.placeholderTextColor + ';\ - --invite-hover-color:' + JamiTheme.inviteHoverColor + ';\ - --bg-text-input:' + JamiTheme.bgTextInput + ';\ - --bg-invitation-rect:' + JamiTheme.bgInvitationRectColor + ';\ - --preview-text-container-color:' + JamiTheme.previewTextContainerColor + ';\ - --preview-title-color:' + JamiTheme.previewTitleColor + ';\ - --preview-subtitle-color:' + JamiTheme.previewSubtitleColor + ';\ - --preview-image-background-color:' + JamiTheme.previewImageBackgroundColor + ';\ - --preview-card-container-color:' + JamiTheme.previewCardContainerColor + ';\ - --preview-url-color:' + JamiTheme.previewUrlColor + ';")' - messageWebView.runJavaScript("init_picker(" + JamiTheme.darkTheme + ");") - messageWebView.runJavaScript(theme); - } - - color: JamiTheme.primaryBackgroundColor - - Connections { - target: JamiTheme - - function onDarkThemeChanged() { - updateChatviewTheme() - } - } - - QtObject { - id: jsBridgeObject - - // ID, under which this object will be known at chatview.js side. - WebChannel.id: "jsbridge" - - // signals to trigger functions in chatview.js - // mainly used to avoid input arg string escape - signal setSendMessageContentRequest(string content) - - // Functions that are exposed, return code can be derived from js side - // by setting callback function. - function deleteInteraction(arg) { - MessagesAdapter.deleteInteraction(arg) - } - - function retryInteraction(arg) { - MessagesAdapter.retryInteraction(arg) - } - - function openFile(arg) { - MessagesAdapter.openFile(arg) - } - - function acceptFile(arg) { - MessagesAdapter.acceptFile(arg) - } - - function refuseFile(arg) { - MessagesAdapter.refuseFile(arg) - } - - function emitMessagesCleared() { - root.messagesCleared() - } - - function emitMessagesLoaded() { - root.messagesLoaded() - } - - function copyToDownloads(interactionId, displayName) { - MessagesAdapter.copyToDownloads(interactionId, displayName) - } - - function parseI18nData() { - return MessagesAdapter.chatviewTranslatedStrings - } - - function loadMessages(n) { - return MessagesAdapter.loadMessages(n) - } - } - - ColumnLayout { - anchors.fill: root - - spacing: 0 - - MessageWebViewHeader { - id: messageWebViewHeader - - Layout.alignment: Qt.AlignHCenter - Layout.fillWidth: true - Layout.preferredHeight: JamiTheme.messageWebViewHeaderPreferredHeight - Layout.maximumHeight: JamiTheme.messageWebViewHeaderPreferredHeight - - userAliasLabelText: headerUserAliasLabelText - userUserNameLabelText: headerUserUserNameLabelText - - DropArea { - anchors.fill: parent - onDropped: messageWebViewFooter.setFilePathsToSend(drop.urls) - } - - onBackClicked: { - mainView.showWelcomeView() - } - - onNeedToHideConversationInCall: { - root.needToHideConversationInCall() - } - - onPluginSelector: { - // Create plugin handler picker - PLUGINS - PluginHandlerPickerCreation.createPluginHandlerPickerObjects( - root, false) - PluginHandlerPickerCreation.calculateCurrentGeo(root.width / 2, - root.height / 2) - PluginHandlerPickerCreation.openPluginHandlerPicker() - } - } - - StackLayout { - id: messageWebViewStack - - Layout.alignment: Qt.AlignHCenter - Layout.fillWidth: true - Layout.fillHeight: true - Layout.topMargin: JamiTheme.messageWebViewHairLineSize - Layout.bottomMargin: JamiTheme.messageWebViewHairLineSize - - currentIndex: CurrentConversation.isRequest || CurrentConversation.needsSyncing - - GeneralWebEngineView { - id: messageWebView - - Layout.fillWidth: true - Layout.fillHeight: true - - onCompletedLoadHtml: ":/chatview.html" - - webChannel.registeredObjects: [jsBridgeObject] - - DropArea { - anchors.fill: parent - onDropped: messageWebViewFooter.setFilePathsToSend(drop.urls) - } - - onLoadingChanged: { - if (loadRequest.status == WebEngineView.LoadSucceededStatus) { - messageWebView.runJavaScript(UtilsAdapter.getStyleSheet( - "chatcss", - UtilsAdapter.qStringFromFile( - ":/chatview.css"))) - messageWebView.runJavaScript(UtilsAdapter.getStyleSheet( - "chatwin", - UtilsAdapter.qStringFromFile( - ":/chatview-qt.css"))) - messageWebView.runJavaScript(UtilsAdapter.qStringFromFile( - ":/linkify.js")) - messageWebView.runJavaScript(UtilsAdapter.qStringFromFile( - ":/linkify-html.js")) - messageWebView.runJavaScript(UtilsAdapter.qStringFromFile( - ":/linkify-string.js")) - messageWebView.runJavaScript(UtilsAdapter.qStringFromFile( - ":/qwebchannel.js")) - messageWebView.runJavaScript(UtilsAdapter.qStringFromFile( - ":/jed.js")) - messageWebView.runJavaScript(UtilsAdapter.qStringFromFile( - ":/emoji.js")) - messageWebView.runJavaScript(UtilsAdapter.qStringFromFile( - ":/previewInfo.js")) - messageWebView.runJavaScript( - UtilsAdapter.qStringFromFile(":/chatview.js"), - function() { - messageWebView.runJavaScript("init_i18n();") - MessagesAdapter.setDisplayLinks() - updateChatviewTheme() - messageWebView.runJavaScript("displayNavbar(false);") - messageWebView.runJavaScript("hideMessageBar(true);") - jsLoaded = true - }) - } - } - } - - InvitationView { - id: invitationView - - Layout.fillWidth: true - Layout.fillHeight: true - } - } - - ReadOnlyFooter { - visible: CurrentConversation.readOnly - Layout.fillWidth: true - } - - MessageWebViewFooter { - id: messageWebViewFooter - - visible: { - if (CurrentConversation.needsSyncing || CurrentConversation.readOnly) - return false - else if (CurrentConversation.isSwarm && CurrentConversation.isRequest) - return false - return true - } - - Layout.alignment: Qt.AlignHCenter - Layout.fillWidth: true - Layout.preferredHeight: implicitHeight - Layout.maximumHeight: JamiTheme.messageWebViewFooterMaximumHeight - - DropArea { - anchors.fill: parent - onDropped: messageWebViewFooter.setFilePathsToSend(drop.urls) - } - } - } -} diff --git a/src/mainview/components/MessageWebViewHeader.qml b/src/mainview/components/MessageWebViewHeader.qml index fd30b4930..de14a33f1 100644 --- a/src/mainview/components/MessageWebViewHeader.qml +++ b/src/mainview/components/MessageWebViewHeader.qml @@ -223,7 +223,7 @@ Rectangle { lBorderwidth: 0 rBorderwidth: 0 tBorderwidth: 0 - bBorderwidth: JamiTheme.messageWebViewHairLineSize + bBorderwidth: JamiTheme.chatViewHairLineSize borderColor: JamiTheme.tabbarBorderColor } } diff --git a/src/mainview/components/ReadOnlyFooter.qml b/src/mainview/components/ReadOnlyFooter.qml index 5869e541f..97033315e 100644 --- a/src/mainview/components/ReadOnlyFooter.qml +++ b/src/mainview/components/ReadOnlyFooter.qml @@ -34,7 +34,7 @@ Control { Rectangle { anchors.top: parent.top - height: JamiTheme.messageWebViewHairLineSize + height: JamiTheme.chatViewHairLineSize width: parent.width color: JamiTheme.tabbarBorderColor } diff --git a/src/messagesadapter.cpp b/src/messagesadapter.cpp index ec68a5cea..fb6d7ef2b 100644 --- a/src/messagesadapter.cpp +++ b/src/messagesadapter.cpp @@ -1,4 +1,4 @@ -/*! +/* * Copyright (C) 2020 by Savoir-faire Linux * Author: Edric Ladent Milaret <edric.ladent-milaret@savoirfairelinux.com> * Author: Anthony Léonard <anthony.leonard@savoirfairelinux.com> @@ -26,7 +26,6 @@ #include "appsettingsmanager.h" #include "qtutils.h" #include "utils.h" -#include "webchathelpers.h" #include <api/datatransfermodel.h> @@ -39,13 +38,30 @@ #include <QUrl> #include <QMimeData> #include <QBuffer> +#include <QtMath> MessagesAdapter::MessagesAdapter(AppSettingsManager* settingsManager, + PreviewEngine* previewEngine, LRCInstance* instance, QObject* parent) : QmlAdapterBase(instance, parent) , settingsManager_(settingsManager) -{} + , previewEngine_(previewEngine) + , filteredMsgListModel_(new FilteredMsgListModel(this)) +{ + connect(lrcInstance_, &LRCInstance::selectedConvUidChanged, [this]() { + const QString& convId = lrcInstance_->get_selectedConvUid(); + const auto& conversation = lrcInstance_->getConversationFromConvUid(convId); + filteredMsgListModel_->setSourceModel(conversation.interactions.get()); + set_messageListModel(QVariant::fromValue(filteredMsgListModel_)); + }); + + connect(previewEngine_, &PreviewEngine::infoReady, this, &MessagesAdapter::onPreviewInfoReady); + connect(previewEngine_, + &PreviewEngine::linkifyReady, + this, + &MessagesAdapter::onMessageLinkified); +} void MessagesAdapter::safeInit() @@ -59,71 +75,26 @@ MessagesAdapter::safeInit() void MessagesAdapter::setupChatView(const QVariantMap& convInfo) { - Utils::oneShotConnect(qmlObj_, SIGNAL(messagesCleared()), this, SLOT(slotMessagesCleared())); - setMessagesVisibility(false); - clearChatView(); - setIsSwarm(convInfo["isSwarm"].toBool()); + auto* convModel = lrcInstance_->getCurrentConversationModel(); + auto convId = convInfo["convId"].toString(); + if (convInfo["isSwarm"].toBool()) { + convModel->loadConversationMessages(convId, loadChunkSize_); + } + // TODO: current conv observe Q_EMIT newMessageBarPlaceholderText(convInfo["title"].toString()); } void -MessagesAdapter::onNewInteraction(const QString& convUid, - const QString& interactionId, - const lrc::api::interaction::Info& interaction) +MessagesAdapter::loadMoreMessages() { auto accountId = lrcInstance_->get_currentAccountId(); - newInteraction(accountId, convUid, interactionId, interaction); -} - -void -MessagesAdapter::onInteractionStatusUpdated(const QString& convUid, - const QString& interactionId, - const lrc::api::interaction::Info& interaction) -{ - auto currentConversationModel = lrcInstance_->getCurrentConversationModel(); - updateInteraction(*currentConversationModel, interactionId, interaction); -} - -void -MessagesAdapter::onInteractionRemoved(const QString& convUid, const QString& interactionId) -{ - Q_UNUSED(convUid); - removeInteraction(interactionId); -} - -void -MessagesAdapter::onNewMessagesAvailable(const QString& accountId, const QString& conversationId) -{ - auto* convModel = lrcInstance_->accountModel().getAccountInfo(accountId).conversationModel.get(); - auto optConv = convModel->getConversationForUid(conversationId); - if (!optConv) - return; - updateHistory(*convModel, optConv->get().interactions, optConv->get().allMessagesLoaded); - Utils::oneShotConnect(qmlObj_, SIGNAL(messagesLoaded()), this, SLOT(slotMessagesLoaded())); -} - -void -MessagesAdapter::updateConversation(const QString& conversationId) -{ - if (conversationId != lrcInstance_->get_selectedConvUid()) - return; - auto* convModel = lrcInstance_->getCurrentConversationModel(); - if (auto optConv = convModel->getConversationForUid(conversationId)) - setConversationProfileData(optConv->get()); -} - -void -MessagesAdapter::onComposingStatusChanged(const QString& convId, - const QString& contactUri, - bool isComposing) -{ - if (convId != lrcInstance_->get_selectedConvUid()) - return; - if (!settingsManager_->getValue(Settings::Key::EnableTypingIndicator).toBool()) { - return; + auto convId = lrcInstance_->get_selectedConvUid(); + const auto& convInfo = lrcInstance_->getConversationFromConvUid(convId, accountId); + if (convInfo.isSwarm()) { + auto* convModel = lrcInstance_->getCurrentConversationModel(); + convModel->loadConversationMessages(convId, loadChunkSize_); } - contactIsComposing(contactUri, isComposing); } void @@ -138,33 +109,9 @@ MessagesAdapter::connectConversationModel() Qt::UniqueConnection); QObject::connect(currentConversationModel, - &ConversationModel::interactionStatusUpdated, + &ConversationModel::conversationMessagesLoaded, this, - &MessagesAdapter::onInteractionStatusUpdated, - Qt::UniqueConnection); - - QObject::connect(currentConversationModel, - &ConversationModel::interactionRemoved, - this, - &MessagesAdapter::onInteractionRemoved, - Qt::UniqueConnection); - - QObject::connect(currentConversationModel, - &ConversationModel::newMessagesAvailable, - this, - &MessagesAdapter::onNewMessagesAvailable, - Qt::UniqueConnection); - - QObject::connect(currentConversationModel, - &ConversationModel::conversationReady, - this, - &MessagesAdapter::updateConversation, - Qt::UniqueConnection); - - QObject::connect(currentConversationModel, - &ConversationModel::composingStatusChanged, - this, - &MessagesAdapter::onComposingStatusChanged, + &MessagesAdapter::onConversationMessagesLoaded, Qt::UniqueConnection); } @@ -174,29 +121,6 @@ MessagesAdapter::sendConversationRequest() lrcInstance_->makeConversationPermanent(); } -void -MessagesAdapter::slotMessagesCleared() -{ - auto* convModel = lrcInstance_->getCurrentConversationModel(); - - auto optConv = convModel->getConversationForUid(lrcInstance_->get_selectedConvUid()); - if (!optConv) - return; - if (optConv->get().isSwarm() && !optConv->get().allMessagesLoaded) { - convModel->loadConversationMessages(optConv->get().uid, 20); - } else { - updateHistory(*convModel, optConv->get().interactions, optConv->get().allMessagesLoaded); - Utils::oneShotConnect(qmlObj_, SIGNAL(messagesLoaded()), this, SLOT(slotMessagesLoaded())); - } - setConversationProfileData(optConv->get()); -} - -void -MessagesAdapter::slotMessagesLoaded() -{ - setMessagesVisibility(true); -} - void MessagesAdapter::sendMessage(const QString& message) { @@ -279,7 +203,7 @@ MessagesAdapter::refuseFile(const QString& interactionId) } void -MessagesAdapter::pasteKeyDetected() +MessagesAdapter::onPaste() { const QMimeData* mimeData = QApplication::clipboard()->mimeData(); @@ -328,186 +252,24 @@ MessagesAdapter::userIsComposing(bool isComposing) } void -MessagesAdapter::setConversationProfileData(const conversation::Info& convInfo) -{ - // make the all the participant avatars available within the web view - for (const auto& participant : convInfo.participants) { - QByteArray ba; - QBuffer bu(&ba); - Utils::conversationAvatar(lrcInstance_, convInfo.uid).save(&bu, "PNG"); - setSenderImage(participant, QString::fromLocal8Bit(ba.toBase64())); - } -} - -void -MessagesAdapter::newInteraction(const QString& accountId, - const QString& convUid, - const QString& interactionId, - const interaction::Info& interaction) +MessagesAdapter::onNewInteraction(const QString& convUid, + const QString& interactionId, + const interaction::Info& interaction) { Q_UNUSED(interactionId); try { if (convUid.isEmpty() || convUid != lrcInstance_->get_selectedConvUid()) { return; } + auto accountId = lrcInstance_->get_currentAccountId(); auto& accountInfo = lrcInstance_->getAccountInfo(accountId); auto& convModel = accountInfo.conversationModel; convModel->clearUnreadInteractions(convUid); - printNewInteraction(*convModel, interactionId, interaction); Q_EMIT newInteraction(static_cast<int>(interaction.type)); } catch (...) { } } -/* - * JS invoke. - */ -void -MessagesAdapter::setMessagesVisibility(bool visible) -{ - QString s = QString::fromLatin1(visible ? "showMessagesDiv();" : "hideMessagesDiv();"); - QMetaObject::invokeMethod(qmlObj_, "webViewRunJavaScript", Q_ARG(QVariant, s)); -} - -void -MessagesAdapter::setIsSwarm(bool isSwarm) -{ - QString s = QString::fromLatin1("set_is_swarm(%1)").arg(isSwarm); - QMetaObject::invokeMethod(qmlObj_, "webViewRunJavaScript", Q_ARG(QVariant, s)); -} - -void -MessagesAdapter::clearChatView() -{ - QString s = QString::fromLatin1("clearMessages();"); - QMetaObject::invokeMethod(qmlObj_, "webViewRunJavaScript", Q_ARG(QVariant, s)); -} - -void -MessagesAdapter::setDisplayLinks() -{ - QString s - = QString::fromLatin1("setDisplayLinks(%1);") - .arg(settingsManager_->getValue(Settings::Key::DisplayHyperlinkPreviews).toBool()); - QMetaObject::invokeMethod(qmlObj_, "webViewRunJavaScript", Q_ARG(QVariant, s)); -} - -void -MessagesAdapter::updateHistory(lrc::api::ConversationModel& conversationModel, - MessagesList interactions, - bool allLoaded) -{ - auto conversationId = lrcInstance_->get_selectedConvUid(); - auto interactionsStr - = interactionsToJsonArrayObject(conversationModel, conversationId, interactions).toUtf8(); - QString s; - QTextStream out(&s); - out << "updateHistory(" << interactionsStr << ", " << (allLoaded? "true" : "false") << ");"; - QMetaObject::invokeMethod(qmlObj_, "webViewRunJavaScript", Q_ARG(QVariant, s)); - conversationModel.clearUnreadInteractions(conversationId); -} - -void -MessagesAdapter::setSenderImage(const QString& sender, const QString& senderImage) -{ - QJsonObject setSenderImageObject = QJsonObject(); - setSenderImageObject.insert("sender_contact_method", QJsonValue(sender)); - setSenderImageObject.insert("sender_image", QJsonValue(senderImage)); - - auto setSenderImageObjectString = QString( - QJsonDocument(setSenderImageObject).toJson(QJsonDocument::Compact)); - QString s = QString::fromLatin1("setSenderImage(%1);") - .arg(setSenderImageObjectString.toUtf8().constData()); - QMetaObject::invokeMethod(qmlObj_, "webViewRunJavaScript", Q_ARG(QVariant, s)); -} - -void -MessagesAdapter::printNewInteraction(lrc::api::ConversationModel& conversationModel, - const QString& msgId, - const lrc::api::interaction::Info& interaction) -{ - auto interactionObject = interactionToJsonInteractionObject(conversationModel, - lrcInstance_->get_selectedConvUid(), - msgId, - interaction) - .toUtf8(); - if (interactionObject.isEmpty()) { - return; - } - QString s = QString::fromLatin1("addMessage(%1);").arg(interactionObject.constData()); - QMetaObject::invokeMethod(qmlObj_, "webViewRunJavaScript", Q_ARG(QVariant, s)); -} - -void -MessagesAdapter::updateInteraction(lrc::api::ConversationModel& conversationModel, - const QString& msgId, - const lrc::api::interaction::Info& interaction) -{ - auto interactionObject = interactionToJsonInteractionObject(conversationModel, - lrcInstance_->get_selectedConvUid(), - msgId, - interaction) - .toUtf8(); - if (interactionObject.isEmpty()) { - return; - } - QString s = QString::fromLatin1("updateMessage(%1);").arg(interactionObject.constData()); - QMetaObject::invokeMethod(qmlObj_, "webViewRunJavaScript", Q_ARG(QVariant, s)); -} - -void -MessagesAdapter::setMessagesImageContent(const QString& path, bool isBased64) -{ - if (isBased64) { - QString param = QString("addImage_base64('%1')").arg(path); - QMetaObject::invokeMethod(qmlObj_, "webViewRunJavaScript", Q_ARG(QVariant, param)); - } else { - QString param = QString("addImage_path('file://%1')").arg(path); - QMetaObject::invokeMethod(qmlObj_, "webViewRunJavaScript", Q_ARG(QVariant, param)); - } -} - -void -MessagesAdapter::setMessagesFileContent(const QString& path) -{ - qint64 fileSize = QFileInfo(path).size(); - QString fileName = QFileInfo(path).fileName(); - - QString param = QString("addFile_path('%1','%2','%3')") - .arg(path, fileName, Utils::humanFileSize(fileSize)); - - QMetaObject::invokeMethod(qmlObj_, "webViewRunJavaScript", Q_ARG(QVariant, param)); -} - -void -MessagesAdapter::removeInteraction(const QString& interactionId) -{ - QString s = QString::fromLatin1("removeInteraction(%1);").arg(interactionId); - QMetaObject::invokeMethod(qmlObj_, "webViewRunJavaScript", Q_ARG(QVariant, s)); -} - -void -MessagesAdapter::setSendMessageContent(const QString& content) -{ - QMetaObject::invokeMethod(qmlObj_, "setSendMessageContent", Q_ARG(QVariant, content)); -} - -void -MessagesAdapter::contactIsComposing(const QString& contactUri, bool isComposing) -{ - auto* convModel = lrcInstance_->getCurrentConversationModel(); - auto convInfo = convModel->getConversationForUid(lrcInstance_->get_selectedConvUid()); - if (!convInfo) - return; - auto& conv = convInfo->get(); - bool showIsComposing = conv.participants.first() == contactUri; - if (showIsComposing) { - QString s - = QString::fromLatin1("showTypingIndicator(`%1`, %2);").arg(contactUri).arg(isComposing); - QMetaObject::invokeMethod(qmlObj_, "webViewRunJavaScript", Q_ARG(QVariant, s)); - } -} - void MessagesAdapter::acceptInvitation(const QString& convId) { @@ -577,12 +339,75 @@ MessagesAdapter::removeContact(const QString& convUid, bool banContact) } void -MessagesAdapter::loadMessages(int n) +MessagesAdapter::onPreviewInfoReady(QString messageId, QVariantMap info) { - auto* convModel = lrcInstance_->getCurrentConversationModel(); - auto convOpt = convModel->getConversationForUid(lrcInstance_->get_selectedConvUid()); - if (!convOpt) + const QString& convId = lrcInstance_->get_selectedConvUid(); + const QString& accId = lrcInstance_->get_currentAccountId(); + auto& conversation = lrcInstance_->getConversationFromConvUid(convId, accId); + conversation.interactions->addHyperlinkInfo(messageId, info); +} + +void +MessagesAdapter::onConversationMessagesLoaded(uint32_t, const QString& convId) +{ + if (convId != lrcInstance_->get_selectedConvUid()) return; - if (convOpt->get().isSwarm() && !convOpt->get().allMessagesLoaded) - convModel->loadConversationMessages(convOpt->get().uid, n); + Q_EMIT moreMessagesLoaded(); +} + +void +MessagesAdapter::parseMessageUrls(const QString& messageId, const QString& msg) +{ + previewEngine_->parseMessage(messageId, msg); +} + +void +MessagesAdapter::onMessageLinkified(const QString& messageId, const QString& linkified) +{ + const QString& convId = lrcInstance_->get_selectedConvUid(); + const QString& accId = lrcInstance_->get_currentAccountId(); + auto& conversation = lrcInstance_->getConversationFromConvUid(convId, accId); + conversation.interactions->linkifyMessage(messageId, linkified); +} + +bool +MessagesAdapter::isImage(const QString& message) +{ + QRegularExpression pattern("[^\\s]+(.*?)\\.(jpg|jpeg|png)$", + QRegularExpression::CaseInsensitiveOption); + QRegularExpressionMatch match = pattern.match(message); + return match.hasMatch(); +} + +bool +MessagesAdapter::isAnimatedImage(const QString& msg) +{ + QRegularExpression pattern("[^\\s]+(.*?)\\.(gif|apng|webp|avif|flif)$", + QRegularExpression::CaseInsensitiveOption); + QRegularExpressionMatch match = pattern.match(msg); + return match.hasMatch(); +} + +QString +MessagesAdapter::getFormattedTime(const quint64 timestamp) +{ + const auto now = QDateTime::currentDateTime(); + const auto seconds = now.toSecsSinceEpoch() - timestamp; + auto interval = qFloor(seconds / (3600 * 24)); + if (interval > 5) + return QLocale::system().toString(QDateTime::fromSecsSinceEpoch(timestamp), + QLocale::ShortFormat); + if (interval > 1) + return QObject::tr("%1 days ago").arg(interval); + if (interval == 1) + return QObject::tr("one day ago"); + interval = qFloor(seconds / 3600); + if (interval > 1) + return QObject::tr("%1 hours ago").arg(interval); + if (interval == 1) + return QObject::tr("one hour ago"); + interval = qFloor(seconds / 60); + if (interval > 1) + return QObject::tr("%1 minutes ago").arg(interval); + return QObject::tr("just now"); } diff --git a/src/messagesadapter.h b/src/messagesadapter.h index 278393d58..fd27ac9b1 100644 --- a/src/messagesadapter.h +++ b/src/messagesadapter.h @@ -1,4 +1,4 @@ -/*! +/* * Copyright (C) 2020 by Savoir-faire Linux * Author: Mingrui Zhang <mingrui.zhang@savoirfairelinux.com> * @@ -20,28 +20,66 @@ #include "lrcinstance.h" #include "qmladapterbase.h" +#include "previewengine.h" + #include "api/chatview.h" #include <QObject> #include <QString> +#include <QSortFilterProxyModel> + +class FilteredMsgListModel final : public QSortFilterProxyModel +{ + Q_OBJECT + +public: + explicit FilteredMsgListModel(QObject* parent = nullptr) + : QSortFilterProxyModel(parent) + { + sort(0, Qt::AscendingOrder); + } + + bool filterAcceptsRow(int sourceRow, const QModelIndex& sourceParent) const override + { + 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; + }; + + bool lessThan(const QModelIndex& left, const QModelIndex& right) const override + { + return left.row() > right.row(); + }; +}; + class AppSettingsManager; class MessagesAdapter final : public QmlAdapterBase { Q_OBJECT - Q_PROPERTY(QVariantMap chatviewTranslatedStrings MEMBER chatviewTranslatedStrings_ CONSTANT) + QML_RO_PROPERTY(QVariant, messageListModel) public: explicit MessagesAdapter(AppSettingsManager* settingsManager, + PreviewEngine* previewEngine, LRCInstance* instance, QObject* parent = nullptr); ~MessagesAdapter() = default; +Q_SIGNALS: + void newInteraction(int type); + void newMessageBarPlaceholderText(QString placeholderText); + void newFilePasted(QString filePath); + void newTextPasted(); + void previewInformationToQML(QString messageId, QStringList previewInformation); + void moreMessagesLoaded(); + protected: void safeInit() override; Q_INVOKABLE void setupChatView(const QVariantMap& convInfo); + Q_INVOKABLE void loadMoreMessages(); Q_INVOKABLE void connectConversationModel(); Q_INVOKABLE void sendConversationRequest(); Q_INVOKABLE void removeConversation(const QString& convUid); @@ -51,70 +89,39 @@ protected: Q_INVOKABLE void refuseInvitation(const QString& convUid = ""); Q_INVOKABLE void blockConversation(const QString& convUid = ""); Q_INVOKABLE void unbanContact(int index); - - // JS Q_INVOKABLE. - Q_INVOKABLE void setDisplayLinks(); Q_INVOKABLE void sendMessage(const QString& message); Q_INVOKABLE void sendFile(const QString& message); - Q_INVOKABLE void retryInteraction(const QString& interactionId); - Q_INVOKABLE void deleteInteraction(const QString& interactionId); - Q_INVOKABLE void openUrl(const QString& url); - Q_INVOKABLE void openFile(const QString& arg); Q_INVOKABLE void acceptFile(const QString& arg); Q_INVOKABLE void refuseFile(const QString& arg); - Q_INVOKABLE void pasteKeyDetected(); - Q_INVOKABLE void userIsComposing(bool isComposing); - Q_INVOKABLE void loadMessages(int n); + 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 QString getFormattedTime(const quint64 timestamp); + Q_INVOKABLE void parseMessageUrls(const QString& messageId, const QString& msg); + Q_INVOKABLE void onPaste(); // Run corrsponding js functions, c++ to qml. - void setMessagesVisibility(bool visible); - void setIsSwarm(bool isSwarm); - void clearChatView(); - void updateHistory(ConversationModel& conversationModel, - MessagesList interactions, - bool allLoaded); - void setSenderImage(const QString& sender, const QString& senderImage); - void printNewInteraction(lrc::api::ConversationModel& conversationModel, - const QString& msgId, - const lrc::api::interaction::Info& interaction); - void updateInteraction(lrc::api::ConversationModel& conversationModel, - const QString& msgId, - const lrc::api::interaction::Info& interaction); void setMessagesImageContent(const QString& path, bool isBased64 = false); void setMessagesFileContent(const QString& path); - void removeInteraction(const QString& interactionId); void setSendMessageContent(const QString& content); - void contactIsComposing(const QString& contactUri, bool isComposing); - -Q_SIGNALS: - void newInteraction(int type); - void newMessageBarPlaceholderText(QString placeholderText); - void newFilePasted(QString filePath); - void newTextPasted(); private Q_SLOTS: - void slotMessagesCleared(); - void slotMessagesLoaded(); void onNewInteraction(const QString& convUid, const QString& interactionId, const interaction::Info& interaction); - void onInteractionStatusUpdated(const QString& convUid, - const QString& interactionId, - const interaction::Info& interaction); - void onInteractionRemoved(const QString& convUid, const QString& interactionId); - void onNewMessagesAvailable(const QString& accountId, const QString& conversationId); - void updateConversation(const QString& conversationId); - void onComposingStatusChanged(const QString& uid, const QString& contactUri, bool isComposing); + void onPreviewInfoReady(QString messageIndex, QVariantMap urlInMessage); + void onConversationMessagesLoaded(uint32_t requestId, const QString& convId); + void onMessageLinkified(const QString& messageId, const QString& linkified); private: - void setConversationProfileData(const lrc::api::conversation::Info& convInfo); - void newInteraction(const QString& accountId, - const QString& convUid, - const QString& interactionId, - const interaction::Info& interaction); - - const QVariantMap chatviewTranslatedStrings_ {lrc::api::chatview::getTranslatedStrings()}; - AppSettingsManager* settingsManager_; + PreviewEngine* previewEngine_; + FilteredMsgListModel* filteredMsgListModel_; + + static constexpr const int loadChunkSize_ {20}; }; diff --git a/src/previewengine.cpp b/src/previewengine.cpp new file mode 100644 index 000000000..439a91c59 --- /dev/null +++ b/src/previewengine.cpp @@ -0,0 +1,94 @@ +/* + * 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/>. + */ + +#include "previewengine.h" + +#include <QtWebEngine> +#include <QWebEngineScript> +#include <QWebEngineProfile> +#include <QWebEngineSettings> + +PreviewEngine::PreviewEngine(QObject* parent) + : QWebEngineView(qobject_cast<QWidget*>(parent)) + , pimpl_(new PreviewEnginePrivate(this)) +{ + QWebEngineProfile* profile = QWebEngineProfile::defaultProfile(); + + QDir dataDir(QStandardPaths::writableLocation(QStandardPaths::AppLocalDataLocation)); + dataDir.cdUp(); + auto cachePath = dataDir.absolutePath() + "/jami"; + profile->setCachePath(cachePath); + profile->setPersistentStoragePath(cachePath); + profile->setPersistentCookiesPolicy(QWebEngineProfile::NoPersistentCookies); + profile->setHttpCacheType(QWebEngineProfile::NoCache); + + setPage(new QWebEnginePage(profile, this)); + + settings()->setAttribute(QWebEngineSettings::JavascriptEnabled, true); + settings()->setAttribute(QWebEngineSettings::ScrollAnimatorEnabled, false); + settings()->setAttribute(QWebEngineSettings::ErrorPageEnabled, false); + settings()->setAttribute(QWebEngineSettings::PluginsEnabled, false); + settings()->setAttribute(QWebEngineSettings::ScreenCaptureEnabled, false); + settings()->setAttribute(QWebEngineSettings::LinksIncludedInFocusChain, false); + settings()->setAttribute(QWebEngineSettings::LocalStorageEnabled, false); + settings()->setAttribute(QWebEngineSettings::AllowRunningInsecureContent, true); + settings()->setAttribute(QWebEngineSettings::LocalContentCanAccessRemoteUrls, true); + settings()->setAttribute(QWebEngineSettings::XSSAuditingEnabled, false); + settings()->setAttribute(QWebEngineSettings::LocalContentCanAccessFileUrls, true); + + setContextMenuPolicy(Qt::ContextMenuPolicy::NoContextMenu); + + channel_ = new QWebChannel(this); + channel_->registerObject(QStringLiteral("jsbridge"), pimpl_); + + page()->setWebChannel(channel_); + page()->runJavaScript(Utils::QByteArrayFromFile(":/linkify.js"), QWebEngineScript::MainWorld); + page()->runJavaScript(Utils::QByteArrayFromFile(":/linkify-string.js"), + QWebEngineScript::MainWorld); + page()->runJavaScript(Utils::QByteArrayFromFile(":/qwebchannel.js"), + QWebEngineScript::MainWorld); + page()->runJavaScript(Utils::QByteArrayFromFile(":/previewInfo.js"), + QWebEngineScript::MainWorld); + page()->runJavaScript(Utils::QByteArrayFromFile(":/misc/previewInterop.js"), + QWebEngineScript::MainWorld); +} + +void +PreviewEngine::parseMessage(const QString& messageId, const QString& msg) +{ + page()->runJavaScript(QString("parseMessage(`%1`, `%2`)").arg(messageId, msg)); +} + +void +PreviewEnginePrivate::log(const QString& str) +{ + qDebug() << str; +} + +void +PreviewEnginePrivate::infoReady(const QString& messageId, const QVariantMap& info) +{ + Q_EMIT parent_->infoReady(messageId, info); +} + +void +PreviewEnginePrivate::linkifyReady(const QString& messageId, const QString& linkified) +{ + Q_EMIT parent_->linkifyReady(messageId, linkified); +} diff --git a/src/previewengine.h b/src/previewengine.h new file mode 100644 index 000000000..9b45c6428 --- /dev/null +++ b/src/previewengine.h @@ -0,0 +1,64 @@ +/* + * 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/>. + */ + +#pragma once + +#include "utils.h" + +#include <QtWebChannel> +#include <QtWebEngine> +#include <QtWebEngineCore> +#include <QtWebEngine> +#include <QWebEngineView> + +class PreviewEngine; + +class PreviewEnginePrivate : public QObject +{ + Q_OBJECT +public: + explicit PreviewEnginePrivate(PreviewEngine* parent) + : parent_(parent) + {} + + Q_INVOKABLE void infoReady(const QString& messageId, const QVariantMap& info); + Q_INVOKABLE void linkifyReady(const QString& messageId, const QString& linkified); + Q_INVOKABLE void log(const QString& str); + +private: + PreviewEngine* parent_; +}; + +class PreviewEngine : public QWebEngineView +{ + Q_OBJECT +public: + explicit PreviewEngine(QObject* parent = nullptr); + ~PreviewEngine() = default; + + void parseMessage(const QString& messageId, const QString& msg); + +Q_SIGNALS: + void infoReady(const QString& messageId, const QVariantMap& info); + void linkifyReady(const QString& messageId, const QString& linkified); + +private: + QWebChannel* channel_; + PreviewEnginePrivate* pimpl_; +}; diff --git a/src/qmlregister.cpp b/src/qmlregister.cpp index 0b7599beb..31cdc5271 100644 --- a/src/qmlregister.cpp +++ b/src/qmlregister.cpp @@ -24,6 +24,7 @@ #include "contactadapter.h" #include "pluginadapter.h" #include "messagesadapter.h" +#include "previewengine.h" #include "utilsadapter.h" #include "conversationsadapter.h" #include "currentconversation.h" @@ -99,21 +100,22 @@ void registerTypes(QQmlEngine* engine, SystemTray* systemTray, LRCInstance* lrcInstance, - AppSettingsManager* appSettingsManager, + AppSettingsManager* settingsManager, + PreviewEngine* previewEngine, ScreenInfo* screenInfo, QObject* parent) { // setup the adapters (their lifetimes are that of MainApplication) auto callAdapter = new CallAdapter(systemTray, lrcInstance, parent); - auto messagesAdapter = new MessagesAdapter(appSettingsManager, lrcInstance, parent); + auto messagesAdapter = new MessagesAdapter(settingsManager, previewEngine, lrcInstance, parent); auto conversationsAdapter = new ConversationsAdapter(systemTray, lrcInstance, parent); auto avAdapter = new AvAdapter(lrcInstance, parent); auto contactAdapter = new ContactAdapter(lrcInstance, parent); - auto accountAdapter = new AccountAdapter(appSettingsManager, lrcInstance, parent); - auto utilsAdapter = new UtilsAdapter(appSettingsManager, systemTray, lrcInstance, parent); + auto accountAdapter = new AccountAdapter(settingsManager, lrcInstance, parent); + auto utilsAdapter = new UtilsAdapter(settingsManager, systemTray, lrcInstance, parent); auto pluginAdapter = new PluginAdapter(lrcInstance, parent); auto currentConversation = new CurrentConversation(lrcInstance, parent); - auto currentAccount = new CurrentAccount(lrcInstance, appSettingsManager, parent); + auto currentAccount = new CurrentAccount(lrcInstance, settingsManager, parent); auto videoDevices = new VideoDevices(lrcInstance, parent); // qml adapter registration @@ -155,12 +157,14 @@ registerTypes(QQmlEngine* engine, QML_REGISTERTYPE(NS_MODELS, PluginListPreferenceModel); QML_REGISTERTYPE(NS_MODELS, FilesToSendListModel); QML_REGISTERTYPE(NS_MODELS, SmartListModel); + QML_REGISTERTYPE(NS_MODELS, MessageListModel); // Roles & type enums for models QML_REGISTERNAMESPACE(NS_MODELS, AccountList::staticMetaObject, "AccountList"); QML_REGISTERNAMESPACE(NS_MODELS, ConversationList::staticMetaObject, "ConversationList"); QML_REGISTERNAMESPACE(NS_MODELS, ContactList::staticMetaObject, "ContactList"); QML_REGISTERNAMESPACE(NS_MODELS, FilesToSend::staticMetaObject, "FilesToSend"); + QML_REGISTERNAMESPACE(NS_MODELS, MessageList::staticMetaObject, "MessageList"); // QQuickItems QML_REGISTERTYPE(NS_MODELS, PreviewRenderer); @@ -176,10 +180,10 @@ registerTypes(QQmlEngine* engine, QML_REGISTERSINGLETONTYPE_POBJECT(NS_CONSTANTS, screenInfo, "ScreenInfo") QML_REGISTERSINGLETONTYPE_POBJECT(NS_CONSTANTS, lrcInstance, "LRCInstance") - QML_REGISTERSINGLETONTYPE_POBJECT(NS_CONSTANTS, appSettingsManager, "AppSettingsManager") + QML_REGISTERSINGLETONTYPE_POBJECT(NS_CONSTANTS, settingsManager, "AppSettingsManager") auto avatarRegistry = new AvatarRegistry(lrcInstance, parent); - auto wizardViewStepModel = new WizardViewStepModel(lrcInstance, accountAdapter, appSettingsManager, parent); + auto wizardViewStepModel = new WizardViewStepModel(lrcInstance, accountAdapter, settingsManager, parent); QML_REGISTERSINGLETONTYPE_POBJECT(NS_HELPERS, avatarRegistry, "AvatarRegistry"); QML_REGISTERSINGLETONTYPE_POBJECT(NS_MODELS, wizardViewStepModel, "WizardViewStepModel") diff --git a/src/qmlregister.h b/src/qmlregister.h index 54f37b0ec..721de8716 100644 --- a/src/qmlregister.h +++ b/src/qmlregister.h @@ -33,6 +33,7 @@ class SystemTray; class LRCInstance; class AppSettingsManager; +class PreviewEngine; class ScreenInfo; // Hack for QtCreator autocomplete (part 1) @@ -63,6 +64,7 @@ void registerTypes(QQmlEngine* engine, SystemTray* systemTray, LRCInstance* lrcInstance, AppSettingsManager* appSettingsManager, + PreviewEngine* previewEngine, ScreenInfo* screenInfo, QObject* parent); } diff --git a/src/utils.cpp b/src/utils.cpp index 880a0c562..a903569b9 100644 --- a/src/utils.cpp +++ b/src/utils.cpp @@ -789,7 +789,7 @@ Utils::QByteArrayFromFile(const QString& filename) if (file.open(QIODevice::ReadOnly)) { return file.readAll(); } else { - qDebug() << "can't open file"; + qDebug() << "QByteArrayFromFile: can't open file"; return QByteArray(); } } diff --git a/src/webchathelpers.cpp b/src/webchathelpers.cpp deleted file mode 100644 index d265461a8..000000000 --- a/src/webchathelpers.cpp +++ /dev/null @@ -1,159 +0,0 @@ -/* - * Copyright (C) 2017-2020 by Savoir-faire Linux - * Author: Alexandre Viau <alexandre.viau@savoirfairelinux.com> - * Author: S�bastien Blin <sebastien.blin@savoirfairelinux.com> - * Author: Hugo Lefeuvre <hugo.lefeuvre@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 <http://www.gnu.org/licenses/>. - */ - -#include "webchathelpers.h" - -QJsonObject -buildInteractionJson(lrc::api::ConversationModel& conversationModel, - const QString& convId, - const QString msgId, - const lrc::api::interaction::Info& inter) -{ - QRegExp reg(".(jpeg|jpg|gif|png)$"); - auto interaction = inter; - if (interaction.type == lrc::api::interaction::Type::DATA_TRANSFER) { - if (interaction.body.isEmpty()) - return {}; - else if (interaction.body.toLower().contains(reg)) - interaction.body = "file://" + interaction.body; - } - - if (interaction.type == lrc::api::interaction::Type::MERGE) - return {}; - - auto sender = interaction.authorUri; - auto timestamp = QString::number(interaction.timestamp); - auto direction = lrc::api::interaction::isOutgoing(interaction) ? QString("out") - : QString("in"); - - QJsonObject interactionObject = QJsonObject(); - interactionObject.insert("text", QJsonValue(interaction.body)); - interactionObject.insert("id", QJsonValue(msgId)); - interactionObject.insert("sender", QJsonValue(sender)); - interactionObject.insert("sender_contact_method", QJsonValue(sender)); - interactionObject.insert("timestamp", QJsonValue(timestamp)); - interactionObject.insert("direction", QJsonValue(direction)); - interactionObject.insert("duration", QJsonValue(static_cast<int>(interaction.duration))); - - switch (interaction.type) { - case lrc::api::interaction::Type::TEXT: - interactionObject.insert("type", QJsonValue("text")); - break; - case lrc::api::interaction::Type::CALL: - interactionObject.insert("type", QJsonValue("call")); - break; - case lrc::api::interaction::Type::CONTACT: - interactionObject.insert("type", QJsonValue("contact")); - break; - case lrc::api::interaction::Type::DATA_TRANSFER: { - interactionObject.insert("type", QJsonValue("data_transfer")); - lrc::api::datatransfer::Info info = {}; - conversationModel.getTransferInfo(convId, msgId, info); - if (info.status != lrc::api::datatransfer::Status::INVALID) { - interactionObject.insert("totalSize", QJsonValue(qint64(info.totalSize))); - interactionObject.insert("progress", QJsonValue(qint64(info.progress))); - } - interactionObject.insert("displayName", QJsonValue(inter.commit["displayName"])); - break; - } - case lrc::api::interaction::Type::INVALID: - default: - return {}; - } - - if (interaction.isRead) { - interactionObject.insert("delivery_status", QJsonValue("read")); - } - - switch (interaction.status) { - case lrc::api::interaction::Status::SUCCESS: - interactionObject.insert("delivery_status", QJsonValue("sent")); - break; - case lrc::api::interaction::Status::FAILURE: - case lrc::api::interaction::Status::TRANSFER_ERROR: - interactionObject.insert("delivery_status", QJsonValue("failure")); - break; - case lrc::api::interaction::Status::TRANSFER_UNJOINABLE_PEER: - interactionObject.insert("delivery_status", QJsonValue("unjoinable peer")); - break; - case lrc::api::interaction::Status::SENDING: - interactionObject.insert("delivery_status", QJsonValue("sending")); - break; - case lrc::api::interaction::Status::TRANSFER_CREATED: - interactionObject.insert("delivery_status", QJsonValue("connecting")); - break; - case lrc::api::interaction::Status::TRANSFER_ACCEPTED: - interactionObject.insert("delivery_status", QJsonValue("accepted")); - break; - case lrc::api::interaction::Status::TRANSFER_CANCELED: - interactionObject.insert("delivery_status", QJsonValue("canceled")); - break; - case lrc::api::interaction::Status::TRANSFER_ONGOING: - interactionObject.insert("delivery_status", QJsonValue("ongoing")); - break; - case lrc::api::interaction::Status::TRANSFER_AWAITING_PEER: - interactionObject.insert("delivery_status", QJsonValue("awaiting peer")); - break; - case lrc::api::interaction::Status::TRANSFER_AWAITING_HOST: - interactionObject.insert("delivery_status", QJsonValue("awaiting host")); - break; - case lrc::api::interaction::Status::TRANSFER_TIMEOUT_EXPIRED: - interactionObject.insert("delivery_status", QJsonValue("awaiting peer timeout")); - break; - case lrc::api::interaction::Status::TRANSFER_FINISHED: - interactionObject.insert("delivery_status", QJsonValue("finished")); - break; - case lrc::api::interaction::Status::INVALID: - case lrc::api::interaction::Status::UNKNOWN: - default: - interactionObject.insert("delivery_status", QJsonValue("unknown")); - break; - } - return interactionObject; -} - -QString -interactionToJsonInteractionObject(lrc::api::ConversationModel& conversationModel, - const QString& convId, - const QString& msgId, - const lrc::api::interaction::Info& interaction) -{ - auto interactionObject = buildInteractionJson(conversationModel, convId, msgId, interaction); - return QString(QJsonDocument(interactionObject).toJson(QJsonDocument::Compact)); -} - -QString -interactionsToJsonArrayObject(lrc::api::ConversationModel& conversationModel, - const QString& convId, - MessagesList interactions) -{ - QJsonArray array; - for (const auto& interaction : interactions) { - auto interactionObject = buildInteractionJson(conversationModel, - convId, - interaction.first, - interaction.second); - if (!interactionObject.isEmpty()) { - array.append(interactionObject); - } - } - return QString(QJsonDocument(array).toJson(QJsonDocument::Compact)); -} diff --git a/src/webchathelpers.h b/src/webchathelpers.h deleted file mode 100644 index 88fc13085..000000000 --- a/src/webchathelpers.h +++ /dev/null @@ -1,42 +0,0 @@ -/* - * Copyright (C) 2017-2020 by Savoir-faire Linux - * Author: Alexandre Viau <alexandre.viau@savoirfairelinux.com> - * Author: Sébastien Blin <sebastien.blin@savoirfairelinux.com> - * Author: Hugo Lefeuvre <hugo.lefeuvre@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/>. - */ - -#pragma once - -#include <QFile> -#include <QJsonArray> -#include <QJsonDocument> -#include <QJsonObject> - -#include "lrcinstance.h" -#include "api/conversationmodel.h" - -QJsonObject buildInteractionJson(lrc::api::ConversationModel& conversationModel, - const QString& convId, - const QString& msgId, - lrc::api::interaction::Info& interaction); -QString interactionToJsonInteractionObject(lrc::api::ConversationModel& conversationModel, - const QString& convId, - const QString& msgId, - const lrc::api::interaction::Info& interaction); -QString interactionsToJsonArrayObject(lrc::api::ConversationModel& conversationModel, - const QString& convId, - MessagesList interactions); diff --git a/tests/qml/main.cpp b/tests/qml/main.cpp index 86ad4856a..c6d486242 100644 --- a/tests/qml/main.cpp +++ b/tests/qml/main.cpp @@ -22,6 +22,7 @@ #include "appsettingsmanager.h" #include "connectivitymonitor.h" #include "systemtray.h" +#include "previewengine.h" #include <atomic> @@ -75,6 +76,7 @@ public: systemTray_.get(), lrcInstance_.get(), settingsManager_.get(), + previewEngine_.get(), &screenInfo_, this); } @@ -116,6 +118,7 @@ private: QScopedPointer<ConnectivityMonitor> connectivityMonitor_; QScopedPointer<AppSettingsManager> settingsManager_; QScopedPointer<SystemTray> systemTray_; + QScopedPointer<PreviewEngine> previewEngine_; ScreenInfo screenInfo_; bool muteDring_ {false}; diff --git a/tests/qml/resources.qrc b/tests/qml/resources.qrc index ea2093b3f..9dd965abc 100644 --- a/tests/qml/resources.qrc +++ b/tests/qml/resources.qrc @@ -2,7 +2,7 @@ <qresource prefix="/"> <file>src/tst_LocalAccount.qml</file> <file>src/tst_PresenceIndicator.qml</file> - <file>src/tst_MessageWebViewFooter.qml</file> + <file>src/tst_ChatViewFooter.qml</file> <file>src/resources/gif_test.gif</file> <file>src/resources/gz_test.gz</file> <file>src/resources/png_test.png</file> diff --git a/tests/qml/src/tst_MessageWebViewFooter.qml b/tests/qml/src/tst_ChatViewFooter.qml similarity index 97% rename from tests/qml/src/tst_MessageWebViewFooter.qml rename to tests/qml/src/tst_ChatViewFooter.qml index 7d9b2c5e6..f4a7cc8f4 100644 --- a/tests/qml/src/tst_MessageWebViewFooter.qml +++ b/tests/qml/src/tst_ChatViewFooter.qml @@ -35,13 +35,13 @@ ColumnLayout { width: 300 height: uut.implicitHeight - MessageWebViewFooter { + ChatViewFooter { id: uut Layout.alignment: Qt.AlignHCenter Layout.fillWidth: true Layout.preferredHeight: implicitHeight - Layout.maximumHeight: JamiTheme.messageWebViewFooterMaximumHeight + Layout.maximumHeight: JamiTheme.chatViewMaximumWidth TestCase { name: "MessageWebViewFooter Send Message Button Visibility Test" diff --git a/tests/qml/src/tst_FilesToSendContainer.qml b/tests/qml/src/tst_FilesToSendContainer.qml index cdb845ca2..42f42d400 100644 --- a/tests/qml/src/tst_FilesToSendContainer.qml +++ b/tests/qml/src/tst_FilesToSendContainer.qml @@ -40,9 +40,9 @@ ColumnLayout { Layout.alignment: Qt.AlignHCenter Layout.preferredWidth: root.width - Layout.maximumWidth: JamiTheme.messageWebViewFooterContentMaximumWidth + Layout.maximumWidth: JamiTheme.chatViewMaximumWidth Layout.preferredHeight: filesToSendCount ? - JamiTheme.messageWebViewFooterFileContainerPreferredHeight : 0 + JamiTheme.chatViewFooterFileContainerPreferredHeight : 0 TestCase { name: "FilesToSendContainer add/remove file test" -- GitLab