From 4bba505da62383b8bf0fb8d0cd7edc25484a5f62 Mon Sep 17 00:00:00 2001 From: James Graham Date: Fri, 9 Sep 2022 16:41:03 +0000 Subject: [PATCH] Initial work to add push rule support This commit adds the ability to set the master push rule and set push rules for individual rooms as per the matrix spec. See https://spec.matrix.org/v1.3/client-server-api/#push-rules. The master push rule is just on/off and uses the existing notification setting in general setting to enable/disable the server default master push rule .m.rule.master. For each room there is now a page in the room setting that allows the following to be set: - Default - All messages - @mentions and keywords - off New room or override rules are added/removed to achieve this. There is also functionality to check the master/room notification state whenever the setting menu is entered. This allows the status to be updated if changed in another client or get the initial state for a room as it isn't stored. Note - There is currently no menu items in the room list for setting the room push rule settings. This will be added in a later commit, the aim is to focus on making sure the technical implementation is good for now. --- imports/NeoChat/RoomSettings/Categories.qml | 12 +- .../NeoChat/RoomSettings/PushNotification.qml | 57 +++++ .../NeoChat/Settings/GeneralSettingsPage.qml | 1 + imports/NeoChat/Settings/SettingsPage.qml | 1 - res.qrc | 1 + src/CMakeLists.txt | 1 + src/main.cpp | 2 + src/neochatroom.cpp | 196 ++++++++++++++++++ src/neochatroom.h | 24 +++ src/notificationsmanager.cpp | 51 ++++- src/notificationsmanager.h | 12 ++ 11 files changed, 353 insertions(+), 5 deletions(-) create mode 100644 imports/NeoChat/RoomSettings/PushNotification.qml diff --git a/imports/NeoChat/RoomSettings/Categories.qml b/imports/NeoChat/RoomSettings/Categories.qml index 85dfe205b..6148fdd0a 100644 --- a/imports/NeoChat/RoomSettings/Categories.qml +++ b/imports/NeoChat/RoomSettings/Categories.qml @@ -3,12 +3,12 @@ import QtQuick 2.15 import org.kde.kirigami 2.18 as Kirigami -import QtQuick.Controls 2.15 as Controls import QtQuick.Layouts 1.15 Kirigami.CategorizedSettings { id: root property var room + objectName: "settingsPage" actions: [ Kirigami.SettingAction { @@ -30,6 +30,16 @@ Kirigami.CategorizedSettings { room: root.room } } + }, + Kirigami.SettingAction { + text: i18n("Notifications") + icon.name: "notifications" + page: Qt.resolvedUrl("PushNotification.qml") + initialProperties: { + return { + room: root.room + } + } } ] } diff --git a/imports/NeoChat/RoomSettings/PushNotification.qml b/imports/NeoChat/RoomSettings/PushNotification.qml new file mode 100644 index 000000000..0ad8bb52e --- /dev/null +++ b/imports/NeoChat/RoomSettings/PushNotification.qml @@ -0,0 +1,57 @@ +// SPDX-FileCopyrightText: 2022 James Graham +// SPDX-License-Identifier: GPL-2.0-or-later + +import QtQuick 2.15 +import QtQuick.Controls 2.15 as QQC2 +import QtQuick.Layouts 1.15 + +import org.kde.kirigami 2.15 as Kirigami + +import org.kde.neochat 1.0 + +Kirigami.ScrollablePage { + + property var room + + title: i18nc('@title:window', 'Notifications') + + ColumnLayout { + Kirigami.FormLayout { + Layout.fillWidth: true + + QQC2.RadioButton { + text: i18n("Default") + Kirigami.FormData.label: i18n("Room notifications setting:") + checked: room.pushNotificationState === PushNotificationState.Default + enabled: room.pushNotificationState != PushNotificationState.Unknown + onToggled: { + room.pushNotificationState = PushNotificationState.Default + } + } + QQC2.RadioButton { + text: i18n("All messages") + checked: room.pushNotificationState === PushNotificationState.All + enabled: room.pushNotificationState != PushNotificationState.Unknown + onToggled: { + room.pushNotificationState = PushNotificationState.All + } + } + QQC2.RadioButton { + text: i18n("@mentions and keywords") + checked: room.pushNotificationState === PushNotificationState.MentionKeyword + enabled: room.pushNotificationState != PushNotificationState.Unknown + onToggled: { + room.pushNotificationState = PushNotificationState.MentionKeyword + } + } + QQC2.RadioButton { + text: i18n("Off") + checked: room.pushNotificationState === PushNotificationState.Mute + enabled: room.pushNotificationState != PushNotificationState.Unknown + onToggled: { + room.pushNotificationState = PushNotificationState.Mute + } + } + } + } +} diff --git a/imports/NeoChat/Settings/GeneralSettingsPage.qml b/imports/NeoChat/Settings/GeneralSettingsPage.qml index 57521e362..8736d89b7 100644 --- a/imports/NeoChat/Settings/GeneralSettingsPage.qml +++ b/imports/NeoChat/Settings/GeneralSettingsPage.qml @@ -46,6 +46,7 @@ Kirigami.ScrollablePage { onToggled: { Config.showNotifications = checked Config.save() + NotificationsManager.globalNotificationsEnabled = checked } } QQC2.CheckBox { diff --git a/imports/NeoChat/Settings/SettingsPage.qml b/imports/NeoChat/Settings/SettingsPage.qml index 3840527e7..0313d2c31 100644 --- a/imports/NeoChat/Settings/SettingsPage.qml +++ b/imports/NeoChat/Settings/SettingsPage.qml @@ -3,7 +3,6 @@ import QtQuick 2.15 import org.kde.kirigami 2.18 as Kirigami -import QtQuick.Controls 2.15 as Controls import QtQuick.Layouts 1.15 Kirigami.CategorizedSettings { diff --git a/res.qrc b/res.qrc index ef2280d17..d991fc2f4 100644 --- a/res.qrc +++ b/res.qrc @@ -15,6 +15,7 @@ imports/NeoChat/Page/WelcomePage.qml imports/NeoChat/RoomSettings/General.qml imports/NeoChat/RoomSettings/Security.qml + imports/NeoChat/RoomSettings/PushNotification.qml imports/NeoChat/RoomSettings/Categories.qml imports/NeoChat/Component/qmldir imports/NeoChat/Component/FullScreenImage.qml diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 9527302d6..22959a2c2 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -139,6 +139,7 @@ if(ANDROID) "org.kde.neochat" "preferences-system-users" "preferences-desktop-theme-global" + "notifications" ) else() target_link_libraries(neochat PUBLIC Qt::Widgets KF5::KIOWidgets) diff --git a/src/main.cpp b/src/main.cpp index 6e70aab46..b2b31b21f 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -184,6 +184,7 @@ int main(int argc, char *argv[]) #endif qmlRegisterSingletonInstance("org.kde.neochat", 1, 0, "Controller", &Controller::instance()); + qmlRegisterSingletonInstance("org.kde.neochat", 1, 0, "NotificationsManager", &NotificationsManager::instance()); qmlRegisterSingletonInstance("org.kde.neochat", 1, 0, "Clipboard", &clipboard); qmlRegisterSingletonInstance("org.kde.neochat", 1, 0, "Config", config); qmlRegisterSingletonInstance("org.kde.neochat", 1, 0, "RoomManager", &RoomManager::instance()); @@ -210,6 +211,7 @@ int main(int argc, char *argv[]) qmlRegisterType("org.kde.neochat", 1, 0, "SortFilterSpaceListModel"); qmlRegisterType("org.kde.neochat", 1, 0, "DevicesModel"); qmlRegisterUncreatableType("org.kde.neochat", 1, 0, "RoomMessageEvent", "ENUM"); + qmlRegisterUncreatableType("org.kde.neochat", 1, 0, "PushNotificationState", "ENUM"); qmlRegisterUncreatableType("org.kde.neochat", 1, 0, "NeoChatRoomType", "ENUM"); qmlRegisterUncreatableType("org.kde.neochat", 1, 0, "UserType", "ENUM"); diff --git a/src/neochatroom.cpp b/src/neochatroom.cpp index e03fe03b9..74e483e38 100644 --- a/src/neochatroom.cpp +++ b/src/neochatroom.cpp @@ -23,6 +23,7 @@ #include #include #include +#include #include #include #include @@ -39,6 +40,7 @@ #include #include +#include "controller.h" #include "neochatconfig.h" #include "notificationsmanager.h" #include "stickerevent.h" @@ -49,6 +51,7 @@ NeoChatRoom::NeoChatRoom(Connection *connection, QString roomId, JoinState joinState) : Room(connection, std::move(roomId), joinState) { + connect(connection, &Connection::accountDataChanged, this, &NeoChatRoom::updatePushNotificationState); connect(this, &NeoChatRoom::notificationCountChanged, this, &NeoChatRoom::countChanged); connect(this, &NeoChatRoom::highlightCountChanged, this, &NeoChatRoom::countChanged); connect(this, &Room::fileTransferCompleted, this, [this] { @@ -886,3 +889,196 @@ bool NeoChatRoom::isSpace() return false; #endif } + +void NeoChatRoom::setPushNotificationState(PushNotificationState::State state) +{ + // The caller should never try to set the state to unknown. + // It exists only as a default state to diable the settings options until the actual state is retrieved from the server. + if (state == PushNotificationState::Unknown) { + Q_ASSERT(false); + return; + } + + /** + * This stops updatePushNotificationState from temporarily changing + * m_pushNotificationStateUpdating to default after the exisitng rules are deleted but + * before a new rule is added. + * The value is set to false after the rule enable job is successful. + */ + m_pushNotificationStateUpdating = true; + + /** + * First remove any exisiting room rules of the wrong type. + * Note to prevent race conditions any rule that is going ot be overridden later is not removed. + * If the default push notification state is chosen any exisiting rule needs to be removed. + */ + QJsonObject accountData = connection()->accountDataJson("m.push_rules"); + + // For default and mute check for a room rule and remove if found. + if (state == PushNotificationState::Default || state == PushNotificationState::Mute) { + QJsonArray roomRuleArray = accountData["global"].toObject()["room"].toArray(); + for (const auto &i : roomRuleArray) { + QJsonObject roomRule = i.toObject(); + if (roomRule["rule_id"] == id()) { + Controller::instance().activeConnection()->callApi("global", "room", id()); + } + } + } + + // For default, all and @mentions and keywords check for an override rule and remove if found. + if (state == PushNotificationState::Default || state == PushNotificationState::All || state == PushNotificationState::MentionKeyword) { + QJsonArray overrideRuleArray = accountData["global"].toObject()["override"].toArray(); + for (const auto &i : overrideRuleArray) { + QJsonObject overrideRule = i.toObject(); + if (overrideRule["rule_id"] == id()) { + Controller::instance().activeConnection()->callApi("global", "override", id()); + } + } + } + + if (state == PushNotificationState::Mute) { + /** + * To mute a room an override rule with "don't notify is set". + * + * Setup the rule action to "don't notify" to stop all room notifications + * see https://spec.matrix.org/v1.3/client-server-api/#actions + * + * "actions": [ + * "don't_notify" + * ] + */ + const QVector actions = {"dont_notify"}; + /** + * Setup the push condition to get all events for the current room + * see https://spec.matrix.org/v1.3/client-server-api/#conditions-1 + * + * "conditions": [ + * { + * "key": "type", + * "kind": "event_match", + * "pattern": "room_id" + * } + * ] + */ + PushCondition pushCondition; + pushCondition.kind = "event_match"; + pushCondition.key = "room_id"; + pushCondition.pattern = id(); + const QVector conditions = {pushCondition}; + + // Add new override rule and make sure it's enabled + auto job = Controller::instance().activeConnection()->callApi("global", "override", id(), actions, "", "", conditions, ""); + connect(job, &BaseJob::success, this, [this]() { + auto enableJob = Controller::instance().activeConnection()->callApi("global", "override", id(), true); + connect(enableJob, &BaseJob::success, this, [this]() { + m_pushNotificationStateUpdating = false; + }); + }); + } else if (state == PushNotificationState::MentionKeyword) { + /** + * To only get notifcations for @ mentions and keywords a room rule with "don't_notify" is set. + * + * Note - This works becuase a default override rule which catches all user mentions will + * take precedent and notify. See https://spec.matrix.org/v1.3/client-server-api/#default-override-rules. Any keywords will also have a similar override + * rule. + * + * Setup the rule action to "don't notify" to stop all room event notifications + * see https://spec.matrix.org/v1.3/client-server-api/#actions + * + * "actions": [ + * "don't_notify" + * ] + */ + const QVector actions = {"dont_notify"}; + // No conditions for a room rule + const QVector conditions; + + auto setJob = Controller::instance().activeConnection()->callApi("global", "room", id(), actions, "", "", conditions, ""); + connect(setJob, &BaseJob::success, this, [this]() { + auto enableJob = Controller::instance().activeConnection()->callApi("global", "room", id(), true); + connect(enableJob, &BaseJob::success, this, [this]() { + m_pushNotificationStateUpdating = false; + }); + }); + } else if (state == PushNotificationState::All) { + /** + * To send a notification for all room messages a room rule with "notify" is set. + * + * Setup the rule action to "notify" so all room events give notifications. + * Tweeks is also set to follow default sound settings + * see https://spec.matrix.org/v1.3/client-server-api/#actions + * + * "actions": [ + * "notify", + * { + * "set_tweek": "sound", + * "value": "default", + * } + * ] + */ + QJsonObject tweaks; + tweaks.insert("set_tweak", "sound"); + tweaks.insert("value", "default"); + const QVector actions = {"notify", tweaks}; + // No conditions for a room rule + const QVector conditions; + + // Add new room rule and make sure enabled + auto setJob = Controller::instance().activeConnection()->callApi("global", "room", id(), actions, "", "", conditions, ""); + connect(setJob, &BaseJob::success, this, [this]() { + auto enableJob = Controller::instance().activeConnection()->callApi("global", "room", id(), true); + connect(enableJob, &BaseJob::success, this, [this]() { + m_pushNotificationStateUpdating = false; + }); + }); + } + + m_currentPushNotificationState = state; + Q_EMIT pushNotificationStateChanged(m_currentPushNotificationState); + +} + +void NeoChatRoom::updatePushNotificationState(QString type) +{ + if (type != "m.push_rules" || m_pushNotificationStateUpdating) { + return; + } + + QJsonObject accountData = connection()->accountDataJson("m.push_rules"); + + // First look for a room rule with the room id + QJsonArray roomRuleArray = accountData["global"].toObject()["room"].toArray(); + for (const auto &i : roomRuleArray) { + QJsonObject roomRule = i.toObject(); + if (roomRule["rule_id"] == id()) { + QString notifyAction = roomRule["actions"].toArray()[0].toString(); + if (notifyAction == "notify") { + m_currentPushNotificationState = PushNotificationState::All; + Q_EMIT pushNotificationStateChanged(m_currentPushNotificationState); + return; + } else if (notifyAction == "dont_notify") { + m_currentPushNotificationState = PushNotificationState::MentionKeyword; + Q_EMIT pushNotificationStateChanged(m_currentPushNotificationState); + return; + } + } + } + + // Check for an override rule with the room id + QJsonArray overrideRuleArray = accountData["global"].toObject()["override"].toArray(); + for (const auto &i : overrideRuleArray) { + QJsonObject overrideRule = i.toObject(); + if (overrideRule["rule_id"] == id()) { + QString notifyAction = overrideRule["actions"].toArray()[0].toString(); + if (notifyAction == "dont_notify") { + m_currentPushNotificationState = PushNotificationState::Mute; + Q_EMIT pushNotificationStateChanged(m_currentPushNotificationState); + return; + } + } + } + + // If neither a room or override rule exist for the room then the setting must be default + m_currentPushNotificationState = PushNotificationState::Default; + Q_EMIT pushNotificationStateChanged(m_currentPushNotificationState); +} diff --git a/src/neochatroom.h b/src/neochatroom.h index 6c5db5e43..4f8175990 100644 --- a/src/neochatroom.h +++ b/src/neochatroom.h @@ -23,6 +23,21 @@ using namespace Quotient; +class PushNotificationState : public QObject +{ + Q_OBJECT + +public: + enum State { + Unknown, + Default, + Mute, + MentionKeyword, + All, + }; + Q_ENUM(State); +}; + class NeoChatRoom : public Room { Q_OBJECT @@ -36,6 +51,8 @@ class NeoChatRoom : public Room Q_PROPERTY(bool isInvite READ isInvite NOTIFY isInviteChanged) Q_PROPERTY(QString joinRule READ joinRule CONSTANT) Q_PROPERTY(QString htmlSafeDisplayName READ htmlSafeDisplayName NOTIFY displayNameChanged) + Q_PROPERTY(PushNotificationState::State pushNotificationState MEMBER m_currentPushNotificationState WRITE setPushNotificationState NOTIFY + pushNotificationStateChanged) public: explicit NeoChatRoom(Connection *connection, QString roomId, JoinState joinState = {}); @@ -131,6 +148,8 @@ public: Q_INVOKABLE QString htmlSafeDisplayName() const; Q_INVOKABLE void clearInvitationNotification(); + Q_INVOKABLE void setPushNotificationState(PushNotificationState::State state); + #ifndef QUOTIENT_07 Q_INVOKABLE QString htmlSafeMemberName(const QString &userId) const { @@ -145,6 +164,9 @@ private: bool m_hasFileUploading = false; int m_fileUploadingProgress = 0; + PushNotificationState::State m_currentPushNotificationState = PushNotificationState::State::Unknown; + bool m_pushNotificationStateUpdating = false; + void checkForHighlights(const Quotient::TimelineItem &ti); void onAddNewTimelineEvents(timeline_iter_t from) override; @@ -157,6 +179,7 @@ private: private Q_SLOTS: void countChanged(); + void updatePushNotificationState(QString type); Q_SIGNALS: void cachedInputChanged(); @@ -168,6 +191,7 @@ Q_SIGNALS: void lastActiveTimeChanged(); void isInviteChanged(); void displayNameChanged(); + void pushNotificationStateChanged(PushNotificationState::State state); public Q_SLOTS: void uploadFile(const QUrl &url, const QString &body = QString()); diff --git a/src/notificationsmanager.cpp b/src/notificationsmanager.cpp index a849a49f6..23ae3b38e 100644 --- a/src/notificationsmanager.cpp +++ b/src/notificationsmanager.cpp @@ -16,6 +16,9 @@ #endif #include +#include "csapi/pushrules.h" +#include "jobs/basejob.h" + #include "controller.h" #include "neochatconfig.h" #include "roommanager.h" @@ -30,6 +33,10 @@ NotificationsManager &NotificationsManager::instance() NotificationsManager::NotificationsManager(QObject *parent) : QObject(parent) { + // Can't connect the signal up until the active connection has been established by the controller + connect(&Controller::instance(), &Controller::activeConnectionChanged, this, [this]() { + connect(Controller::instance().activeConnection(), &Connection::accountDataChanged, this, &NotificationsManager::updateGlobalNotificationsEnabled); + }); } void NotificationsManager::postNotification(NeoChatRoom *room, @@ -73,7 +80,6 @@ void NotificationsManager::postNotification(NeoChatRoom *room, notification->setHint(QStringLiteral("x-kde-origin-name"), room->localUser()->id()); - notification->sendEvent(); m_notifications.insert(room->id(), notification); @@ -98,11 +104,11 @@ void NotificationsManager::postInviteNotification(NeoChatRoom *room, const QStri WindowController::instance().showAndRaiseWindow(notification->xdgActivationToken()); }); notification->setActions({i18n("Accept Invitation"), i18n("Reject Invitation")}); - connect(notification, &KNotification::action1Activated, this, [this, room, notification]() { + connect(notification, &KNotification::action1Activated, this, [room, notification]() { room->acceptInvitation(); notification->close(); }); - connect(notification, &KNotification::action2Activated, this, [this, room, notification]() { + connect(notification, &KNotification::action2Activated, this, [room, notification]() { RoomManager::instance().leaveRoom(room); notification->close(); }); @@ -122,3 +128,42 @@ void NotificationsManager::clearInvitationNotification(const QString &roomId) m_invitations[roomId]->close(); } } + +/** + * The master push rule sets all notifications to off when enabled + * see https://spec.matrix.org/v1.3/client-server-api/#default-override-rules + * therefore to enable push rules the master rule needs to be disabled and vice versa + */ +void NotificationsManager::setGlobalNotificationsEnabled(bool enabled) +{ + using namespace Quotient; + + auto job = Controller::instance().activeConnection()->callApi("global", "override", ".m.rule.master"); + connect(job, &BaseJob::success, this, [this, job, enabled]() { + if (job->enabled() == enabled) { + Controller::instance().activeConnection()->callApi("global", "override", ".m.rule.master", !enabled); + m_globalNotificationsEnabled = enabled; + Q_EMIT globalNotificationsEnabledChanged(m_globalNotificationsEnabled); + } + }); +} + +void NotificationsManager::updateGlobalNotificationsEnabled(QString type) +{ + if (type != "m.push_rules") { + return; + } + + QJsonObject accountData = Controller::instance().activeConnection()->accountDataJson("m.push_rules"); + QJsonArray overrideRuleArray = accountData.value("global").toObject().value("override").toArray(); + + for (const auto &i : overrideRuleArray) { + QJsonObject overrideRule = i.toObject(); + if (overrideRule.value("rule_id") == ".m.rule.master") { + bool ruleEnabled = overrideRule.value("enabled").toBool(); + m_globalNotificationsEnabled = !ruleEnabled; + NeoChatConfig::self()->setShowNotifications(m_globalNotificationsEnabled); + Q_EMIT globalNotificationsEnabledChanged(m_globalNotificationsEnabled); + } + } +} diff --git a/src/notificationsmanager.h b/src/notificationsmanager.h index d3c861108..8db75c074 100644 --- a/src/notificationsmanager.h +++ b/src/notificationsmanager.h @@ -10,11 +10,13 @@ #include +#include "neochatconfig.h" #include "neochatroom.h" class NotificationsManager : public QObject { Q_OBJECT + Q_PROPERTY(bool globalNotificationsEnabled MEMBER m_globalNotificationsEnabled WRITE setGlobalNotificationsEnabled NOTIFY globalNotificationsEnabledChanged) public: static NotificationsManager &instance(); @@ -25,9 +27,19 @@ public: void clearInvitationNotification(const QString &roomId); + Q_INVOKABLE void setGlobalNotificationsEnabled(bool enabled); + private: NotificationsManager(QObject *parent = nullptr); QMultiMap m_notifications; QHash> m_invitations; + + bool m_globalNotificationsEnabled; + +private Q_SLOTS: + void updateGlobalNotificationsEnabled(QString type); + +Q_SIGNALS: + void globalNotificationsEnabledChanged(bool newState); };