- 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>
<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>
tray/notificationcache.h
tray/notificationcache.cpp
creds/credentialsfactory.h
+ tray/talkreply.cpp
creds/credentialsfactory.cpp
creds/httpcredentialsgui.h
creds/httpcredentialsgui.cpp
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()
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();
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)
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
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
--- /dev/null
+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")
+ }
+ }
+ }
+}
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;
roles[ShareableRole] = "isShareable";
roles[IsCurrentUserFileActivityRole] = "isCurrentUserFileActivity";
roles[ThumbnailRole] = "thumbnail";
+ roles[TalkConversationTokenRole] = "conversationToken";
+ roles[TalkMessageIdRole] = "messageId";
+
return roles;
}
const auto preview = a._previews[0];
return(generatePreviewMap(preview));
}
+ case TalkConversationTokenRole:
+ return a._talkNotificationData.conversationToken;
+ case TalkMessageIdRole:
+ return a._talkNotificationData.messageId;
default:
return QVariant();
}
ShareableRole,
IsCurrentUserFileActivityRole,
ThumbnailRole,
+ TalkConversationTokenRole,
+ TalkMessageIdRole,
};
Q_ENUM(DataRole)
//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();
--- /dev/null
+#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();
+}
+}
--- /dev/null
+#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;
+};
+}
#include "tray/activitylistmodel.h"
#include "tray/notificationcache.h"
#include "tray/unifiedsearchresultslistmodel.h"
+#include "tray/talkreply.h"
#include "userstatusconnector.h"
#include "thumbnailjob.h"
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)
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;
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);
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);
void slotRefreshImmediately();
void setNotificationRefreshInterval(std::chrono::milliseconds interval);
void slotRebuildNavigationAppList();
+ void slotSendReplyMessage(const QString &conversationToken, const QString &message, const QString &replyTo);
private:
void slotPushNotificationsReady();
// number of currently running notification requests. If non zero,
// no query for notifications is started.
int _notificationRequestsRunning;
+ QString textSentStr;
};
class UserModel : public QAbstractListModel
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)
--- /dev/null
+#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"
<file>theme/black/email.svg</file>
<file>theme/black/edit.svg</file>
<file>theme/delete.svg</file>
+ <file>theme/send.svg</file>
</qresource>
</RCC>
--- /dev/null
+<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