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