Welcome to mirror list, hosted at ThFree Co, Russian Federation.

github.com/nextcloud/desktop.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorCamila <hello@camila.codes>2022-01-23 21:10:16 +0300
committerCamila <hello@camila.codes>2022-03-17 15:34:21 +0300
commit2c79c614bf9199216580925a5cfa67ae4173bb7c (patch)
treee5825ba7ead61b5c23150828c3e8f2eb7b47a3f8
parent253f4bb280be3e90241faedd6fee208aafe01f8c (diff)
Add TalkReply class and tests.feature/talk-reply
- 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 <hello@camila.codes>
-rw-r--r--resources.qrc1
-rw-r--r--src/gui/CMakeLists.txt2
-rw-r--r--src/gui/systray.h1
-rw-r--r--src/gui/tray/ActivityItem.qml6
-rw-r--r--src/gui/tray/ActivityItemActions.qml4
-rw-r--r--src/gui/tray/ActivityItemContent.qml16
-rw-r--r--src/gui/tray/TalkReplyTextField.qml77
-rw-r--r--src/gui/tray/activitydata.h6
-rw-r--r--src/gui/tray/activitylistmodel.cpp13
-rw-r--r--src/gui/tray/activitylistmodel.h2
-rw-r--r--src/gui/tray/notificationhandler.cpp15
-rw-r--r--src/gui/tray/talkreply.cpp44
-rw-r--r--src/gui/tray/talkreply.h24
-rw-r--r--src/gui/tray/usermodel.cpp9
-rw-r--r--src/gui/tray/usermodel.h4
-rw-r--r--test/CMakeLists.txt1
-rw-r--r--test/testtalkreply.cpp93
-rw-r--r--theme.qrc.in1
-rw-r--r--theme/send.svg1
19 files changed, 313 insertions, 7 deletions
diff --git a/resources.qrc b/resources.qrc
index a931e6716..c78b166fa 100644
--- a/resources.qrc
+++ b/resources.qrc
@@ -28,5 +28,6 @@
<file>src/gui/tray/ActivityItemContextMenu.qml</file>
<file>src/gui/tray/ActivityItemActions.qml</file>
<file>src/gui/tray/ActivityItemContent.qml</file>
+ <file>src/gui/tray/TalkReplyTextField.qml</file>
</qresource>
</RCC>
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..b47cd1f0b 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,9 @@ MouseArea {
Layout.fillWidth: true
Layout.leftMargin: 40
Layout.bottomMargin: model.links.length > 1 ? 5 : 0
+ // TODO this should be in the TalkReply Loader in ActivityItemContent
+ // but it did not work
+ Layout.topMargin: 20
displayActions: model.displayActions
objectType: model.objectType
diff --git a/src/gui/tray/ActivityItemActions.qml b/src/gui/tray/ActivityItemActions.qml
index 32c3b893f..ba450659b 100644
--- a/src/gui/tray/ActivityItemActions.qml
+++ b/src/gui/tray/ActivityItemActions.qml
@@ -34,7 +34,9 @@ RowLayout {
readonly property bool primary: model.index === 0 && model.modelData.verb !== "DELETE"
Layout.minimumWidth: primary ? Style.activityItemActionPrimaryButtonMinWidth : Style.activityItemActionSecondaryButtonMinWidth
- Layout.preferredHeight: primary ? parent.height : parent.height * 0.3
+ Layout.preferredHeight: primary && !isTalkReplyPossible
+ ? parent.height
+ : parent.height * 0.3
Layout.preferredWidth: primary ? -1 : parent.height
text: model.modelData.label
diff --git a/src/gui/tray/ActivityItemContent.qml b/src/gui/tray/ActivityItemContent.qml
index 7cfc59edc..7f953638b 100644
--- a/src/gui/tray/ActivityItemContent.qml
+++ b/src/gui/tray/ActivityItemContent.qml
@@ -126,9 +126,21 @@ RowLayout {
maximumLineCount: 2
font.pixelSize: Style.subLinePixelSize
color: "#808080"
- }
- }
+ }
+
+ Loader {
+ id: talkReplyTextFieldLoader
+ active: isChatActivity && isTalkReplyPossible
+ anchors.top: activityTextDateTime.bottom
+
+ 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..807876f8f
--- /dev/null
+++ b/src/gui/tray/TalkReplyTextField.qml
@@ -0,0 +1,77 @@
+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
+ leftPadding: 12
+
+ 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..a24bc453f 100644
--- a/src/gui/tray/activitylistmodel.cpp
+++ b/src/gui/tray/activitylistmodel.cpp
@@ -75,6 +75,9 @@ QHash<int, QByteArray> 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();
}
@@ -662,7 +669,8 @@ QVariant ActivityListModel::convertLinkToActionButton(const OCC::Activity &activ
const auto isReplyIconApplicable = activityLink._verb == QStringLiteral("WEB")
&& (activity._objectType == QStringLiteral("chat") || activity._objectType == QStringLiteral("call")
- || activity._objectType == QStringLiteral("room"));
+ || activity._objectType == QStringLiteral("room"))
+ && (activity._talkNotificationData.messageId == "" && activity._talkNotificationData.conversationToken == "");
const QString replyButtonPath = QStringLiteral("image://svgimage-custom-color/reply.svg");
@@ -675,7 +683,8 @@ QVariant ActivityListModel::convertLinkToActionButton(const OCC::Activity &activ
const auto isReplyLabelApplicable = activityLink._verb == QStringLiteral("WEB")
&& (activity._objectType == QStringLiteral("chat")
- || (activity._objectType != QStringLiteral("room") && activity._objectType != QStringLiteral("call")));
+ || (activity._objectType != QStringLiteral("room") && activity._objectType != QStringLiteral("call")))
+ && (activity._talkNotificationData.messageId == "" && activity._talkNotificationData.conversationToken == "");
if (activityLink._verb == QStringLiteral("DELETE")) {
activityLinkCopy._label = QObject::tr("Mark as read");
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 <QJsonDocument>
+#include <QJsonObject>
+#include <QJsonArray>
+
+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<JsonApiJob> 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 <QtCore>
+#include <QPointer>
+
+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> 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 <QJsonDocument>
+#include <QJsonObject>
+#include <QTest>
+#include <QSignalSpy>
+
+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> fakeQnam;
+ QScopedPointer<OCC::AccountState> 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<QNetworkReply*>(new FakeErrorReply(op, req, this, 404, QByteArrayLiteral("{error: \"Not found!\"}")));
+ }
+
+ return reply;
+ });
+
+ }
+
+ void testSendReplyMessage_noReplyToSet_messageIsSent()
+ {
+ QPointer<OCC::TalkReply> 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<QVariant> arguments = replyMessageSent.takeFirst();
+ QVERIFY(arguments.at(0).toString() == message);
+ }
+
+ void testSendReplyMessage_replyToSet_messageIsSent()
+ {
+ QPointer<OCC::TalkReply> 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<QVariant> 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 @@
<file>theme/black/email.svg</file>
<file>theme/black/edit.svg</file>
<file>theme/delete.svg</file>
+ <file>theme/send.svg</file>
</qresource>
</RCC>
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 @@
+<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 0 24 24" width="24px" fill="#000000"><path d="M0 0h24v24H0z" fill="none"/><path d="M2.01 21L23 12 2.01 3 2 10l15 2-15 2z"/></svg> \ No newline at end of file