From d2e0afa0709faac8926f07d8b28993f937f4c3b7 Mon Sep 17 00:00:00 2001 From: Claudio Cambra Date: Tue, 8 Nov 2022 16:16:39 +0100 Subject: [PATCH 1/3] Add Talk message type of notifications to native macOS notifications Signed-off-by: Claudio Cambra --- src/gui/systray.mm | 117 ++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 106 insertions(+), 11 deletions(-) diff --git a/src/gui/systray.mm b/src/gui/systray.mm index ddcd4f362c045..d9e5d03d0f051 100644 --- a/src/gui/systray.mm +++ b/src/gui/systray.mm @@ -1,5 +1,10 @@ #include "QtCore/qurl.h" +#include "account.h" +#include "accountstate.h" +#include "accountmanager.h" #include "config.h" +#include "systray.h" +#include "tray/talkreply.h" #include #include #include @@ -9,6 +14,58 @@ Q_LOGGING_CATEGORY(lcMacSystray, "nextcloud.gui.macsystray") +/************************* Private utility functions *************************/ + +namespace { + +void sendTalkReply(UNNotificationResponse *response, UNNotificationContent* content) +{ + if (!response || !content) { + qCWarning(lcMacSystray()) << "Invalid notification response or content." + << "Can't send talk reply."; + return; + } + + UNTextInputNotificationResponse *textInputResponse = (UNTextInputNotificationResponse*)response; + + if (!textInputResponse) { + qCWarning(lcMacSystray()) << "Notification response was not a text input response." + << "Can't send talk reply."; + return; + } + + NSString *reply = textInputResponse.userText; + NSString *token = [content.userInfo objectForKey:@"token"]; + NSString *account = [content.userInfo objectForKey:@"account"]; + NSString *replyTo = [content.userInfo objectForKey:@"replyTo"]; + + const auto qReply = QString::fromNSString(reply); + const auto qReplyTo = QString::fromNSString(replyTo); + const auto qToken = QString::fromNSString(token); + const auto qAccount = QString::fromNSString(account); + + const auto accountState = OCC::AccountManager::instance()->accountFromUserId(qAccount); + + if (!accountState) { + qCWarning(lcMacSystray()) << "Could not find account matching" << qAccount + << "Can't send talk reply."; + return; + } + + qCDebug(lcMacSystray()) << "Sending talk reply from macOS notification." + << "Reply is:" << qReply + << "Replying to:" << qReplyTo + << "Token:" << qToken + << "Account:" << qAccount; + + QPointer talkReply = new OCC::TalkReply(accountState.data(), OCC::Systray::instance()); + talkReply->sendReplyMessage(qToken, qReply, qReplyTo); +} + +} // anonymous namespace + +/**************************** Objective-C classes ****************************/ + @interface NotificationCenterDelegate : NSObject @end @implementation NotificationCenterDelegate @@ -34,23 +91,24 @@ - (void)userNotificationCenter:(UNUserNotificationCenter *)center UNNotificationContent* content = response.notification.request.content; if ([content.categoryIdentifier isEqualToString:@"UPDATE"]) { - if ([response.actionIdentifier isEqualToString:@"DOWNLOAD_ACTION"] || [response.actionIdentifier isEqualToString:UNNotificationDefaultActionIdentifier]) - { + if ([response.actionIdentifier isEqualToString:@"DOWNLOAD_ACTION"] || [response.actionIdentifier isEqualToString:UNNotificationDefaultActionIdentifier]) { qCDebug(lcMacSystray()) << "Opening update download url in browser."; [[NSWorkspace sharedWorkspace] openURL:[NSURL URLWithString:[content.userInfo objectForKey:@"webUrl"]]]; } + } else if ([content.categoryIdentifier isEqualToString:@"TALK_MESSAGE"]) { + + if ([response.actionIdentifier isEqualToString:@"TALK_REPLY_ACTION"]) { + sendTalkReply(response, content); + } } completionHandler(); } @end -namespace OCC { +/********************* Methods accessible to C++ Systray *********************/ -enum MacNotificationAuthorizationOptions { - Default = 0, - Provisional -}; +namespace OCC { double menuBarThickness() { @@ -93,10 +151,24 @@ void registerNotificationCategories(const QString &localisedDownloadString) { intentIdentifiers:@[] options:UNNotificationCategoryOptionNone]; - [[UNUserNotificationCenter currentNotificationCenter] setNotificationCategories:[NSSet setWithObjects:generalCategory, updateCategory, nil]]; + // Create the custom action for talk notifications + UNTextInputNotificationAction* talkReplyAction = [UNTextInputNotificationAction + actionWithIdentifier:@"TALK_REPLY_ACTION" + title:QObject::tr("Reply").toNSString() + options:UNNotificationActionOptionNone + textInputButtonTitle:QObject::tr("Reply").toNSString() + textInputPlaceholder:QObject::tr("Send a Nextcloud Talk reply").toNSString()]; + + UNNotificationCategory* talkReplyCategory = [UNNotificationCategory + categoryWithIdentifier:@"TALK_MESSAGE" + actions:@[talkReplyAction] + intentIdentifiers:@[] + options:UNNotificationCategoryOptionNone]; + + [[UNUserNotificationCenter currentNotificationCenter] setNotificationCategories:[NSSet setWithObjects:generalCategory, updateCategory, talkReplyCategory, nil]]; } -void checkNotificationAuth(MacNotificationAuthorizationOptions additionalAuthOption = MacNotificationAuthorizationOptions::Provisional) +void checkNotificationAuth(MacNotificationAuthorizationOptions additionalAuthOption) { UNUserNotificationCenter* center = [UNUserNotificationCenter currentNotificationCenter]; UNAuthorizationOptions authOptions = UNAuthorizationOptionAlert + UNAuthorizationOptionSound; @@ -170,6 +242,30 @@ void sendOsXUpdateNotification(const QString &title, const QString &message, con [center addNotificationRequest:request withCompletionHandler:nil]; } +void sendOsXTalkNotification(const QString &title, const QString &message, const QString &token, const QString &replyTo, const AccountStatePtr accountState) +{ + UNUserNotificationCenter* center = [UNUserNotificationCenter currentNotificationCenter]; + checkNotificationAuth(); + + if (!accountState || !accountState->account()) { + sendOsXUserNotification(title, message); + return; + } + + NSString *accountNS = accountState->account()->displayName().toNSString(); + NSString *tokenNS = token.toNSString(); + NSString *replyToNS = replyTo.toNSString(); + + UNMutableNotificationContent* content = basicNotificationContent(title, message); + content.categoryIdentifier = @"TALK_MESSAGE"; + content.userInfo = [NSDictionary dictionaryWithObjects:@[accountNS, tokenNS, replyToNS] forKeys:@[@"account", @"token", @"replyTo"]]; + + UNTimeIntervalNotificationTrigger* trigger = [UNTimeIntervalNotificationTrigger triggerWithTimeInterval:1 repeats: NO]; + UNNotificationRequest* request = [UNNotificationRequest requestWithIdentifier:@"NCTalkMessageNotification" content:content trigger:trigger]; + + [center addNotificationRequest:request withCompletionHandler:nil]; +} + void setTrayWindowLevelAndVisibleOnAllSpaces(QWindow *window) { NSView *nativeView = (NSView *)window->winId(); @@ -185,5 +281,4 @@ bool osXInDarkMode() return [osxMode containsString:@"Dark"]; } -} - +} // OCC namespace From 8913f88c504bfb051df0b0a96e6d7c31c853735f Mon Sep 17 00:00:00 2001 From: Claudio Cambra Date: Tue, 8 Nov 2022 19:18:41 +0100 Subject: [PATCH 2/3] Differentiate between normal normal and Talk notifications, invoke macOS text reply notifications Signed-off-by: Claudio Cambra --- src/gui/systray.cpp | 12 +++++ src/gui/systray.h | 2 + src/gui/tray/usermodel.cpp | 107 +++++++++++++++++++++++++++---------- src/gui/tray/usermodel.h | 13 +++-- 4 files changed, 104 insertions(+), 30 deletions(-) diff --git a/src/gui/systray.cpp b/src/gui/systray.cpp index d524b0dbacc9e..4d96ab1df81e4 100644 --- a/src/gui/systray.cpp +++ b/src/gui/systray.cpp @@ -486,6 +486,18 @@ void Systray::showUpdateMessage(const QString &title, const QString &message, co #endif } +void Systray::showTalkMessage(const QString &title, const QString &message, const QString &token, const QString &replyTo, const AccountStatePtr &accountState) +{ +#if defined(Q_OS_MACOS) && defined(BUILD_OWNCLOUD_OSX_BUNDLE) + sendOsXTalkNotification(title, message, token, replyTo, accountState); +#else // TODO: Implement custom notifications (i.e. actionable) for other OSes + Q_UNUSED(replyTo); + Q_UNUSED(token); + Q_UNUSED(accountState); + showMessage(title, message); +#endif +} + void Systray::setToolTip(const QString &tip) { QSystemTrayIcon::setToolTip(tr("%1: %2").arg(Theme::instance()->appNameGUI(), tip)); diff --git a/src/gui/systray.h b/src/gui/systray.h index 697d24e2cb39e..e2d4d738820aa 100644 --- a/src/gui/systray.h +++ b/src/gui/systray.h @@ -51,6 +51,7 @@ void registerNotificationCategories(const QString &localizedDownloadString); bool canOsXSendUserNotification(); void sendOsXUserNotification(const QString &title, const QString &message); void sendOsXUpdateNotification(const QString &title, const QString &message, const QUrl &webUrl); +void sendOsXTalkNotification(const QString &title, const QString &message, const QString &token, const QString &replyTo, const AccountStatePtr accountState); void setTrayWindowLevelAndVisibleOnAllSpaces(QWindow *window); double menuBarThickness(); #endif @@ -113,6 +114,7 @@ public slots: void showMessage(const QString &title, const QString &message, QSystemTrayIcon::MessageIcon icon = Information); void showUpdateMessage(const QString &title, const QString &message, const QUrl &webUrl); + void showTalkMessage(const QString &title, const QString &message, const QString &replyTo, const QString &token, const AccountStatePtr &accountState); void setToolTip(const QString &tip); void createCallDialog(const OCC::Activity &callNotification, const OCC::AccountStatePtr accountState); diff --git a/src/gui/tray/usermodel.cpp b/src/gui/tray/usermodel.cpp index a07088c0f3ff2..86100b823053b 100644 --- a/src/gui/tray/usermodel.cpp +++ b/src/gui/tray/usermodel.cpp @@ -82,8 +82,60 @@ User::User(AccountStatePtr &account, const bool &isCurrent, QObject *parent) connect(this, &User::sendReplyMessage, this, &User::slotSendReplyMessage); } +void User::checkNotifiedNotifications() +{ + // after one hour, clear the gui log notification store + constexpr qint64 clearGuiLogInterval = 60 * 60 * 1000; + if (_guiLogTimer.elapsed() > clearGuiLogInterval) { + _notifiedNotifications.clear(); + } +} + +bool User::notificationAlreadyShown(const long notificationId) +{ + checkNotifiedNotifications(); + return _notifiedNotifications.contains(notificationId); +} + +bool User::canShowNotification(const long notificationId) +{ + ConfigFile cfg; + return cfg.optionalServerNotifications() && + isDesktopNotificationsAllowed() && + !notificationAlreadyShown(notificationId); +} + void User::showDesktopNotification(const QString &title, const QString &message, const long notificationId) { + if(!canShowNotification(notificationId)) { + return; + } + + _notifiedNotifications.insert(notificationId); + Logger::instance()->postGuiLog(title, message); + // restart the gui log timer now that we show a new notification + _guiLogTimer.start(); +} + +void User::showDesktopNotification(const Activity &activity) +{ + const auto notificationId = activity._id; + const auto message = AccountManager::instance()->accounts().count() == 1 ? "" : activity._accName; + showDesktopNotification(activity._subject, message, notificationId); +} + +void User::showDesktopNotification(const ActivityList &activityList) +{ + const auto subject = QStringLiteral("%1 notifications").arg(activityList.count()); + const auto notificationId = -static_cast(qHash(subject)); + + if (!canShowNotification(notificationId)) { + return; + } + + const auto multipleAccounts = AccountManager::instance()->accounts().count() > 1; + const auto message = multipleAccounts ? activityList.constFirst()._accName : QString(); + // Notification ids are uints, which are 4 bytes. Error activities don't have ids, however, so we generate one. // To avoid possible collisions between the activity ids which are actually the notification ids received from // the server (which are always positive) and our "fake" error activity ids, we assign a negative id to the @@ -91,30 +143,40 @@ void User::showDesktopNotification(const QString &title, const QString &message, // // To ensure that we can still treat an unsigned int as normal, we use a long, which is 8 bytes. - ConfigFile cfg; - if (!cfg.optionalServerNotifications() || !isDesktopNotificationsAllowed()) { - return; + Logger::instance()->postGuiLog(subject, message); + + for(const auto &activity : activityList) { + _notifiedNotifications.insert(activity._id); + _activityModel->addNotificationToActivityList(activity); } +} - // after one hour, clear the gui log notification store - constexpr qint64 clearGuiLogInterval = 60 * 60 * 1000; - if (_guiLogTimer.elapsed() > clearGuiLogInterval) { - _notifiedNotifications.clear(); +void User::showDesktopTalkNotification(const Activity &activity) +{ + const auto notificationId = activity._id; + + if (!canShowNotification(notificationId)) { + return; } - if (_notifiedNotifications.contains(notificationId)) { + if (activity._talkNotificationData.messageId.isEmpty()) { + showDesktopNotification(activity._subject, activity._message, notificationId); return; } _notifiedNotifications.insert(notificationId); - Logger::instance()->postGuiLog(title, message); - // restart the gui log timer now that we show a new notification + _activityModel->addNotificationToActivityList(activity); + + Systray::instance()->showTalkMessage(activity._subject, + activity._message, + activity._talkNotificationData.conversationToken, + activity._talkNotificationData.messageId, + _account); _guiLogTimer.start(); } void User::slotBuildNotificationDisplay(const ActivityList &list) { - const auto multipleAccounts = AccountManager::instance()->accounts().count() > 1; ActivityList toNotifyList; std::copy_if(list.constBegin(), list.constEnd(), std::back_inserter(toNotifyList), [&](const Activity &activity) { @@ -131,25 +193,16 @@ void User::slotBuildNotificationDisplay(const ActivityList &list) }); if(toNotifyList.count() > 2) { - const auto subject = QStringLiteral("%1 notifications").arg(toNotifyList.count()); - const auto message = multipleAccounts ? toNotifyList.constFirst()._accName : QString(); - showDesktopNotification(subject, message, -static_cast(qHash(subject))); - - // Set these activities as notified here, rather than in showDesktopNotification - for(const auto &activity : qAsConst(toNotifyList)) { - _notifiedNotifications.insert(activity._id); - _activityModel->addNotificationToActivityList(activity); - } - + showDesktopNotification(toNotifyList); return; } for(const auto &activity : qAsConst(toNotifyList)) { - const auto message = activity._objectType == QStringLiteral("chat") - ? activity._message : AccountManager::instance()->accounts().count() == 1 ? "" : activity._accName; - - showDesktopNotification(activity._subject, message, activity._id); // We assigned the notif. id to the activity id - _activityModel->addNotificationToActivityList(activity); + if (activity._objectType == QStringLiteral("chat")) { + showDesktopTalkNotification(activity); + } else { + showDesktopNotification(activity); + } } } @@ -548,7 +601,7 @@ void User::slotAddErrorToGui(const QString &folderAlias, SyncFileItem::Status st // add 'other errors' to activity list _activityModel->addErrorToActivityList(activity); - showDesktopNotification(activity._subject, activity._message, activity._id); + showDesktopNotification(activity); if (!_expiredActivitiesCheckTimer.isActive()) { _expiredActivitiesCheckTimer.start(expiredActivitiesCheckIntervalMsecs); diff --git a/src/gui/tray/usermodel.h b/src/gui/tray/usermodel.h index d28ff8ea596b7..c49cd9990c774 100644 --- a/src/gui/tray/usermodel.h +++ b/src/gui/tray/usermodel.h @@ -110,22 +110,29 @@ public slots: void slotSendReplyMessage(const int activityIndex, const QString &conversationToken, const QString &message, const QString &replyTo); void forceSyncNow() const; -private: +private slots: void slotPushNotificationsReady(); void slotDisconnectPushNotifications(); void slotReceivedPushNotification(Account *account); void slotReceivedPushActivity(Account *account); void slotCheckExpiredActivities(); + void checkNotifiedNotifications(); + void showDesktopNotification(const QString &title, const QString &message, const long notificationId); + void showDesktopNotification(const Activity &activity); + void showDesktopNotification(const ActivityList &activityList); + void showDesktopTalkNotification(const Activity &activity); + +private: void connectPushNotifications() const; [[nodiscard]] bool checkPushNotificationsAreReady() const; bool isActivityOfCurrentAccount(const Folder *folder) const; [[nodiscard]] bool isUnsolvableConflict(const SyncFileItemPtr &item) const; - void showDesktopNotification(const QString &title, const QString &message, const long notificationId); + bool notificationAlreadyShown(const long notificationId); + bool canShowNotification(const long notificationId); -private: AccountStatePtr _account; bool _isCurrentUser; ActivityListModel *_activityModel; From 4a42aee17e0ace308940a9ebfdba456aedc4d8ad Mon Sep 17 00:00:00 2001 From: Claudio Cambra Date: Fri, 9 Dec 2022 14:36:05 +0100 Subject: [PATCH 3/3] Remove semicolons from Q_UNUSED macros Signed-off-by: Claudio Cambra --- src/gui/systray.cpp | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/gui/systray.cpp b/src/gui/systray.cpp index 4d96ab1df81e4..f9106ea8d6939 100644 --- a/src/gui/systray.cpp +++ b/src/gui/systray.cpp @@ -491,9 +491,9 @@ void Systray::showTalkMessage(const QString &title, const QString &message, cons #if defined(Q_OS_MACOS) && defined(BUILD_OWNCLOUD_OSX_BUNDLE) sendOsXTalkNotification(title, message, token, replyTo, accountState); #else // TODO: Implement custom notifications (i.e. actionable) for other OSes - Q_UNUSED(replyTo); - Q_UNUSED(token); - Q_UNUSED(accountState); + Q_UNUSED(replyTo) + Q_UNUSED(token) + Q_UNUSED(accountState) showMessage(title, message); #endif }