mirror of
https://github.com/nextcloud/desktop.git
synced 2024-11-29 12:19:03 +03:00
Merge pull request #4200 from nextcloud/feature/talk-reply
Feature/Talk Reply v1
This commit is contained in:
commit
d2b67ffea2
18 changed files with 304 additions and 4 deletions
|
@ -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>
|
||||
|
|
|
@ -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
|
||||
|
@ -393,7 +394,6 @@ function(generate_sized_png_from_svg icon_path size)
|
|||
set(icon_name_dir ${ARG_OUTPUT_ICON_PATH})
|
||||
endif ()
|
||||
|
||||
|
||||
if (EXISTS "${icon_name_dir}/${size}-${icon_name_wle}.png")
|
||||
return()
|
||||
endif()
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -127,6 +127,19 @@ RowLayout {
|
|||
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 {
|
||||
|
|
76
src/gui/tray/TalkReplyTextField.qml
Normal file
76
src/gui/tray/TalkReplyTextField.qml
Normal file
|
@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
|
|
|
@ -67,6 +67,8 @@ public:
|
|||
ShareableRole,
|
||||
IsCurrentUserFileActivityRole,
|
||||
ThumbnailRole,
|
||||
TalkConversationTokenRole,
|
||||
TalkMessageIdRole,
|
||||
};
|
||||
Q_ENUM(DataRole)
|
||||
|
||||
|
|
|
@ -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();
|
||||
|
|
44
src/gui/tray/talkreply.cpp
Normal file
44
src/gui/tray/talkreply.cpp
Normal file
|
@ -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();
|
||||
}
|
||||
}
|
24
src/gui/tray/talkreply.h
Normal file
24
src/gui/tray/talkreply.h
Normal file
|
@ -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;
|
||||
};
|
||||
}
|
|
@ -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;
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
93
test/testtalkreply.cpp
Normal file
93
test/testtalkreply.cpp
Normal file
|
@ -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"
|
|
@ -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>
|
||||
|
|
1
theme/send.svg
Normal file
1
theme/send.svg
Normal file
|
@ -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>
|
After Width: | Height: | Size: 194 B |
Loading…
Reference in a new issue