Merge pull request #4200 from nextcloud/feature/talk-reply

Feature/Talk Reply v1
This commit is contained in:
Matthieu Gallien 2022-03-17 23:36:33 +01:00 committed by GitHub
commit d2b67ffea2
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
18 changed files with 304 additions and 4 deletions

View file

@ -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>

View file

@ -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()

View file

@ -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();

View file

@ -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

View file

@ -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 {

View 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")
}
}
}
}

View file

@ -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;

View file

@ -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();
}

View file

@ -67,6 +67,8 @@ public:
ShareableRole,
IsCurrentUserFileActivityRole,
ThumbnailRole,
TalkConversationTokenRole,
TalkMessageIdRole,
};
Q_ENUM(DataRole)

View file

@ -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();

View 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
View 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;
};
}

View file

@ -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;

View file

@ -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

View file

@ -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
View 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"

View file

@ -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
View 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