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