From 1946228d2b90749d17ebdb6d15fb18dff8b5f2a1 Mon Sep 17 00:00:00 2001 From: James Graham Date: Wed, 16 Nov 2022 20:59:35 +0000 Subject: [PATCH] Add Global Notification Settings This add the final list of settings in the main setting window as a new page notifications as there are quite a few now. This completes previous work on push rules giving the ability to set the default global rules. Adding keyword rules is also now supported. This also uses the new mobileform layout. The settings are designed to give some visual feedback as options for whether notifications are on/off, play a sound or are highlighted are chosen. The left icon is designed to mimic the notification dot in the roomlist. The whole mobileform delegate can also be clicked to cycle through the available options. The rationale for whether an option is available is as follows: - Highlight is not available if would lead to every message in a room being highlighted - Keyword notifications cannot be switched off instead the rule is just deleted - Only keyword rules can be deleted, default rules cannot be touched There is also rules plumbed in for features that don't exist in neochat yet, i.e. encrypted chats and rooms, calls. I figured I may as well plumb these in and test them my plan was to hide them before merge, they can then be unhidden when the features are complete. ![image](/uploads/12fa8378847887ea7234e22b1460f952/image.png) --- src/CMakeLists.txt | 6 + src/keywordnotificationrulemodel.cpp | 108 ++++++ src/keywordnotificationrulemodel.h | 35 ++ src/main.cpp | 3 + src/neochatroom.h | 2 +- src/notificationsmanager.cpp | 298 ++++++++++++++++- src/notificationsmanager.h | 80 ++++- src/qml/Settings/GeneralSettingsPage.qml | 16 +- src/qml/Settings/GlobalNotificationsPage.qml | 328 +++++++++++++++++++ src/qml/Settings/NotificationRuleItem.qml | 182 ++++++++++ src/qml/Settings/SettingsPage.qml | 5 + src/res.qrc | 2 + 12 files changed, 1030 insertions(+), 35 deletions(-) create mode 100644 src/keywordnotificationrulemodel.cpp create mode 100644 src/keywordnotificationrulemodel.h create mode 100644 src/qml/Settings/GlobalNotificationsPage.qml create mode 100644 src/qml/Settings/NotificationRuleItem.qml diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 2ff51e32f..29ba91045 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -21,6 +21,7 @@ add_library(neochat STATIC userlistmodel.cpp publicroomlistmodel.cpp userdirectorylistmodel.cpp + keywordnotificationrulemodel.cpp utils.cpp notificationsmanager.cpp sortfilterroomlistmodel.cpp @@ -138,6 +139,7 @@ if(ANDROID) "rating-unrated" "search" "mail-replied-symbolic" + "edit-clear" "edit-copy" "gtk-quit" "compass" @@ -148,6 +150,10 @@ if(ANDROID) "preferences-system-users" "preferences-desktop-theme-global" "notifications" + "notifications-disabled" + "audio-volume-high" + "audio-volume-muted" + "draw-highlight" "zoom-in" "zoom-out" "image-rotate-left-symbolic" diff --git a/src/keywordnotificationrulemodel.cpp b/src/keywordnotificationrulemodel.cpp new file mode 100644 index 000000000..a15f4aa02 --- /dev/null +++ b/src/keywordnotificationrulemodel.cpp @@ -0,0 +1,108 @@ +// SPDX-FileCopyrightText: 2022 James Graham +// SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL + +#include "keywordnotificationrulemodel.h" +#include "controller.h" +#include "notificationsmanager.h" + +#include +#include +#include +#include +#include +#include + +KeywordNotificationRuleModel::KeywordNotificationRuleModel(QObject *parent) + : QAbstractListModel(parent) +{ + controllerConnectionChanged(); + connect(&Controller::instance(), &Controller::activeConnectionChanged, this, &KeywordNotificationRuleModel::controllerConnectionChanged); +} + +void KeywordNotificationRuleModel::controllerConnectionChanged() +{ + connect(Controller::instance().activeConnection(), &Quotient::Connection::accountDataChanged, this, &KeywordNotificationRuleModel::updateNotificationRules); + updateNotificationRules("m.push_rules"); +} + +void KeywordNotificationRuleModel::updateNotificationRules(const QString &type) +{ + if (type != "m.push_rules") { + return; + } + + const QJsonObject ruleDataJson = Controller::instance().activeConnection()->accountDataJson("m.push_rules"); + const Quotient::PushRuleset ruleData = Quotient::fromJson(ruleDataJson["global"].toObject()); + const QVector contentRules = ruleData.content; + + beginResetModel(); + m_notificationRules.clear(); + for (const auto &i : contentRules) { + if (!m_notificationRules.contains(i.ruleId) && i.ruleId[0] != '.') { + m_notificationRules.append(i.ruleId); + } + } + endResetModel(); +} + +QVariant KeywordNotificationRuleModel::data(const QModelIndex &index, int role) const +{ + if (!index.isValid()) { + return {}; + } + + if (index.row() >= m_notificationRules.count()) { + qDebug() << "KeywordNotificationRuleModel, something's wrong: index.row() >= m_notificationRules.count()"; + return {}; + } + + if (role == NameRole) { + return m_notificationRules.at(index.row()); + } + + return {}; +} + +int KeywordNotificationRuleModel::rowCount(const QModelIndex &parent) const +{ + Q_UNUSED(parent) + + return m_notificationRules.count(); +} + +void KeywordNotificationRuleModel::addKeyword(const QString &keyword) +{ + if (m_notificationRules.count() == 0) { + NotificationsManager::instance().initializeKeywordNotificationAction(); + } + + const QVector actions = NotificationsManager::instance().getKeywordNotificationActions(); + + auto job = Controller::instance() + .activeConnection() + ->callApi("global", "content", keyword, actions, "", "", QVector(), keyword); + connect(job, &Quotient::BaseJob::success, this, [this, keyword]() { + beginInsertRows(QModelIndex(), m_notificationRules.count(), m_notificationRules.count()); + m_notificationRules.append(keyword); + endInsertRows(); + }); +} + +void KeywordNotificationRuleModel::removeKeywordAtIndex(int index) +{ + auto job = Controller::instance().activeConnection()->callApi("global", "content", m_notificationRules[index]); + connect(job, &Quotient::BaseJob::success, this, [this, index]() { + beginRemoveRows(QModelIndex(), index, index); + m_notificationRules.removeAt(index); + endRemoveRows(); + + if (m_notificationRules.count() == 0) { + NotificationsManager::instance().deactivateKeywordNotificationAction(); + } + }); +} + +QHash KeywordNotificationRuleModel::roleNames() const +{ + return {{NameRole, QByteArrayLiteral("name")}}; +} diff --git a/src/keywordnotificationrulemodel.h b/src/keywordnotificationrulemodel.h new file mode 100644 index 000000000..831cdf714 --- /dev/null +++ b/src/keywordnotificationrulemodel.h @@ -0,0 +1,35 @@ +// SPDX-FileCopyrightText: 2022 James Graham +// SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL + +#pragma once + +#include + +#include + +class KeywordNotificationRuleModel : public QAbstractListModel +{ + Q_OBJECT + +public: + enum EventRoles { + NameRole = Qt::DisplayRole, + }; + + KeywordNotificationRuleModel(QObject *parent = nullptr); + + [[nodiscard]] QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override; + [[nodiscard]] int rowCount(const QModelIndex &parent = QModelIndex()) const override; + + [[nodiscard]] QHash roleNames() const override; + + Q_INVOKABLE void addKeyword(const QString &keyword); + Q_INVOKABLE void removeKeywordAtIndex(int index); + +private Q_SLOTS: + void controllerConnectionChanged(); + void updateNotificationRules(const QString &type); + +private: + QList m_notificationRules; +}; diff --git a/src/main.cpp b/src/main.cpp index 6e28395de..f7108aa4d 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -49,6 +49,7 @@ #include "filetypesingleton.h" #include "joinrulesevent.h" #include "linkpreviewer.h" +#include "keywordnotificationrulemodel.h" #include "login.h" #include "matriximageprovider.h" #include "messageeventmodel.h" @@ -216,8 +217,10 @@ int main(int argc, char *argv[]) #ifdef QUOTIENT_07 qmlRegisterType("org.kde.neochat", 1, 0, "PollHandler"); #endif + qmlRegisterType("org.kde.neochat", 1, 0, "KeywordNotificationRuleModel"); qmlRegisterUncreatableType("org.kde.neochat", 1, 0, "RoomMessageEvent", "ENUM"); qmlRegisterUncreatableType("org.kde.neochat", 1, 0, "PushNotificationState", "ENUM"); + qmlRegisterUncreatableType("org.kde.neochat", 1, 0, "PushNotificationAction", "ENUM"); qmlRegisterUncreatableType("org.kde.neochat", 1, 0, "NeoChatRoomType", "ENUM"); qmlRegisterUncreatableType("org.kde.neochat", 1, 0, "UserType", "ENUM"); qmlRegisterUncreatableType("org.kde.neochat", 1, 0, "NeoChatUser", {}); diff --git a/src/neochatroom.h b/src/neochatroom.h index 5468f029d..05100bfc7 100644 --- a/src/neochatroom.h +++ b/src/neochatroom.h @@ -211,7 +211,7 @@ private: bool m_hasFileUploading = false; int m_fileUploadingProgress = 0; - PushNotificationState::State m_currentPushNotificationState = PushNotificationState::State::Unknown; + PushNotificationState::State m_currentPushNotificationState = PushNotificationState::Unknown; bool m_pushNotificationStateUpdating = false; void checkForHighlights(const Quotient::TimelineItem &ti); diff --git a/src/notificationsmanager.cpp b/src/notificationsmanager.cpp index d14df3473..ee1a544ac 100644 --- a/src/notificationsmanager.cpp +++ b/src/notificationsmanager.cpp @@ -6,6 +6,7 @@ #include #include +#include #include #include @@ -36,7 +37,9 @@ NotificationsManager::NotificationsManager(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); + connect(Controller::instance().activeConnection(), &Connection::accountDataChanged, this, &NotificationsManager::updateNotificationRules); + // Ensure that the push rule states are retrieved after the connection is changed + updateNotificationRules("m.push_rules"); }); } @@ -137,34 +140,295 @@ void NotificationsManager::clearInvitationNotification(const QString &roomId) */ 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); - } - }); + setNotificationRuleEnabled("override", ".m.rule.master", !enabled); } -void NotificationsManager::updateGlobalNotificationsEnabled(QString type) +void NotificationsManager::setOneToOneNotificationAction(PushNotificationAction::Action action) +{ + setNotificationRuleActions("underride", ".m.rule.room_one_to_one", action); +} + +void NotificationsManager::setEncryptedOneToOneNotificationAction(PushNotificationAction::Action action) +{ + setNotificationRuleActions("underride", ".m.rule.encrypted_room_one_to_one", action); +} + +void NotificationsManager::setGroupChatNotificationAction(PushNotificationAction::Action action) +{ + setNotificationRuleActions("underride", ".m.rule.message", action); +} + +void NotificationsManager::setEncryptedGroupChatNotificationAction(PushNotificationAction::Action action) +{ + setNotificationRuleActions("underride", ".m.rule.encrypted", action); +} + +/* + * .m.rule.contains_display_name is an override rule so it needs to be disabled when off + * so that other rules can match the message if they apply. + */ +void NotificationsManager::setDisplayNameNotificationAction(PushNotificationAction::Action action) +{ + if (action == PushNotificationAction::Off) { + setNotificationRuleEnabled("override", ".m.rule.contains_display_name", false); + } else { + setNotificationRuleActions("override", ".m.rule.contains_display_name", action); + setNotificationRuleEnabled("override", ".m.rule.contains_display_name", true); + } +} + +/* + * .m.rule.roomnotif is an override rule so it needs to be disabled when off + * so that other rules can match the message if they apply. + */ +void NotificationsManager::setRoomNotificationAction(PushNotificationAction::Action action) +{ + if (action == PushNotificationAction::Off) { + setNotificationRuleEnabled("override", ".m.rule.roomnotif", false); + } else { + setNotificationRuleActions("override", ".m.rule.roomnotif", action); + setNotificationRuleEnabled("override", ".m.rule.roomnotif", true); + } +} + +void NotificationsManager::initializeKeywordNotificationAction() +{ + m_keywordNotificationAction = PushNotificationAction::Highlight; + Q_EMIT keywordNotificationActionChanged(m_keywordNotificationAction); +} + +void NotificationsManager::deactivateKeywordNotificationAction() +{ + m_keywordNotificationAction = PushNotificationAction::Off; + Q_EMIT keywordNotificationActionChanged(m_keywordNotificationAction); +} + +QVector NotificationsManager::getKeywordNotificationActions() +{ + return toActions(m_keywordNotificationAction); +} + +void NotificationsManager::setKeywordNotificationAction(PushNotificationAction::Action action) +{ + // Unlike the other rules this needs to be set here for the case where there are no keyords. + m_keywordNotificationAction = action; + Q_EMIT keywordNotificationActionChanged(m_keywordNotificationAction); + + const QJsonObject accountData = Controller::instance().activeConnection()->accountDataJson("m.push_rules"); + const QJsonArray contentRuleArray = accountData["global"].toObject()["content"].toArray(); + for (const auto &i : contentRuleArray) { + const QJsonObject contentRule = i.toObject(); + if (contentRule["rule_id"].toString()[0] != '.') { + setNotificationRuleActions("content", contentRule["rule_id"].toString(), action); + } + } +} + +/* + * .m.rule.invite_for_me is an override rule so it needs to be disabled when off + * so that other rules can match the message if they apply. + */ +void NotificationsManager::setInviteNotificationAction(PushNotificationAction::Action action) +{ + if (action == PushNotificationAction::Off) { + setNotificationRuleEnabled("override", ".m.rule.invite_for_me", false); + } else { + setNotificationRuleActions("override", ".m.rule.invite_for_me", action); + setNotificationRuleEnabled("override", ".m.rule.invite_for_me", true); + } +} + +void NotificationsManager::setCallInviteNotificationAction(PushNotificationAction::Action action) +{ + setNotificationRuleActions("underride", ".m.rule.call", action); +} + +/* + * .m.rule.tombstone is an override rule so it needs to be disabled when off + * so that other rules can match the message if they apply. + */ +void NotificationsManager::setTombstoneNotificationAction(PushNotificationAction::Action action) +{ + if (action == PushNotificationAction::Off) { + setNotificationRuleEnabled("override", ".m.rule.tombstone", false); + } else { + setNotificationRuleActions("override", ".m.rule.tombstone", action); + setNotificationRuleEnabled("override", ".m.rule.tombstone", true); + } +} + +void NotificationsManager::updateNotificationRules(const 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(); + const QJsonObject accountData = Controller::instance().activeConnection()->accountDataJson("m.push_rules"); + // Update override rules + const QJsonArray overrideRuleArray = accountData["global"].toObject()["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(); + const QJsonObject overrideRule = i.toObject(); + if (overrideRule["rule_id"] == ".m.rule.master") { + bool ruleEnabled = overrideRule["enabled"].toBool(); m_globalNotificationsEnabled = !ruleEnabled; NeoChatConfig::self()->setShowNotifications(m_globalNotificationsEnabled); Q_EMIT globalNotificationsEnabledChanged(m_globalNotificationsEnabled); } + + const PushNotificationAction::Action action = toAction(overrideRule); + + if (overrideRule["rule_id"] == ".m.rule.contains_display_name") { + m_displayNameNotificationAction = action; + Q_EMIT displayNameNotificationActionChanged(m_displayNameNotificationAction); + } else if (overrideRule["rule_id"] == ".m.rule.roomnotif") { + m_roomNotificationAction = action; + Q_EMIT roomNotificationActionChanged(m_roomNotificationAction); + } else if (overrideRule["rule_id"] == ".m.rule.invite_for_me") { + m_inviteNotificationAction = action; + Q_EMIT inviteNotificationActionChanged(m_inviteNotificationAction); + } else if (overrideRule["rule_id"] == ".m.rule.tombstone") { + m_tombstoneNotificationAction = action; + Q_EMIT tombstoneNotificationActionChanged(m_tombstoneNotificationAction); + } + } + + // Update content rules + const QJsonArray contentRuleArray = accountData["global"].toObject()["content"].toArray(); + PushNotificationAction::Action keywordAction = PushNotificationAction::Unknown; + for (const auto &i : contentRuleArray) { + const QJsonObject contentRule = i.toObject(); + const PushNotificationAction::Action action = toAction(contentRule); + bool actionMismatch = false; + + if (contentRule["rule_id"].toString()[0] != '.' && !actionMismatch) { + if (keywordAction == PushNotificationAction::Unknown) { + keywordAction = action; + m_keywordNotificationAction = action; + Q_EMIT keywordNotificationActionChanged(m_keywordNotificationAction); + } else if (action != keywordAction) { + actionMismatch = true; + m_keywordNotificationAction = PushNotificationAction::On; + Q_EMIT keywordNotificationActionChanged(m_keywordNotificationAction); + } + } + } + // If there are no keywords set the state to off, this is the only time it'll be in the off state + if (keywordAction == PushNotificationAction::Unknown) { + m_keywordNotificationAction = PushNotificationAction::Off; + Q_EMIT keywordNotificationActionChanged(m_keywordNotificationAction); + } + + // Update underride rules + const QJsonArray underrideRuleArray = accountData["global"].toObject()["underride"].toArray(); + for (const auto &i : underrideRuleArray) { + const QJsonObject underrideRule = i.toObject(); + const PushNotificationAction::Action action = toAction(underrideRule); + + if (underrideRule["rule_id"] == ".m.rule.room_one_to_one") { + m_oneToOneNotificationAction = action; + Q_EMIT oneToOneNotificationActionChanged(m_oneToOneNotificationAction); + } else if (underrideRule["rule_id"] == ".m.rule.encrypted_room_one_to_one") { + m_encryptedOneToOneNotificationAction = action; + Q_EMIT encryptedOneToOneNotificationActionChanged(m_encryptedOneToOneNotificationAction); + } else if (underrideRule["rule_id"] == ".m.rule.message") { + m_groupChatNotificationAction = action; + Q_EMIT groupChatNotificationActionChanged(m_groupChatNotificationAction); + } else if (underrideRule["rule_id"] == ".m.rule.encrypted") { + m_encryptedGroupChatNotificationAction = action; + Q_EMIT encryptedGroupChatNotificationActionChanged(m_encryptedGroupChatNotificationAction); + } else if (underrideRule["rule_id"] == ".m.rule.call") { + m_callInviteNotificationAction = action; + Q_EMIT callInviteNotificationActionChanged(m_callInviteNotificationAction); + } } } + +void NotificationsManager::setNotificationRuleEnabled(const QString &kind, const QString &ruleId, bool enabled) +{ + auto job = Controller::instance().activeConnection()->callApi("global", kind, ruleId); + connect(job, &BaseJob::success, this, [job, kind, ruleId, enabled]() { + if (job->enabled() != enabled) { + Controller::instance().activeConnection()->callApi("global", kind, ruleId, enabled); + } + }); +} + +void NotificationsManager::setNotificationRuleActions(const QString &kind, const QString &ruleId, PushNotificationAction::Action action) +{ + QVector actions; + if (ruleId == ".m.rule.call") { + actions = toActions(action, "ring"); + } else { + actions = toActions(action); + } + + Controller::instance().activeConnection()->callApi("global", kind, ruleId, actions); +} + +PushNotificationAction::Action NotificationsManager::toAction(const QJsonObject &rule) +{ + const QJsonArray actions = rule["actions"].toArray(); + bool isNoisy = false; + bool highlightEnabled = false; + const bool enabled = rule["enabled"].toBool(); + for (const auto &i : actions) { + QJsonObject action = i.toObject(); + if (action["set_tweak"].toString() == "sound") { + isNoisy = true; + } else if (action["set_tweak"].toString() == "highlight") { + if (action["value"].toString() != "false") { + highlightEnabled = true; + } + } + } + + if (!enabled) { + return PushNotificationAction::Off; + } + + if (actions[0] == "notify") { + if (isNoisy && highlightEnabled) { + return PushNotificationAction::NoisyHighlight; + } else if (isNoisy) { + return PushNotificationAction::Noisy; + } else if (highlightEnabled) { + return PushNotificationAction::Highlight; + } else { + return PushNotificationAction::On; + } + } else { + return PushNotificationAction::Off; + } +} + +QVector NotificationsManager::toActions(PushNotificationAction::Action action, const QString &sound) +{ + // 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 (action == PushNotificationAction::Unknown) { + Q_ASSERT(false); + return QVector(); + } + + QVector actions; + + if (action != PushNotificationAction::Off) { + actions.append("notify"); + } else { + actions.append("dont_notify"); + } + if (action == PushNotificationAction::Noisy || action == PushNotificationAction::NoisyHighlight) { + QJsonObject soundTweak; + soundTweak.insert("set_tweak", "sound"); + soundTweak.insert("value", sound); + actions.append(soundTweak); + } + if (action == PushNotificationAction::Highlight || action == PushNotificationAction::NoisyHighlight) { + QJsonObject highlightTweak; + highlightTweak.insert("set_tweak", "highlight"); + actions.append(highlightTweak); + } + + return actions; +} diff --git a/src/notificationsmanager.h b/src/notificationsmanager.h index e1f3500de..d80b43112 100644 --- a/src/notificationsmanager.h +++ b/src/notificationsmanager.h @@ -8,14 +8,51 @@ #include #include #include +#include class KNotification; class NeoChatRoom; +class PushNotificationAction : public QObject +{ + Q_OBJECT + +public: + enum Action { + Unknown = 0, + Off, + On, + Noisy, + Highlight, + NoisyHighlight, + }; + Q_ENUM(Action); +}; + class NotificationsManager : public QObject { Q_OBJECT Q_PROPERTY(bool globalNotificationsEnabled MEMBER m_globalNotificationsEnabled WRITE setGlobalNotificationsEnabled NOTIFY globalNotificationsEnabledChanged) + Q_PROPERTY(PushNotificationAction::Action oneToOneNotificationAction MEMBER m_oneToOneNotificationAction WRITE setOneToOneNotificationAction NOTIFY + oneToOneNotificationActionChanged) + Q_PROPERTY(PushNotificationAction::Action encryptedOneToOneNotificationAction MEMBER m_encryptedOneToOneNotificationAction WRITE + setEncryptedOneToOneNotificationAction NOTIFY encryptedOneToOneNotificationActionChanged) + Q_PROPERTY(PushNotificationAction::Action groupChatNotificationAction MEMBER m_groupChatNotificationAction WRITE setGroupChatNotificationAction NOTIFY + groupChatNotificationActionChanged) + Q_PROPERTY(PushNotificationAction::Action encryptedGroupChatNotificationAction MEMBER m_encryptedGroupChatNotificationAction WRITE + setEncryptedGroupChatNotificationAction NOTIFY encryptedGroupChatNotificationActionChanged) + Q_PROPERTY(PushNotificationAction::Action displayNameNotificationAction MEMBER m_displayNameNotificationAction WRITE setDisplayNameNotificationAction NOTIFY + displayNameNotificationActionChanged) + Q_PROPERTY(PushNotificationAction::Action roomNotificationAction MEMBER m_roomNotificationAction WRITE setRoomNotificationAction NOTIFY + roomNotificationActionChanged) + Q_PROPERTY(PushNotificationAction::Action keywordNotificationAction MEMBER m_keywordNotificationAction WRITE setKeywordNotificationAction NOTIFY + keywordNotificationActionChanged) + Q_PROPERTY(PushNotificationAction::Action inviteNotificationAction MEMBER m_inviteNotificationAction WRITE setInviteNotificationAction NOTIFY + inviteNotificationActionChanged) + Q_PROPERTY(PushNotificationAction::Action callInviteNotificationAction MEMBER m_callInviteNotificationAction WRITE setCallInviteNotificationAction NOTIFY + callInviteNotificationActionChanged) + Q_PROPERTY(PushNotificationAction::Action tombstoneNotificationAction MEMBER m_tombstoneNotificationAction WRITE setTombstoneNotificationAction NOTIFY + tombstoneNotificationActionChanged) public: static NotificationsManager &instance(); @@ -26,7 +63,9 @@ public: void clearInvitationNotification(const QString &roomId); - Q_INVOKABLE void setGlobalNotificationsEnabled(bool enabled); + void initializeKeywordNotificationAction(); + void deactivateKeywordNotificationAction(); + QVector getKeywordNotificationActions(); private: NotificationsManager(QObject *parent = nullptr); @@ -35,10 +74,47 @@ private: QHash> m_invitations; bool m_globalNotificationsEnabled; + PushNotificationAction::Action m_oneToOneNotificationAction = PushNotificationAction::Unknown; + PushNotificationAction::Action m_encryptedOneToOneNotificationAction = PushNotificationAction::Unknown; + PushNotificationAction::Action m_groupChatNotificationAction = PushNotificationAction::Unknown; + PushNotificationAction::Action m_encryptedGroupChatNotificationAction = PushNotificationAction::Unknown; + PushNotificationAction::Action m_displayNameNotificationAction = PushNotificationAction::Unknown; + PushNotificationAction::Action m_roomNotificationAction = PushNotificationAction::Unknown; + PushNotificationAction::Action m_keywordNotificationAction = PushNotificationAction::Unknown; + PushNotificationAction::Action m_inviteNotificationAction = PushNotificationAction::Unknown; + PushNotificationAction::Action m_callInviteNotificationAction = PushNotificationAction::Unknown; + PushNotificationAction::Action m_tombstoneNotificationAction = PushNotificationAction::Unknown; + + void setGlobalNotificationsEnabled(bool enabled); + void setOneToOneNotificationAction(PushNotificationAction::Action action); + void setEncryptedOneToOneNotificationAction(PushNotificationAction::Action action); + void setGroupChatNotificationAction(PushNotificationAction::Action action); + void setEncryptedGroupChatNotificationAction(PushNotificationAction::Action action); + void setDisplayNameNotificationAction(PushNotificationAction::Action action); + void setRoomNotificationAction(PushNotificationAction::Action action); + void setKeywordNotificationAction(PushNotificationAction::Action action); + void setInviteNotificationAction(PushNotificationAction::Action action); + void setCallInviteNotificationAction(PushNotificationAction::Action action); + void setTombstoneNotificationAction(PushNotificationAction::Action action); + + void setNotificationRuleEnabled(const QString &kind, const QString &ruleId, bool enabled); + void setNotificationRuleActions(const QString &kind, const QString &ruleId, PushNotificationAction::Action action); + PushNotificationAction::Action toAction(const QJsonObject &rule); + QVector toActions(PushNotificationAction::Action action, const QString &sound = "default"); private Q_SLOTS: - void updateGlobalNotificationsEnabled(QString type); + void updateNotificationRules(const QString &type); Q_SIGNALS: void globalNotificationsEnabledChanged(bool newState); + void oneToOneNotificationActionChanged(PushNotificationAction::Action action); + void encryptedOneToOneNotificationActionChanged(PushNotificationAction::Action action); + void groupChatNotificationActionChanged(PushNotificationAction::Action action); + void encryptedGroupChatNotificationActionChanged(PushNotificationAction::Action action); + void displayNameNotificationActionChanged(PushNotificationAction::Action action); + void roomNotificationActionChanged(PushNotificationAction::Action action); + void keywordNotificationActionChanged(PushNotificationAction::Action action); + void inviteNotificationActionChanged(PushNotificationAction::Action action); + void callInviteNotificationActionChanged(PushNotificationAction::Action action); + void tombstoneNotificationActionChanged(PushNotificationAction::Action action); }; diff --git a/src/qml/Settings/GeneralSettingsPage.qml b/src/qml/Settings/GeneralSettingsPage.qml index 10b6bca4a..a0505e255 100644 --- a/src/qml/Settings/GeneralSettingsPage.qml +++ b/src/qml/Settings/GeneralSettingsPage.qml @@ -72,22 +72,8 @@ Kirigami.ScrollablePage { contentItem: ColumnLayout { spacing: 0 MobileForm.FormCardHeader { - title: i18n("Notifications and events") + title: i18n("Timeline Events") } - MobileForm.FormCheckDelegate { - id: showNotificationsDelegate - // TODO: When there are enough notification and timeline event - // settings, make 2 separate groups with FormData labels. - text: i18n("Show notifications") - checked: Config.showNotifications - enabled: !Config.isShowNotificationsImmutable - onToggled: { - Config.showNotifications = checked - Config.save() - } - } - - MobileForm.FormDelegateSeparator { above: showNotificationsDelegate; below: showLeaveJoinEventDelegate } MobileForm.FormCheckDelegate { id: showLeaveJoinEventDelegate diff --git a/src/qml/Settings/GlobalNotificationsPage.qml b/src/qml/Settings/GlobalNotificationsPage.qml new file mode 100644 index 000000000..4f67fe1bd --- /dev/null +++ b/src/qml/Settings/GlobalNotificationsPage.qml @@ -0,0 +1,328 @@ +// SPDX-FileCopyrightText: 2022 James Graham +// SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL + +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.kirigamiaddons.labs.mobileform 0.1 as MobileForm + +import org.kde.neochat 1.0 + +Kirigami.ScrollablePage { + title: i18nc("@title:window", "Notifications") + ColumnLayout { + id: notificationLayout + anchors.fill: parent + + MobileForm.FormCard { + Layout.fillWidth: true + + contentItem: MobileForm.FormSwitchDelegate { + text: i18n("Enable notifications for this account") + checked: Config.showNotifications + enabled: !Config.isShowNotificationsImmutable + onToggled: { + Config.showNotifications = checked + Config.save() + NotificationsManager.globalNotificationsEnabled = checked + } + } + } + + MobileForm.FormCard { + Layout.fillWidth: true + + contentItem: ColumnLayout { + spacing: 0 + + MobileForm.FormCardHeader { + title: i18n("Room Notifications") + } + NotificationRuleItem { + text: i18n("Messages in one-to-one chats") + + notificationsOn: notificationLayout.isNotificationRuleOn(NotificationsManager.oneToOneNotificationAction) + noisyOn: notificationLayout.isNotificationRuleNoisy(NotificationsManager.oneToOneNotificationAction) + enabled: NotificationsManager.oneToOneNotificationAction !== PushNotificationAction.Unknown + + notificationAction: NotificationsManager.oneToOneNotificationAction + onNotificationActionChanged: { + if (notificationAction && NotificationsManager.oneToOneNotificationAction != notificationAction) { + NotificationsManager.oneToOneNotificationAction = notificationAction + } + } + } + NotificationRuleItem { + text: i18n("Encrypted messages in one-to-one chats") + + visible: Controller.encryptionSupported + + notificationsOn: notificationLayout.isNotificationRuleOn(NotificationsManager.encryptedOneToOneNotificationAction) + noisyOn: notificationLayout.isNotificationRuleNoisy(NotificationsManager.encryptedOneToOneNotificationAction) + enabled: NotificationsManager.encryptedOneToOneNotificationAction !== PushNotificationAction.Unknown + + notificationAction: NotificationsManager.encryptedOneToOneNotificationAction + onNotificationActionChanged: { + if (notificationAction && NotificationsManager.encryptedOneToOneNotificationAction != notificationAction) { + NotificationsManager.encryptedOneToOneNotificationAction = notificationAction + } + } + } + NotificationRuleItem { + text: i18n("Messages in group chats") + + notificationsOn: notificationLayout.isNotificationRuleOn(NotificationsManager.groupChatNotificationAction) + noisyOn: notificationLayout.isNotificationRuleNoisy(NotificationsManager.groupChatNotificationAction) + enabled: NotificationsManager.groupChatNotificationAction !== PushNotificationAction.Unknown + + notificationAction: NotificationsManager.groupChatNotificationAction + onNotificationActionChanged: { + if (notificationAction && NotificationsManager.groupChatNotificationAction != notificationAction) { + NotificationsManager.groupChatNotificationAction = notificationAction + } + } + } + NotificationRuleItem { + text: i18n("Messages in encrypted group chats") + + visible: Controller.encryptionSupported + + notificationsOn: notificationLayout.isNotificationRuleOn(NotificationsManager.encryptedGroupChatNotificationAction) + noisyOn: notificationLayout.isNotificationRuleNoisy(NotificationsManager.encryptedGroupChatNotificationAction) + enabled: NotificationsManager.encryptedGroupChatNotificationAction !== PushNotificationAction.Unknown + + notificationAction: NotificationsManager.encryptedGroupChatNotificationAction + onNotificationActionChanged: { + if (notificationAction && NotificationsManager.encryptedGroupChatNotificationAction != notificationAction) { + NotificationsManager.encryptedGroupChatNotificationAction = notificationAction + } + } + } + NotificationRuleItem { + text: i18n("Room upgrade messages") + + notificationsOn: notificationLayout.isNotificationRuleOn(NotificationsManager.tombstoneNotificationAction) + noisyOn: notificationLayout.isNotificationRuleNoisy(NotificationsManager.tombstoneNotificationAction) + highlightable: true + highlightOn: notificationLayout.isNotificationRuleHighlight(NotificationsManager.tombstoneNotificationAction) + enabled: NotificationsManager.tombstoneNotificationAction !== PushNotificationAction.Unknown + + notificationAction: NotificationsManager.tombstoneNotificationAction + onNotificationActionChanged: { + if (notificationAction && NotificationsManager.tombstoneNotificationAction != notificationAction) { + NotificationsManager.tombstoneNotificationAction = notificationAction + } + } + } + } + } + + MobileForm.FormCard { + Layout.fillWidth: true + + contentItem: ColumnLayout { + spacing: 0 + + MobileForm.FormCardHeader { + title: i18n("@Mentions") + } + NotificationRuleItem { + text: i18n("Messages containing my display name") + + notificationsOn: notificationLayout.isNotificationRuleOn(NotificationsManager.displayNameNotificationAction) + noisyOn: notificationLayout.isNotificationRuleNoisy(NotificationsManager.displayNameNotificationAction) + highlightable: true + highlightOn: notificationLayout.isNotificationRuleHighlight(NotificationsManager.displayNameNotificationAction) + enabled: NotificationsManager.displayNameNotificationAction !== PushNotificationAction.Unknown + + notificationAction: NotificationsManager.displayNameNotificationAction + onNotificationActionChanged: { + if (notificationAction && NotificationsManager.displayNameNotificationAction != notificationAction) { + NotificationsManager.displayNameNotificationAction = notificationAction + } + } + } + NotificationRuleItem { + text: i18n("Whole room (@room) notifications") + + notificationsOn: notificationLayout.isNotificationRuleOn(NotificationsManager.roomNotificationAction) + noisyOn: notificationLayout.isNotificationRuleNoisy(NotificationsManager.roomNotificationAction) + highlightable: true + highlightOn: notificationLayout.isNotificationRuleHighlight(NotificationsManager.roomNotificationAction) + enabled: NotificationsManager.roomNotificationAction !== PushNotificationAction.Unknown + + notificationAction: NotificationsManager.roomNotificationAction + onNotificationActionChanged: { + if (notificationAction && NotificationsManager.roomNotificationAction != notificationAction) { + NotificationsManager.roomNotificationAction = notificationAction + } + } + } + } + } + + MobileForm.FormCard { + Layout.fillWidth: true + + contentItem: ColumnLayout { + spacing: 0 + + MobileForm.FormCardHeader { + title: i18n("Keywords") + } + NotificationRuleItem { + id: keywordNotificationAction + text: i18n("Messages containing my keywords") + + notificationsOn: true + notificationsOnModifiable: false + noisyOn: notificationLayout.isNotificationRuleNoisy(NotificationsManager.keywordNotificationAction) + highlightable: true + highlightOn: notificationLayout.isNotificationRuleHighlight(NotificationsManager.keywordNotificationAction) + enabled: NotificationsManager.keywordNotificationAction !== PushNotificationAction.Unknown && + NotificationsManager.keywordNotificationAction !== PushNotificationAction.Off + + notificationAction: NotificationsManager.keywordNotificationAction + onNotificationActionChanged: { + if (notificationAction && NotificationsManager.keywordNotificationAction != notificationAction) { + NotificationsManager.keywordNotificationAction = notificationAction + } + } + } + MobileForm.FormDelegateSeparator {} + Repeater { + model: KeywordNotificationRuleModel { + id: keywordNotificationRuleModel + } + + delegate: NotificationRuleItem { + text: name + notificationAction: keywordNotificationAction.notificationAction + notificationsOn: keywordNotificationAction.notificationsOn + notificationsOnModifiable: false + noisyOn: keywordNotificationAction.noisyOn + noisyModifiable: false + highlightOn: keywordNotificationAction.highlightOn + deletable: true + + onDeleteItemChanged: { + if (deleteItem && deletable) { + keywordNotificationRuleModel.removeKeywordAtIndex(index) + } + } + } + } + MobileForm.AbstractFormDelegate { + Layout.fillWidth: true + + contentItem : RowLayout { + Kirigami.ActionTextField { + id: keywordAddField + + Layout.fillWidth: true + + placeholderText: i18n("Keyword…") + + rightActions: Kirigami.Action { + icon.name: "edit-clear" + visible: keywordAddField.text.length > 0 + onTriggered: { + keywordAddField.text = "" + } + } + + onAccepted: { + keywordNotificationRuleModel.addKeyword(keywordAddField.text, PushNotificationAction.On) + keywordAddField.text = "" + } + } + QQC2.Button { + id: addButton + + text: i18n("Add keyword") + Accessible.name: text + icon.name: "list-add" + display: QQC2.AbstractButton.IconOnly + + onClicked: { + keywordNotificationRuleModel.addKeyword(keywordAddField.text, PushNotificationAction.On) + keywordAddField.text = "" + } + + QQC2.ToolTip { + text: addButton.text + delay: Kirigami.Units.toolTipDelay + } + } + } + } + } + } + + MobileForm.FormCard { + Layout.fillWidth: true + + contentItem: ColumnLayout { + spacing: 0 + + MobileForm.FormCardHeader { + title: i18n("Invites") + } + NotificationRuleItem { + text: i18n("Invites to a room") + + notificationsOn: notificationLayout.isNotificationRuleOn(NotificationsManager.inviteNotificationAction) + noisyOn: notificationLayout.isNotificationRuleNoisy(NotificationsManager.inviteNotificationAction) + highlightable: true + highlightOn: notificationLayout.isNotificationRuleHighlight(NotificationsManager.inviteNotificationAction) + enabled: NotificationsManager.inviteNotificationAction !== PushNotificationAction.Unknown + + notificationAction: NotificationsManager.inviteNotificationAction + onNotificationActionChanged: { + if (notificationAction && NotificationsManager.inviteNotificationAction != notificationAction) { + NotificationsManager.inviteNotificationAction = notificationAction + } + } + } + NotificationRuleItem { + text: i18n("Call invitation") + + // TODO enable this option when calls are supported + visible: false + + notificationsOn: notificationLayout.isNotificationRuleOn(NotificationsManager.callInviteNotificationAction) + noisyOn: notificationLayout.isNotificationRuleNoisy(NotificationsManager.callInviteNotificationAction) + highlightable: true + highlightOn: notificationLayout.isNotificationRuleHighlight(NotificationsManager.callInviteNotificationAction) + enabled: NotificationsManager.callInviteNotificationAction !== PushNotificationAction.Unknown + + notificationAction: NotificationsManager.callInviteNotificationAction + onNotificationActionChanged: { + if (notificationAction && NotificationsManager.callInviteNotificationAction != notificationAction) { + NotificationsManager.callInviteNotificationAction = notificationAction + } + } + } + } + } + + function isNotificationRuleOn(action) { + return action == PushNotificationAction.On || + action == PushNotificationAction.Noisy || + action == PushNotificationAction.Highlight || + action == PushNotificationAction.NoisyHighlight + } + + function isNotificationRuleNoisy(action) { + return action == PushNotificationAction.Noisy || + action == PushNotificationAction.NoisyHighlight + } + + function isNotificationRuleHighlight(action) { + return action == PushNotificationAction.Highlight || + action == PushNotificationAction.NoisyHighlight + } + } +} diff --git a/src/qml/Settings/NotificationRuleItem.qml b/src/qml/Settings/NotificationRuleItem.qml new file mode 100644 index 000000000..b8dc1ac0c --- /dev/null +++ b/src/qml/Settings/NotificationRuleItem.qml @@ -0,0 +1,182 @@ +// SPDX-FileCopyrightText: 2022 James Graham +// SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL + +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.kirigamiaddons.labs.mobileform 0.1 as MobileForm + +import org.kde.neochat 1.0 + +MobileForm.AbstractFormDelegate { + id: notificationRuleItem + + property var notificationAction: PushNotificationAction.Unkown + property bool notificationsOn: false + property bool notificationsOnModifiable: true + property bool noisyOn: false + property bool noisyModifiable: true + property bool highlightOn: false + property bool highlightable: false + property bool deleteItem: false + property bool deletable: false + + Layout.fillWidth: true + + onClicked: { + notificationAction = nextNotificationRuleAction(notificationAction) + } + + contentItem : RowLayout { + spacing: Kirigami.Units.largeSpacing + + QQC2.Label { + Layout.minimumWidth: Kirigami.Units.gridUnit + Kirigami.Units.largeSpacing + Layout.minimumHeight: Kirigami.Units.gridUnit + Kirigami.Units.largeSpacing + + text: notificationsOn ? "" : "●" + color: Kirigami.Theme.textColor + horizontalAlignment: Text.AlignHCenter + background: Rectangle { + visible: notificationsOn + Kirigami.Theme.colorSet: Kirigami.Theme.Button + color: highlightOn ? Kirigami.Theme.positiveTextColor : Kirigami.Theme.disabledTextColor + opacity: highlightOn ? 1 : 0.3 + radius: height / 2 + } + } + QQC2.Label { + Layout.fillWidth: true + Layout.alignment: Qt.AlignVCenter + + text: notificationRuleItem.text + elide: Text.ElideRight + wrapMode: Text.Wrap + maximumLineCount: 2 + } + RowLayout { + Layout.alignment: Qt.AlignRight + + QQC2.Button { + id: onButton + + text: onButton.checked ? i18n("Disable notifications") : i18n("Enable notifications") + Accessible.name: text + icon.name: checked ? "notifications" : "notifications-disabled" + display: QQC2.AbstractButton.IconOnly + + visible: notificationRuleItem.notificationsOnModifiable + checkable: true + checked: notificationRuleItem.notificationsOn + enabled: notificationRuleItem.enabled + down: checked + onToggled: { + notificationRuleItem.notificationAction = notificationRuleItem.notifcationRuleAction() + } + + QQC2.ToolTip { + text: onButton.text + delay: Kirigami.Units.toolTipDelay + } + } + QQC2.Button { + id: noisyButton + + text: noisyButton.checked ? i18n("Mute notifications") : i18n("Unmute notifications") + Accessible.name: text + icon.name: checked ? "audio-volume-high" : "audio-volume-muted" + display: QQC2.AbstractButton.IconOnly + + visible: notificationRuleItem.noisyModifiable + checkable: true + checked: notificationRuleItem.noisyOn + enabled: (onButton.checked || !notificationRuleItem.notificationsOnModifiable) && notificationRuleItem.enabled + down: checked + onToggled: { + notificationRuleItem.notificationAction = notificationRuleItem.notifcationRuleAction() + } + + QQC2.ToolTip { + text: noisyButton.text + delay: Kirigami.Units.toolTipDelay + } + } + QQC2.Button { + id: highlightButton + + text: highlightButton.checked ? i18nc("As in clicking this button will switch off highlights for messages that match this rule", "Disable message highlights") : i18nc("As in clicking this button will switch on highlights for messages that match this rule", "Enable message highlights") + Accessible.name: text + icon.name: "draw-highlight" + display: QQC2.AbstractButton.IconOnly + + visible: notificationRuleItem.highlightable + checkable: true + checked: notificationRuleItem.highlightOn + enabled: (onButton.checked || !notificationRuleItem.notificationsOnModifiable) && notificationRuleItem.enabled + down: checked + onToggled: { + notificationRuleItem.notificationAction = notificationRuleItem.notifcationRuleAction() + } + + QQC2.ToolTip { + text: highlightButton.text + delay: Kirigami.Units.toolTipDelay + } + } + QQC2.Button { + id: deleteButton + + Accessible.name: i18n("Delete keyword") + icon.name: "edit-delete-remove" + + visible: notificationRuleItem.deletable + + onClicked: { + notificationRuleItem.deleteItem = !notificationRuleItem.deleteItem + } + } + } + } + + function notifcationRuleAction() { + if (onButton.checked) { + if (noisyButton.checked && highlightButton.checked) { + return PushNotificationAction.NoisyHighlight + } else if (noisyButton.checked) { + return PushNotificationAction.Noisy + } else if (highlightButton.checked) { + return PushNotificationAction.Highlight + } else { + return PushNotificationAction.On + } + } else { + return PushNotificationAction.Off + } + } + + function nextNotificationRuleAction(action) { + let finished = false + + if (action == PushNotificationAction.NoisyHighlight) { + action = PushNotificationAction.Off + } else { + action += 1 + } + + while (!finished) { + if (action == PushNotificationAction.Off && !notificationRuleItem.notificationsOnModifiable) { + action = PushNotificationAction.On + } else if (action == PushNotificationAction.Noisy && !notificationRuleItem.noisyModifiable) { + action = PushNotificationAction.Highlight + } else if (action == PushNotificationAction.Highlight && !notificationRuleItem.highlightable) { + action = PushNotificationAction.Off + } else { + finished = true + } + } + + return action + } +} diff --git a/src/qml/Settings/SettingsPage.qml b/src/qml/Settings/SettingsPage.qml index 9a97d8ae3..51d6258e8 100644 --- a/src/qml/Settings/SettingsPage.qml +++ b/src/qml/Settings/SettingsPage.qml @@ -18,6 +18,11 @@ Kirigami.CategorizedSettings { icon.name: "preferences-desktop-theme-global" page: Qt.resolvedUrl("AppearanceSettingsPage.qml") }, + Kirigami.SettingAction { + text: i18n("Notifications") + icon.name: "preferences-desktop-notification" + page: Qt.resolvedUrl("GlobalNotificationsPage.qml") + }, Kirigami.SettingAction { text: i18n("Accounts") icon.name: "preferences-system-users" diff --git a/src/res.qrc b/src/res.qrc index 3b8e52070..fe026204b 100644 --- a/src/res.qrc +++ b/src/res.qrc @@ -80,6 +80,8 @@ qml/Settings/ColorScheme.qml qml/Settings/GeneralSettingsPage.qml qml/Settings/Emoticons.qml + qml/Settings/GlobalNotificationsPage.qml + qml/Settings/NotificationRuleItem.qml qml/Settings/AppearanceSettingsPage.qml qml/Settings/AccountsPage.qml qml/Settings/AccountEditorPage.qml