Explorar el Código

Merge pull request #5143 from nextcloud/feature/interactive-talk-macos-notifications

Add interactive NC Talk notifications on macOS
Claudio Cambra hace 3 años
padre
commit
5d7068a0f7
Se han modificado 5 ficheros con 210 adiciones y 41 borrados
  1. 12 0
      src/gui/systray.cpp
  2. 2 0
      src/gui/systray.h
  3. 106 11
      src/gui/systray.mm
  4. 80 27
      src/gui/tray/usermodel.cpp
  5. 10 3
      src/gui/tray/usermodel.h

+ 12 - 0
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));

+ 2 - 0
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);

+ 106 - 11
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 <QString>
 #include <QWindow>
 #include <QLoggingCategory>
@@ -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<OCC::TalkReply> 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 @@ Q_LOGGING_CATEGORY(lcMacSystray, "nextcloud.gui.macsystray")
     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

+ 80 - 27
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<int>(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<int>(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);

+ 10 - 3
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;