From 73bae8cd309e555d3caf932ffec31906c1f4b4cd Mon Sep 17 00:00:00 2001 From: Camila Date: Sun, 23 Jan 2022 19:10:16 +0100 Subject: [PATCH] Add TalkReply class and tests. - Add struct TalkNotificationData to handle token and messageId. - Handle chat and call notifications with the new struct. - Add talk token and messageId to data roles in ActivityListModel. - Add Talk Reply component to the ActivityList. - User Loader to display the TalkReply component. - Move Talk Reply from ActivityItem to ActivityItemContent due to PR #4186. - Use TextField instead of Text. - Disable send reply button instead of changing border color when field is empty. Signed-off-by: Camila --- resources.qrc | 1 + src/gui/CMakeLists.txt | 2 +- src/gui/systray.h | 1 + src/gui/tray/ActivityItem.qml | 4 +- src/gui/tray/ActivityItemContent.qml | 17 ++++- src/gui/tray/TalkReplyTextField.qml | 76 +++++++++++++++++++++++ src/gui/tray/activitydata.h | 6 ++ src/gui/tray/activitylistmodel.cpp | 7 +++ src/gui/tray/activitylistmodel.h | 2 + src/gui/tray/notificationhandler.cpp | 15 +++++ src/gui/tray/talkreply.cpp | 44 +++++++++++++ src/gui/tray/talkreply.h | 24 +++++++ src/gui/tray/usermodel.cpp | 9 +++ src/gui/tray/usermodel.h | 4 ++ test/CMakeLists.txt | 1 + test/testtalkreply.cpp | 93 ++++++++++++++++++++++++++++ theme.qrc.in | 1 + theme/send.svg | 1 + 18 files changed, 304 insertions(+), 4 deletions(-) create mode 100644 src/gui/tray/TalkReplyTextField.qml create mode 100644 src/gui/tray/talkreply.cpp create mode 100644 src/gui/tray/talkreply.h create mode 100644 test/testtalkreply.cpp create mode 100644 theme/send.svg diff --git a/resources.qrc b/resources.qrc index a931e6716..c78b166fa 100644 --- a/resources.qrc +++ b/resources.qrc @@ -28,5 +28,6 @@ src/gui/tray/ActivityItemContextMenu.qml src/gui/tray/ActivityItemActions.qml src/gui/tray/ActivityItemContent.qml + src/gui/tray/TalkReplyTextField.qml diff --git a/src/gui/CMakeLists.txt b/src/gui/CMakeLists.txt index 3d1075a4b..8b47d33ff 100644 --- a/src/gui/CMakeLists.txt +++ b/src/gui/CMakeLists.txt @@ -207,6 +207,7 @@ set(client_SRCS tray/notificationcache.h tray/notificationcache.cpp creds/credentialsfactory.h + tray/talkreply.cpp creds/credentialsfactory.cpp creds/httpcredentialsgui.h creds/httpcredentialsgui.cpp @@ -392,7 +393,6 @@ function(generate_sized_png_from_svg icon_path size) if (ARG_OUTPUT_ICON_PATH) set(icon_name_dir ${ARG_OUTPUT_ICON_PATH}) endif () - if (EXISTS "${icon_name_dir}/${size}-${icon_name_wle}.png") return() diff --git a/src/gui/systray.h b/src/gui/systray.h index a4a6cbc64..84f90d34b 100644 --- a/src/gui/systray.h +++ b/src/gui/systray.h @@ -91,6 +91,7 @@ signals: void showWindow(); void openShareDialog(const QString &sharePath, const QString &localPath); void showFileActivityDialog(const QString &objectName, const int objectId); + void sendChatMessage(const QString &token, const QString &message, const QString &replyTo); public slots: void slotNewUserSelected(); diff --git a/src/gui/tray/ActivityItem.qml b/src/gui/tray/ActivityItem.qml index 9c049529a..b3363a7eb 100644 --- a/src/gui/tray/ActivityItem.qml +++ b/src/gui/tray/ActivityItem.qml @@ -12,7 +12,8 @@ MouseArea { property bool isFileActivityList: false - property bool isChatActivity: model.objectType === "chat" || model.objectType === "room" + property bool isChatActivity: model.objectType === "chat" || model.objectType === "room" || model.objectType === "call" + property bool isTalkReplyPossible: model.conversationToken !== "" signal fileActivityButtonClicked(string absolutePath) @@ -67,6 +68,7 @@ MouseArea { Layout.fillWidth: true Layout.leftMargin: 40 Layout.bottomMargin: model.links.length > 1 ? 5 : 0 + Layout.topMargin: isTalkReplyPossible? 48 : 0 displayActions: model.displayActions objectType: model.objectType diff --git a/src/gui/tray/ActivityItemContent.qml b/src/gui/tray/ActivityItemContent.qml index 7cfc59edc..aa7534c62 100644 --- a/src/gui/tray/ActivityItemContent.qml +++ b/src/gui/tray/ActivityItemContent.qml @@ -126,9 +126,22 @@ RowLayout { maximumLineCount: 2 font.pixelSize: Style.subLinePixelSize color: "#808080" - } - } + } + Loader { + id: talkReplyTextFieldLoader + active: isChatActivity && isTalkReplyPossible + + anchors.top: activityTextDateTime.bottom + anchors.topMargin: 10 + + sourceComponent: TalkReplyTextField { + id: talkReplyMessage + anchors.fill: parent + } + } + } + Button { id: dismissActionButton diff --git a/src/gui/tray/TalkReplyTextField.qml b/src/gui/tray/TalkReplyTextField.qml new file mode 100644 index 000000000..e59c22cec --- /dev/null +++ b/src/gui/tray/TalkReplyTextField.qml @@ -0,0 +1,76 @@ +import QtQuick 2.15 +import Style 1.0 +import QtQuick.Controls 2.15 +import QtQuick.Layouts 1.15 +import com.nextcloud.desktopclient 1.0 + +Item { + id: root + + function sendReplyMessage() { + if (replyMessageTextField.text === "") { + return; + } + + UserModel.currentUser.sendReplyMessage(model.conversationToken, replyMessageTextField.text, model.messageId); + replyMessageSent.text = replyMessageTextField.text; + replyMessageTextField.clear(); + } + + Text { + id: replyMessageSent + font.pixelSize: Style.topLinePixelSize + color: Style.menuBorder + visible: replyMessageSent.text !== "" + } + + TextField { + id: replyMessageTextField + + // TODO use Layout to manage width/height. The Layout.minimunWidth does not apply to the width set. + height: 38 + width: 250 + + onAccepted: root.sendReplyMessage() + visible: replyMessageSent.text === "" + + topPadding: 4 + + placeholderText: qsTr("Reply to …") + + background: Rectangle { + id: replyMessageTextFieldBorder + radius: 24 + border.width: 1 + border.color: Style.ncBlue + } + + Button { + id: sendReplyMessageButton + width: 32 + height: parent.height + opacity: 0.8 + flat: true + enabled: replyMessageTextField.text !== "" + onClicked: root.sendReplyMessage() + + icon { + source: "image://svgimage-custom-color/send.svg" + "/" + Style.ncBlue + width: 38 + height: 38 + color: hovered || !sendReplyMessageButton.enabled? Style.menuBorder : Style.ncBlue + } + + anchors { + right: replyMessageTextField.right + top: replyMessageTextField.top + } + + ToolTip { + visible: sendReplyMessageButton.hovered + delay: Qt.styleHints.mousePressAndHoldInterval + text: qsTr("Send reply to chat message") + } + } + } +} diff --git a/src/gui/tray/activitydata.h b/src/gui/tray/activitydata.h index 20b278326..e9f1b9192 100644 --- a/src/gui/tray/activitydata.h +++ b/src/gui/tray/activitydata.h @@ -109,10 +109,16 @@ public: QUrl link; // Optional (files only) }; + struct TalkNotificationData { + QString conversationToken; + QString messageId; + }; + Type _type; qlonglong _id; QString _fileAction; int _objectId; + TalkNotificationData _talkNotificationData; QString _objectType; QString _objectName; QString _subject; diff --git a/src/gui/tray/activitylistmodel.cpp b/src/gui/tray/activitylistmodel.cpp index 8cad1589e..45fcdf949 100644 --- a/src/gui/tray/activitylistmodel.cpp +++ b/src/gui/tray/activitylistmodel.cpp @@ -75,6 +75,9 @@ QHash ActivityListModel::roleNames() const roles[ShareableRole] = "isShareable"; roles[IsCurrentUserFileActivityRole] = "isCurrentUserFileActivity"; roles[ThumbnailRole] = "thumbnail"; + roles[TalkConversationTokenRole] = "conversationToken"; + roles[TalkMessageIdRole] = "messageId"; + return roles; } @@ -310,6 +313,10 @@ QVariant ActivityListModel::data(const QModelIndex &index, int role) const const auto preview = a._previews[0]; return(generatePreviewMap(preview)); } + case TalkConversationTokenRole: + return a._talkNotificationData.conversationToken; + case TalkMessageIdRole: + return a._talkNotificationData.messageId; default: return QVariant(); } diff --git a/src/gui/tray/activitylistmodel.h b/src/gui/tray/activitylistmodel.h index 3a688a636..9c966e92c 100644 --- a/src/gui/tray/activitylistmodel.h +++ b/src/gui/tray/activitylistmodel.h @@ -67,6 +67,8 @@ public: ShareableRole, IsCurrentUserFileActivityRole, ThumbnailRole, + TalkConversationTokenRole, + TalkMessageIdRole, }; Q_ENUM(DataRole) diff --git a/src/gui/tray/notificationhandler.cpp b/src/gui/tray/notificationhandler.cpp index 9acd325b9..aa5bd4155 100644 --- a/src/gui/tray/notificationhandler.cpp +++ b/src/gui/tray/notificationhandler.cpp @@ -100,6 +100,21 @@ void ServerNotificationHandler::slotNotificationsReceived(const QJsonDocument &j //need to know, specially for remote_share a._objectType = json.value("object_type").toString(); + + // 2 cases to consider: + // - server == 24 & has Talk: notification type chat/call contains conversationToken/messageId in object_type + // - server < 24 & has Talk: notification type chat/call contains _only_ the conversationToken in object_type + if (a._objectType == "chat" || a._objectType == "call") { + const auto objectId = json.value("object_id").toString(); + const auto objectIdData = objectId.split("/"); + a._talkNotificationData.conversationToken = objectIdData.first(); + if (a._objectType == "chat" && objectIdData.size() > 1) { + a._talkNotificationData.messageId = objectIdData.last(); + } else { + qCInfo(lcServerNotification) << "Replying directly to Talk conversation" << a._talkNotificationData.conversationToken << "will not be possible because the notification doesn't contain the message ID."; + } + } + a._status = 0; a._subject = json.value("subject").toString(); diff --git a/src/gui/tray/talkreply.cpp b/src/gui/tray/talkreply.cpp new file mode 100644 index 000000000..4819801cf --- /dev/null +++ b/src/gui/tray/talkreply.cpp @@ -0,0 +1,44 @@ +#include "talkreply.h" +#include "accountstate.h" + +#include +#include +#include + +namespace OCC { + +Q_LOGGING_CATEGORY(lcTalkReply, "nextcloud.gui.talkreply", QtInfoMsg) + +TalkReply::TalkReply(AccountState *accountState, QObject *parent) + : QObject(parent) + , _accountState(accountState) +{ + Q_ASSERT(_accountState && _accountState->account()); +} + +void TalkReply::sendReplyMessage(const QString &conversationToken, const QString &message, const QString &replyTo) +{ + QPointer apiJob = new JsonApiJob(_accountState->account(), + QLatin1String("ocs/v2.php/apps/spreed/api/v1/chat/%1").arg(conversationToken), + this); + + QObject::connect(apiJob, &JsonApiJob::jsonReceived, this, [&](const QJsonDocument &response, const int statusCode) { + if(statusCode != 200) { + qCWarning(lcTalkReply) << "Status code" << statusCode; + } + + const auto responseObj = response.object().value("ocs").toObject().value("data").toObject(); + emit replyMessageSent(responseObj.value("message").toString()); + + deleteLater(); + }); + + QUrlQuery params; + params.addQueryItem(QStringLiteral("message"), message); + params.addQueryItem(QStringLiteral("replyTo"), QString(replyTo)); + + apiJob->addQueryParams(params); + apiJob->setVerb(JsonApiJob::Verb::Post); + apiJob->start(); +} +} diff --git a/src/gui/tray/talkreply.h b/src/gui/tray/talkreply.h new file mode 100644 index 000000000..2b83cbacb --- /dev/null +++ b/src/gui/tray/talkreply.h @@ -0,0 +1,24 @@ +#pragma once + +#include +#include + +namespace OCC { +class AccountState; + +class TalkReply : public QObject +{ + Q_OBJECT + +public: + explicit TalkReply(AccountState *accountState, QObject *parent = nullptr); + + void sendReplyMessage(const QString &conversationToken, const QString &message, const QString &replyTo = {}); + +signals: + void replyMessageSent(const QString &message); + +private: + AccountState *_accountState = nullptr; +}; +} diff --git a/src/gui/tray/usermodel.cpp b/src/gui/tray/usermodel.cpp index 487d995cb..0a4eb632c 100644 --- a/src/gui/tray/usermodel.cpp +++ b/src/gui/tray/usermodel.cpp @@ -15,6 +15,7 @@ #include "tray/activitylistmodel.h" #include "tray/notificationcache.h" #include "tray/unifiedsearchresultslistmodel.h" +#include "tray/talkreply.h" #include "userstatusconnector.h" #include "thumbnailjob.h" @@ -79,6 +80,8 @@ User::User(AccountStatePtr &account, const bool &isCurrent, QObject *parent) connect(_account->account().data(), &Account::capabilitiesChanged, this, &User::accentColorChanged); connect(_activityModel, &ActivityListModel::sendNotificationRequest, this, &User::slotSendNotificationRequest); + + connect(this, &User::sendReplyMessage, this, &User::slotSendReplyMessage); } void User::showDesktopNotification(const QString &title, const QString &message) @@ -785,6 +788,12 @@ void User::removeAccount() const AccountManager::instance()->save(); } +void User::slotSendReplyMessage(const QString &token, const QString &message, const QString &replyTo) +{ + QPointer talkReply = new TalkReply(_account.data(), this); + talkReply->sendReplyMessage(token, message, replyTo); +} + /*-------------------------------------------------------------------------------------*/ UserModel *UserModel::_instance = nullptr; diff --git a/src/gui/tray/usermodel.h b/src/gui/tray/usermodel.h index 8844ef291..2f1fdc74d 100644 --- a/src/gui/tray/usermodel.h +++ b/src/gui/tray/usermodel.h @@ -38,6 +38,7 @@ class User : public QObject Q_PROPERTY(QString avatar READ avatarUrl NOTIFY avatarChanged) Q_PROPERTY(bool isConnected READ isConnected NOTIFY accountStateChanged) Q_PROPERTY(UnifiedSearchResultsListModel* unifiedSearchResultsListModel READ getUnifiedSearchResultsListModel CONSTANT) + public: User(AccountStatePtr &account, const bool &isCurrent = false, QObject *parent = nullptr); @@ -86,6 +87,7 @@ signals: void headerColorChanged(); void headerTextColorChanged(); void accentColorChanged(); + void sendReplyMessage(const QString &token, const QString &message, const QString &replyTo); public slots: void slotItemCompleted(const QString &folder, const SyncFileItemPtr &item); @@ -105,6 +107,7 @@ public slots: void slotRefreshImmediately(); void setNotificationRefreshInterval(std::chrono::milliseconds interval); void slotRebuildNavigationAppList(); + void slotSendReplyMessage(const QString &conversationToken, const QString &message, const QString &replyTo); private: void slotPushNotificationsReady(); @@ -139,6 +142,7 @@ private: // number of currently running notification requests. If non zero, // no query for notifications is started. int _notificationRequestsRunning; + QString textSentStr; }; class UserModel : public QAbstractListModel diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index ccecd5fce..7c142f4a6 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -63,6 +63,7 @@ nextcloud_add_test(SetUserStatusDialog) nextcloud_add_test(UnifiedSearchListmodel) nextcloud_add_test(ActivityListModel) nextcloud_add_test(ActivityData) +nextcloud_add_test(TalkReply) if( UNIX AND NOT APPLE ) nextcloud_add_test(InotifyWatcher) diff --git a/test/testtalkreply.cpp b/test/testtalkreply.cpp new file mode 100644 index 000000000..063efd410 --- /dev/null +++ b/test/testtalkreply.cpp @@ -0,0 +1,93 @@ +#include "tray/talkreply.h" + +#include "account.h" +#include "accountstate.h" +#include "syncenginetestutils.h" + +#include +#include +#include +#include + +namespace { + +//reply to message +//https://nextcloud-talk.readthedocs.io/en/latest/chat/#sending-a-new-chat-message +static QByteArray replyToMessageSent = R"({"ocs":{"meta":{"status":"ok","statuscode":201,"message":"OK"},"data":{"id":12,"token":"abc123","actorType":"users","actorId":"user1","actorDisplayName":"User 1","timestamp":1636474603,"message":"test message 2","messageParameters":[],"systemMessage":"","messageType":"comment","isReplyable":true,"referenceId":"","parent":{"id":10,"token":"abc123","actorType":"users","actorId":"user2","actorDisplayName":"User 2","timestamp":1624987427,"message":"test message 1","messageParameters":[],"systemMessage":"","messageType":"comment","isReplyable":true,"referenceId":"2857b6eb77b4d7f1f46c6783513e8ef4a0c7ac53"}}}} +)"; + +// only send message to chat +static QByteArray replyMessageSent = R"({"ocs":{"meta":{"status":"ok","statuscode":201,"message":"OK"},"data":{"id":11,"token":"abc123","actorType":"users","actorId":"user1","actorDisplayName":"User 1","timestamp":1636474440,"message":"test message 3","messageParameters":[],"systemMessage":"","messageType":"comment","isReplyable":true,"referenceId":""}}} +)"; + +} + +class TestTalkReply : public QObject +{ + Q_OBJECT + +public: + TestTalkReply() = default; + + OCC::AccountPtr account; + QScopedPointer fakeQnam; + QScopedPointer accountState; + +private slots: + void initTestCase() + { + fakeQnam.reset(new FakeQNAM({})); + account = OCC::Account::create(); + account->setCredentials(new FakeCredentials{fakeQnam.data()}); + account->setUrl(QUrl(("http://example.de"))); + accountState.reset(new OCC::AccountState(account)); + + fakeQnam->setOverride([this](QNetworkAccessManager::Operation op, const QNetworkRequest &req, QIODevice *device) { + Q_UNUSED(device); + QNetworkReply *reply = nullptr; + + const auto urlQuery = QUrlQuery(req.url()); + const auto message = urlQuery.queryItemValue(QStringLiteral("message")); + const auto replyTo = urlQuery.queryItemValue(QStringLiteral("replyTo")); + const auto path = req.url().path(); + + if (path.startsWith(QStringLiteral("/ocs/v2.php/apps/spreed/api/v1/chat")) && replyTo.isEmpty()) { + reply = new FakePayloadReply(op, req, replyMessageSent, fakeQnam.data()); + } else if (path.startsWith(QStringLiteral("/ocs/v2.php/apps/spreed/api/v1/chat")) && !replyTo.isEmpty()) { + reply = new FakePayloadReply(op, req, replyToMessageSent, fakeQnam.data()); + } + + if (!reply) { + return qobject_cast(new FakeErrorReply(op, req, this, 404, QByteArrayLiteral("{error: \"Not found!\"}"))); + } + + return reply; + }); + + } + + void testSendReplyMessage_noReplyToSet_messageIsSent() + { + QPointer talkReply = new OCC::TalkReply(accountState.data()); + const auto message = QStringLiteral("test message 3"); + talkReply->sendReplyMessage(QStringLiteral("abc123"), message); + QSignalSpy replyMessageSent(talkReply.data(), &OCC::TalkReply::replyMessageSent); + QVERIFY(replyMessageSent.wait()); + QList arguments = replyMessageSent.takeFirst(); + QVERIFY(arguments.at(0).toString() == message); + } + + void testSendReplyMessage_replyToSet_messageIsSent() + { + QPointer talkReply = new OCC::TalkReply(accountState.data()); + const auto message = QStringLiteral("test message 2"); + talkReply->sendReplyMessage(QStringLiteral("abc123"), message, QStringLiteral("11")); + QSignalSpy replyMessageSent(talkReply.data(), &OCC::TalkReply::replyMessageSent); + QVERIFY(replyMessageSent.wait()); + QList arguments = replyMessageSent.takeFirst(); + QVERIFY(arguments.at(0).toString() == message); + } +}; + +QTEST_MAIN(TestTalkReply) +#include "testtalkreply.moc" diff --git a/theme.qrc.in b/theme.qrc.in index c2d84b95f..e66e79bc2 100644 --- a/theme.qrc.in +++ b/theme.qrc.in @@ -213,5 +213,6 @@ theme/black/email.svg theme/black/edit.svg theme/delete.svg + theme/send.svg diff --git a/theme/send.svg b/theme/send.svg new file mode 100644 index 000000000..28572dec2 --- /dev/null +++ b/theme/send.svg @@ -0,0 +1 @@ + \ No newline at end of file