diff --git a/src/gui/CMakeLists.txt b/src/gui/CMakeLists.txt index cd90eb3fb2005..8112ddacfb21c 100644 --- a/src/gui/CMakeLists.txt +++ b/src/gui/CMakeLists.txt @@ -180,8 +180,14 @@ set(client_SRCS tooltipupdater.cpp urischemehandler.h urischemehandler.cpp - notificationconfirmjob.h - notificationconfirmjob.cpp + notifications/notificationconfirmjob.h + notifications/notificationconfirmjob.cpp + notifications/notificationmanager.h + notifications/notificationmanager.cpp + notifications/servernotificationhandler.h + notifications/servernotificationhandler.cpp + notifications/talkreply.h + notifications/talkreply.cpp guiutility.h guiutility.cpp elidedlabel.h @@ -247,12 +253,9 @@ set(client_SRCS search/unifiedsearchresultslistmodel.cpp tray/usermodel.h tray/usermodel.cpp - tray/notificationhandler.h - tray/notificationhandler.cpp tray/sortedactivitylistmodel.h tray/sortedactivitylistmodel.cpp creds/credentialsfactory.h - tray/talkreply.cpp creds/credentialsfactory.cpp creds/httpcredentialsgui.h creds/httpcredentialsgui.cpp @@ -332,7 +335,12 @@ IF( APPLE ) macOS/trayaccountpopup/trayaccountpopup_mac.mm) list(APPEND client_SRCS macOS/singleinstancemanager_mac.mm) - list(APPEND client_SRCS systray_mac_usernotifications.mm) + list(APPEND client_SRCS + notifications/macnotificationcenter.h + notifications/macnotificationcenter_p.h + notifications/macnotificationcenter.mm + notifications/ncnotificationcenterdelegate.h + notifications/ncnotificationcenterdelegate.mm) # FinderSync XPC is independent of File Provider — always built on macOS list(APPEND client_SRCS diff --git a/src/gui/macOS/trayaccountpopup/ncnotificationactionspopup.mm b/src/gui/macOS/trayaccountpopup/ncnotificationactionspopup.mm index 1dc9091b995c5..9a1bc436deece 100644 --- a/src/gui/macOS/trayaccountpopup/ncnotificationactionspopup.mm +++ b/src/gui/macOS/trayaccountpopup/ncnotificationactionspopup.mm @@ -11,6 +11,7 @@ #import "trayaccountpopupmetrics.h" #import "trayaccountpopupviewutils.h" +#include "notifications/notificationmanager.h" #include "tray/usermodel.h" #include @@ -61,8 +62,13 @@ - (void)populateForUserIndex:(int)userIndex activityIndex:(int)activityIndex act width:kNotificationActionsPopupWidth enabled:YES action:^{ + const auto user = OCC::UserModel::instance()->user(userIndex); + const auto notificationManager = user ? user->notificationManager() : nullptr; + if (!notificationManager) { + return; + } if (dismisses) { - OCC::UserModel::instance()->dismissNotification(userIndex, activityIndex); + notificationManager->dismissNotification(activityIndex); [weakSelf orderOut:nil]; return; } @@ -72,7 +78,7 @@ - (void)populateForUserIndex:(int)userIndex activityIndex:(int)activityIndex act } [weakOwner closeAllPopups]; - OCC::UserModel::instance()->triggerNotificationAction(userIndex, activityIndex, actionIndex); + notificationManager->triggerNotificationAction(activityIndex, actionIndex); }]); } addOwnedArrangedSubview(_stack, [[NCSpacerView alloc] initWithHeight:kActionVerticalPadding width:kNotificationActionsPopupWidth]); diff --git a/src/gui/notifications/macnotificationcenter.h b/src/gui/notifications/macnotificationcenter.h new file mode 100644 index 0000000000000..1b0e8c7aaeedb --- /dev/null +++ b/src/gui/notifications/macnotificationcenter.h @@ -0,0 +1,41 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +#pragma once + +#include "accountfwd.h" +#include "tray/activitydata.h" + +#include +#include +#include + +namespace OCC::MacNotificationCenter { + +/** @brief Configure the macOS notification center delegate, permissions and base categories. */ +void initialize(const QString &localizedDownloadString); + +/** @brief Return whether the macOS notification center is available. */ +[[nodiscard]] bool canSendNotification(); + +/** @brief Send a basic macOS notification. */ +void sendNotification(const QString &title, const QString &message); + +/** @brief Send an update notification that opens a download URL. */ +void sendUpdateNotification(const QString &title, const QString &message, const QUrl &webUrl); + +/** + * @brief Send a server notification with its server-provided actions. + * @param playSound Whether the notification should play the default sound. + */ +void sendServerNotification(const Activity &activity, const AccountStatePtr &accountState, bool playSound); + +/** @brief Remove a pending and delivered server notification. */ +void removeServerNotification(const QString &accountId, qint64 notificationId); + +/** @brief Remove native server notifications absent from the latest server list. */ +void reconcileServerNotifications(const QString &accountId, const QSet &activeNotificationIds); + +} diff --git a/src/gui/notifications/macnotificationcenter.mm b/src/gui/notifications/macnotificationcenter.mm new file mode 100644 index 0000000000000..ea901453e26b0 --- /dev/null +++ b/src/gui/notifications/macnotificationcenter.mm @@ -0,0 +1,724 @@ +/* + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +#include +#include +#include +#include + +#include + +#import +#import + +#include "account.h" +#include "accountstate.h" +#include "accountmanager.h" +#include "config.h" +#include "notifications/macnotificationcenter.h" +#include "notifications/notificationmanager.h" +#import "notifications/ncnotificationcenterdelegate.h" + +Q_LOGGING_CATEGORY(lcMacNotificationCenter, "nextcloud.gui.macnotificationcenter") + +/************************* Private utility functions *************************/ + +namespace { + +constexpr auto maximumNativeNotificationActions = 4; + +NSString * const serverCategoryPrefix = @"NEXTCLOUD_SERVER_"; +NSString * const serverActionPrefix = @"NEXTCLOUD_SERVER_ACTION_"; +NSString * const legacyTalkCategoryIdentifier = @"TALK_MESSAGE"; +NSString * const accountIdUserInfoKey = @"accountId"; +NSString * const notificationIdUserInfoKey = @"notificationId"; +NSString * const responseActionsUserInfoKey = @"responseActions"; +NSString * const actionTypeUserInfoKey = @"actionType"; +NSString * const actionLinkUserInfoKey = @"link"; +NSString * const actionVerbUserInfoKey = @"verb"; + +NSMutableDictionary *notificationCategoryRegistry() +{ + static NSMutableDictionary *registry = nil; + static dispatch_once_t once; + dispatch_once(&once, ^{ + registry = [[NSMutableDictionary alloc] init]; + }); + return registry; +} + +NSMutableDictionary *pendingServerNotificationCategoryReferences() +{ + static NSMutableDictionary *references = nil; + static dispatch_once_t once; + dispatch_once(&once, ^{ + references = [[NSMutableDictionary alloc] init]; + }); + return references; +} + +void setRegisteredNotificationCategories() +{ + auto * const registry = notificationCategoryRegistry(); + NSSet *categories = nil; + @synchronized (registry) { + categories = [NSSet setWithArray:registry.allValues]; + } + [UNUserNotificationCenter.currentNotificationCenter setNotificationCategories:categories]; +} + +void registerNativeNotificationCategory(UNNotificationCategory *category) +{ + if (!category) { + return; + } + + auto * const registry = notificationCategoryRegistry(); + auto changed = false; + @synchronized (registry) { + if (![registry objectForKey:category.identifier]) { + [registry setObject:category forKey:category.identifier]; + changed = true; + } + } + if (changed) { + setRegisteredNotificationCategories(); + } +} + +void retainPendingServerNotificationCategory(NSString *categoryIdentifier) +{ + auto * const references = pendingServerNotificationCategoryReferences(); + @synchronized (references) { + const auto count = [[references objectForKey:categoryIdentifier] unsignedIntegerValue]; + [references setObject:@(count + 1) forKey:categoryIdentifier]; + } +} + +void releasePendingServerNotificationCategory(NSString *categoryIdentifier) +{ + auto * const references = pendingServerNotificationCategoryReferences(); + @synchronized (references) { + const auto count = [[references objectForKey:categoryIdentifier] unsignedIntegerValue]; + if (count <= 1) { + [references removeObjectForKey:categoryIdentifier]; + } else { + [references setObject:@(count - 1) forKey:categoryIdentifier]; + } + } +} + +void pruneUnusedServerNotificationCategories(NSSet *referencedCategoryIdentifiers) +{ + auto * const registry = notificationCategoryRegistry(); + auto * const pendingReferences = pendingServerNotificationCategoryReferences(); + auto changed = false; + @synchronized (registry) { + @synchronized (pendingReferences) { + for (NSString *categoryIdentifier in registry.allKeys) { + if ([categoryIdentifier hasPrefix:serverCategoryPrefix] + && ![referencedCategoryIdentifiers containsObject:categoryIdentifier] + && ![pendingReferences objectForKey:categoryIdentifier]) { + [registry removeObjectForKey:categoryIdentifier]; + changed = true; + } + } + } + } + if (changed) { + setRegisteredNotificationCategories(); + } +} + +void seedNativeNotificationCategories(NSArray *categories) +{ + auto * const center = UNUserNotificationCenter.currentNotificationCenter; + auto * const registry = notificationCategoryRegistry(); + @synchronized (registry) { + for (UNNotificationCategory *category in categories) { + [registry setObject:category forKey:category.identifier]; + } + } + + [center getNotificationCategoriesWithCompletionHandler:^(NSSet *registeredCategories) { + @synchronized (registry) { + for (UNNotificationCategory *category in registeredCategories) { + if ([category.identifier isEqualToString:legacyTalkCategoryIdentifier]) { + continue; + } + if (![registry objectForKey:category.identifier]) { + [registry setObject:category forKey:category.identifier]; + } + } + } + setRegisteredNotificationCategories(); + }]; +} + +QString nativeServerNotificationIdentifier(const QString &accountId, const qint64 notificationId) +{ + auto identity = accountId.toUtf8(); + identity += ':'; + identity += QByteArray::number(notificationId); + const auto digest = QCryptographicHash::hash(identity, QCryptographicHash::Sha256).toHex(); + return QStringLiteral("NextcloudServerNotification-%1").arg(QString::fromLatin1(digest)); +} + +QString serverNotificationThreadKind(const OCC::Activity &activity) +{ + if (activity._objectType == QStringLiteral("call")) { + return QStringLiteral("calls"); + } + if (activity._objectType == QStringLiteral("chat") + || activity._objectType == QStringLiteral("room")) { + return QStringLiteral("talk"); + } + if (activity._app == QStringLiteral("announcementcenter") + || activity._app == QStringLiteral("nextcloud_announcements") + || activity._app == QStringLiteral("admin_notifications") + || activity._app == QStringLiteral("notifications") + || activity._app == QStringLiteral("updatenotification")) { + return QStringLiteral("system"); + } + if (activity._app == QStringLiteral("files") + || activity._app.startsWith(QStringLiteral("files_")) + || activity._app == QStringLiteral("federatedfilesharing") + || activity._app == QStringLiteral("sharebymail") + || activity._app == QStringLiteral("groupfolders") + || activity._objectType == QStringLiteral("files") + || activity._objectType == QStringLiteral("remote_share") + || activity._objectType == QStringLiteral("local_share")) { + return QStringLiteral("files"); + } + return QStringLiteral("other"); +} + +QString nativeServerNotificationThreadIdentifier(const OCC::Activity &activity, const QString &accountId) +{ + const auto kind = serverNotificationThreadKind(activity); + auto identity = accountId.toUtf8(); + identity += '\0'; + identity += kind.toUtf8(); + if (kind == QStringLiteral("talk")) { + identity += '\0'; + identity += activity._talkNotificationData.conversationToken.toUtf8(); + } + const auto digest = QCryptographicHash::hash(identity, QCryptographicHash::Sha256).toHex(); + return QStringLiteral("NextcloudServerThread-%1-%2").arg(kind, QString::fromLatin1(digest)); +} + +QVariantList nativeNotificationActions(const OCC::Activity &activity) +{ + const auto previewActions = activity.notificationPreviewActions(); + if (previewActions.size() <= maximumNativeNotificationActions) { + return previewActions; + } + + auto selectedActions = QVariantList{}; + auto dismissAction = QVariant{}; + for (const auto &action : previewActions) { + const auto actionMap = action.toMap(); + if (actionMap.value(QStringLiteral("actionType")).toString() == QStringLiteral("dismiss")) { + dismissAction = action; + continue; + } + if (selectedActions.size() < maximumNativeNotificationActions - (dismissAction.isValid() ? 1 : 0)) { + selectedActions.push_back(action); + } + } + + if (dismissAction.isValid()) { + while (selectedActions.size() >= maximumNativeNotificationActions) { + selectedActions.removeLast(); + } + selectedActions.push_back(dismissAction); + } + + return selectedActions; +} + +void performOnMainThread(dispatch_block_t action) +{ + if ([NSThread isMainThread]) { + action(); + return; + } + dispatch_sync(dispatch_get_main_queue(), action); +} + +void performWithNotificationManager( + const QString &accountId, const std::function &action) +{ + const auto capturedAccountId = accountId; + const auto capturedAction = action; + performOnMainThread(^{ + const auto notificationManager = OCC::NotificationManager::forAccountId(capturedAccountId); + if (!notificationManager) { + qCWarning(lcMacNotificationCenter) << "Could not find notification manager for native response."; + return; + } + capturedAction(notificationManager); + }); +} + +QHash &serverNotificationReconciliationGenerations() +{ + static auto generations = QHash{}; + return generations; +} + +quint64 beginServerNotificationReconciliation(const QString &accountId) +{ + auto &generation = serverNotificationReconciliationGenerations()[accountId]; + ++generation; + return generation; +} + +bool isCurrentServerNotificationReconciliation(const QString &accountId, const quint64 generation) +{ + return serverNotificationReconciliationGenerations().value(accountId) == generation; +} + +NSArray *staleServerNotificationIdentifiers( + NSArray *requests, const QString &accountId, const QSet &activeNotificationIds) +{ + auto * const staleIdentifiers = [NSMutableArray array]; + for (UNNotificationRequest *request in requests) { + const auto content = request.content; + if (![content.categoryIdentifier hasPrefix:serverCategoryPrefix]) { + continue; + } + + NSString * const requestAccountId = [content.userInfo objectForKey:accountIdUserInfoKey]; + NSNumber * const notificationId = [content.userInfo objectForKey:notificationIdUserInfoKey]; + if ([requestAccountId isEqualToString:accountId.toNSString()] + && [notificationId isKindOfClass:[NSNumber class]] + && !activeNotificationIds.contains(notificationId.longLongValue)) { + [staleIdentifiers addObject:request.identifier]; + } + } + return staleIdentifiers; +} + +void removePendingAndDeliveredNotifications(NSArray *identifiers) +{ + if (identifiers.count == 0) { + return; + } + + auto * const center = UNUserNotificationCenter.currentNotificationCenter; + [center removePendingNotificationRequestsWithIdentifiers:identifiers]; + [center removeDeliveredNotificationsWithIdentifiers:identifiers]; +} + +NSSet *serverNotificationCategoryIdentifiers( + NSArray *requests, NSSet *staleIdentifiers) +{ + auto * const categoryIdentifiers = [NSMutableSet set]; + for (UNNotificationRequest *request in requests) { + if (![staleIdentifiers containsObject:request.identifier] + && [request.content.categoryIdentifier hasPrefix:serverCategoryPrefix]) { + [categoryIdentifiers addObject:request.content.categoryIdentifier]; + } + } + return categoryIdentifiers; +} + +bool handleServerNotificationResponse(UNNotificationResponse *response, UNNotificationContent *content) +{ + if (![content.categoryIdentifier hasPrefix:serverCategoryPrefix]) { + return false; + } + + NSString * const accountId = [content.userInfo objectForKey:accountIdUserInfoKey]; + NSNumber * const notificationId = [content.userInfo objectForKey:notificationIdUserInfoKey]; + if (![accountId isKindOfClass:[NSString class]] || ![notificationId isKindOfClass:[NSNumber class]]) { + qCWarning(lcMacNotificationCenter) << "Native server notification is missing its stable identity."; + return true; + } + + const auto qAccountId = QString::fromNSString(accountId); + const auto qNotificationId = notificationId.longLongValue; + + if ([response.actionIdentifier isEqualToString:UNNotificationDismissActionIdentifier]) { + performWithNotificationManager(qAccountId, [qNotificationId](OCC::NotificationManager *notificationManager) { + notificationManager->dismissServerNotification(qNotificationId); + }); + return true; + } + + if ([response.actionIdentifier isEqualToString:UNNotificationDefaultActionIdentifier]) { + performWithNotificationManager(qAccountId, [](OCC::NotificationManager *notificationManager) { + notificationManager->showActivities(); + }); + return true; + } + + NSDictionary * const responseActions = [content.userInfo objectForKey:responseActionsUserInfoKey]; + NSDictionary * const action = [responseActions objectForKey:response.actionIdentifier]; + NSString * const actionType = [action objectForKey:actionTypeUserInfoKey]; + if (![actionType isKindOfClass:[NSString class]]) { + qCWarning(lcMacNotificationCenter) << "Native server notification action has no dispatch metadata."; + return true; + } + + const auto qActionType = QString::fromNSString(actionType); + if (qActionType == QStringLiteral("dismiss")) { + performWithNotificationManager(qAccountId, [qNotificationId](OCC::NotificationManager *notificationManager) { + notificationManager->dismissServerNotification(qNotificationId); + }); + } else if (qActionType == QStringLiteral("reply")) { + if (![response isKindOfClass:[UNTextInputNotificationResponse class]]) { + qCWarning(lcMacNotificationCenter) << "Native Talk reply was not a text input response."; + return true; + } + + UNTextInputNotificationResponse * const textResponse = (UNTextInputNotificationResponse *)response; + const auto qReply = QString::fromNSString(textResponse.userText); + const auto qToken = QString::fromNSString([content.userInfo objectForKey:@"token"]); + const auto qReplyTo = QString::fromNSString([content.userInfo objectForKey:@"replyTo"]); + performWithNotificationManager(qAccountId, [qReply, qToken, qReplyTo](OCC::NotificationManager *notificationManager) { + notificationManager->sendTalkReply(qReply, qToken, qReplyTo); + }); + } else if (qActionType == QStringLiteral("trigger")) { + const auto qLink = QString::fromNSString([action objectForKey:actionLinkUserInfoKey]); + const auto qVerb = QString::fromNSString([action objectForKey:actionVerbUserInfoKey]).toUtf8(); + performWithNotificationManager(qAccountId, [qNotificationId, qLink, qVerb](OCC::NotificationManager *notificationManager) { + notificationManager->triggerServerNotificationAction(qNotificationId, qLink, qVerb); + }); + } else if (qActionType == QStringLiteral("openActivities")) { + performWithNotificationManager(qAccountId, [](OCC::NotificationManager *notificationManager) { + notificationManager->showActivities(); + }); + } + + return true; +} + +} // anonymous namespace + +/********************* Methods accessible to C++ callers *********************/ + +namespace OCC::MacNotificationCenter { + +enum class AuthorizationOptions { + Default, + Provisional, +}; + +void willPresentNotification(void (^completionHandler)(UNNotificationPresentationOptions options)) +{ +#if __MAC_OS_X_VERSION_MIN_REQUIRED >= MAC_OS_X_VERSION_11_0 + completionHandler(UNNotificationPresentationOptionSound + UNNotificationPresentationOptionBanner); +#else + completionHandler(UNNotificationPresentationOptionSound + UNNotificationPresentationOptionAlert); +#endif +} + +void didReceiveNotificationResponse(UNNotificationResponse *response, void (^completionHandler)(void)) +{ + qCDebug(lcMacNotificationCenter) << "Received notification with category identifier:" + << response.notification.request.content.categoryIdentifier + << "and action identifier" + << response.actionIdentifier; + + UNNotificationContent * const content = response.notification.request.content; + if (handleServerNotificationResponse(response, content)) { + completionHandler(); + return; + } + + if ([content.categoryIdentifier isEqualToString:@"UPDATE"]) { + if ([response.actionIdentifier isEqualToString:@"DOWNLOAD_ACTION"] + || [response.actionIdentifier isEqualToString:UNNotificationDefaultActionIdentifier]) { + qCDebug(lcMacNotificationCenter) << "Opening update download url in browser."; + [[NSWorkspace sharedWorkspace] openURL:[NSURL URLWithString:[content.userInfo objectForKey:@"webUrl"]]]; + } + } + + completionHandler(); +} + +// TODO: Get this to actually check for permissions +bool canSendNotification() +{ + UNUserNotificationCenter * const center = UNUserNotificationCenter.currentNotificationCenter; + return center != nil; +} + +void registerBaseNotificationCategories(const QString &localisedDownloadString) +{ + UNNotificationCategory * const generalCategory = [UNNotificationCategory + categoryWithIdentifier:@"GENERAL" + actions:@[] + intentIdentifiers:@[] + options:UNNotificationCategoryOptionCustomDismissAction]; + + // Create the custom actions for update notifications. + UNNotificationAction * const downloadAction = [UNNotificationAction + actionWithIdentifier:@"DOWNLOAD_ACTION" + title:localisedDownloadString.toNSString() + options:UNNotificationActionOptionNone]; + + // Create the category with the custom actions. + UNNotificationCategory * const updateCategory = [UNNotificationCategory + categoryWithIdentifier:@"UPDATE" + actions:@[downloadAction] + intentIdentifiers:@[] + options:UNNotificationCategoryOptionNone]; + + seedNativeNotificationCategories(@[generalCategory, updateCategory]); +} + +void requestAuthorization(const AuthorizationOptions additionalAuthOption = AuthorizationOptions::Provisional) +{ + UNUserNotificationCenter * const center = UNUserNotificationCenter.currentNotificationCenter; + UNAuthorizationOptions authOptions = UNAuthorizationOptionAlert + UNAuthorizationOptionSound; + + if (additionalAuthOption == AuthorizationOptions::Provisional) { + authOptions += UNAuthorizationOptionProvisional; + } + + [center requestAuthorizationWithOptions:(authOptions) completionHandler:^(BOOL granted, NSError * _Nullable error) { + // Enable or disable features based on authorization. + if (granted) { + qCDebug(lcMacNotificationCenter) << "Authorization for notifications has been granted, can display notifications."; + } else { + qCDebug(lcMacNotificationCenter) << "Authorization for notifications not granted."; + if (error) { + const auto errorDescription = QString::fromNSString(error.localizedDescription); + qCWarning(lcMacNotificationCenter) << "Error from notification center: " << errorDescription; + } + } + }]; +} + +void setNotificationCenterDelegate() +{ + UNUserNotificationCenter * const center = UNUserNotificationCenter.currentNotificationCenter; + + static dispatch_once_t once; + dispatch_once(&once, ^{ + id delegate = [[NCNotificationCenterDelegate alloc] init]; + [center setDelegate:delegate]; + }); +} + +void initialize(const QString &localizedDownloadString) +{ + setNotificationCenterDelegate(); + requestAuthorization(AuthorizationOptions::Default); + registerBaseNotificationCategories(localizedDownloadString); +} + +UNMutableNotificationContent *basicNotificationContent(const QString &title, const QString &message, const bool playSound = true) +{ + UNMutableNotificationContent * const content = [[[UNMutableNotificationContent alloc] init] autorelease]; + content.title = title.toNSString(); + content.body = message.toNSString(); + if (playSound) { + content.sound = [UNNotificationSound defaultSound]; + } + + return content; +} + +void sendNotification(const QString &title, const QString &message) +{ + UNUserNotificationCenter * const center = UNUserNotificationCenter.currentNotificationCenter; + requestAuthorization(); + + UNMutableNotificationContent * const content = basicNotificationContent(title, message); + content.categoryIdentifier = @"GENERAL"; + + UNTimeIntervalNotificationTrigger * const trigger = [UNTimeIntervalNotificationTrigger triggerWithTimeInterval:1 repeats:NO]; + UNNotificationRequest * const request = [UNNotificationRequest requestWithIdentifier:@"NCUserNotification" content:content trigger:trigger]; + + [center addNotificationRequest:request withCompletionHandler:nil]; +} + +void sendUpdateNotification(const QString &title, const QString &message, const QUrl &webUrl) +{ + UNUserNotificationCenter * const center = UNUserNotificationCenter.currentNotificationCenter; + requestAuthorization(); + + UNMutableNotificationContent * const content = basicNotificationContent(title, message); + content.categoryIdentifier = @"UPDATE"; + content.userInfo = [NSDictionary dictionaryWithObject:[webUrl.toNSURL() absoluteString] forKey:@"webUrl"]; + + UNTimeIntervalNotificationTrigger * const trigger = [UNTimeIntervalNotificationTrigger triggerWithTimeInterval:1 repeats:NO]; + UNNotificationRequest * const request = [UNNotificationRequest requestWithIdentifier:@"NCUpdateNotification" content:content trigger:trigger]; + + [center addNotificationRequest:request withCompletionHandler:nil]; +} + +void sendServerNotification(const Activity &activity, const AccountStatePtr &accountState, const bool playSound) +{ + if (!accountState || !accountState->account()) { + const auto previewText = activity.notificationPreviewText(); + sendNotification(previewText.title, previewText.subtitle); + return; + } + + auto * const center = UNUserNotificationCenter.currentNotificationCenter; + requestAuthorization(); + + const auto previewText = activity.notificationPreviewText(); + auto message = previewText.subtitle; + if (message.isEmpty() && AccountManager::instance()->accounts().size() > 1) { + message = activity._accName; + } + + auto * const nativeActions = [NSMutableArray array]; + auto * const responseActions = [NSMutableDictionary dictionary]; + auto categorySignature = QByteArray{}; + const auto actions = nativeNotificationActions(activity); + for (auto nativeActionIndex = 0; nativeActionIndex < actions.size(); ++nativeActionIndex) { + const auto action = actions.at(nativeActionIndex).toMap(); + const auto label = action.value(QStringLiteral("label")).toString(); + const auto actionType = action.value(QStringLiteral("actionType")).toString(); + const auto actionIndex = action.value(QStringLiteral("actionIndex")).toInt(); + const auto verb = action.value(QStringLiteral("verb")).toString().toUtf8(); + if (label.isEmpty()) { + continue; + } + + NSString * const identifier = [NSString stringWithFormat:@"%@%d", serverActionPrefix, nativeActionIndex]; + auto nativeActionType = actionType; + UNNotificationAction *nativeAction = nil; + const auto isInlineReply = verb == QByteArrayLiteral("REPLY") + && !activity._talkNotificationData.messageId.isEmpty(); + if (isInlineReply) { + nativeActionType = QStringLiteral("reply"); + nativeAction = [UNTextInputNotificationAction + actionWithIdentifier:identifier + title:label.toNSString() + options:UNNotificationActionOptionNone + textInputButtonTitle:QObject::tr("Reply").toNSString() + textInputPlaceholder:QObject::tr("Send a Nextcloud Talk reply").toNSString()]; + } else { + auto options = UNNotificationActionOptionNone; + if (verb == QByteArrayLiteral("DELETE")) { + options = UNNotificationActionOptionDestructive; + } else if (nativeActionType == QStringLiteral("openActivities")) { + options = UNNotificationActionOptionForeground; + } + nativeAction = [UNNotificationAction + actionWithIdentifier:identifier + title:label.toNSString() + options:options]; + } + + auto * const metadata = [NSMutableDictionary dictionaryWithObject:nativeActionType.toNSString() + forKey:actionTypeUserInfoKey]; + if (actionIndex >= 0 && actionIndex < activity._links.size()) { + const auto &link = activity._links.at(actionIndex); + [metadata setObject:link._link.toNSString() forKey:actionLinkUserInfoKey]; + [metadata setObject:QString::fromUtf8(link._verb).toNSString() forKey:actionVerbUserInfoKey]; + } + [responseActions setObject:metadata forKey:identifier]; + [nativeActions addObject:nativeAction]; + + categorySignature.append(identifier.UTF8String); + categorySignature.append('\0'); + categorySignature.append(label.toUtf8()); + categorySignature.append('\0'); + categorySignature.append(nativeActionType.toUtf8()); + categorySignature.append('\0'); + categorySignature.append(verb); + categorySignature.append('\0'); + } + + const auto categoryDigest = QCryptographicHash::hash(categorySignature, QCryptographicHash::Sha256).toHex(); + NSString * const categoryIdentifier = QStringLiteral("%1%2") + .arg(QString::fromNSString(serverCategoryPrefix), QString::fromLatin1(categoryDigest)) + .toNSString(); + UNNotificationCategory * const category = [UNNotificationCategory + categoryWithIdentifier:categoryIdentifier + actions:nativeActions + intentIdentifiers:@[] + options:UNNotificationCategoryOptionCustomDismissAction]; + registerNativeNotificationCategory(category); + + const auto accountId = accountState->account()->id(); + auto * const content = basicNotificationContent(previewText.title, message, playSound); + content.categoryIdentifier = categoryIdentifier; + content.threadIdentifier = nativeServerNotificationThreadIdentifier(activity, accountId).toNSString(); + + auto * const userInfo = [NSMutableDictionary dictionaryWithObjectsAndKeys: + accountId.toNSString(), accountIdUserInfoKey, + [NSNumber numberWithLongLong:activity._id], notificationIdUserInfoKey, + responseActions, responseActionsUserInfoKey, + nil]; + if (!activity._talkNotificationData.conversationToken.isEmpty()) { + [userInfo setObject:activity._talkNotificationData.conversationToken.toNSString() forKey:@"token"]; + } + if (!activity._talkNotificationData.messageId.isEmpty()) { + [userInfo setObject:activity._talkNotificationData.messageId.toNSString() forKey:@"replyTo"]; + } + content.userInfo = userInfo; + + const auto requestIdentifier = nativeServerNotificationIdentifier(accountId, activity._id); + UNTimeIntervalNotificationTrigger * const trigger = [UNTimeIntervalNotificationTrigger triggerWithTimeInterval:1 repeats:NO]; + UNNotificationRequest * const request = [UNNotificationRequest requestWithIdentifier:requestIdentifier.toNSString() + content:content + trigger:trigger]; + retainPendingServerNotificationCategory(categoryIdentifier); + [center addNotificationRequest:request withCompletionHandler:^(NSError *error) { + releasePendingServerNotificationCategory(categoryIdentifier); + if (error) { + qCWarning(lcMacNotificationCenter) << "Could not deliver server notification:" + << QString::fromNSString(error.localizedDescription); + } + }]; +} + +void removeServerNotification(const QString &accountId, const qint64 notificationId) +{ + const auto identifier = nativeServerNotificationIdentifier(accountId, notificationId).toNSString(); + auto * const center = UNUserNotificationCenter.currentNotificationCenter; + [center removePendingNotificationRequestsWithIdentifiers:@[identifier]]; + [center removeDeliveredNotificationsWithIdentifiers:@[identifier]]; +} + +void reconcileServerNotifications(const QString &accountId, const QSet &activeNotificationIds) +{ + auto * const center = UNUserNotificationCenter.currentNotificationCenter; + const auto capturedAccountId = accountId; + const auto capturedActiveNotificationIds = activeNotificationIds; + __block quint64 generation = 0; + performOnMainThread(^{ + generation = beginServerNotificationReconciliation(capturedAccountId); + }); + + [center getPendingNotificationRequestsWithCompletionHandler:^(NSArray *pendingRequests) { + [center getDeliveredNotificationsWithCompletionHandler:^(NSArray *notifications) { + performOnMainThread(^{ + if (!isCurrentServerNotificationReconciliation(capturedAccountId, generation)) { + return; + } + + auto * const deliveredRequests = [NSMutableArray arrayWithCapacity:notifications.count]; + for (UNNotification *notification in notifications) { + [deliveredRequests addObject:notification.request]; + } + + auto * const stalePendingIdentifiers = + staleServerNotificationIdentifiers(pendingRequests, capturedAccountId, capturedActiveNotificationIds); + auto * const staleDeliveredIdentifiers = + staleServerNotificationIdentifiers(deliveredRequests, capturedAccountId, capturedActiveNotificationIds); + removePendingAndDeliveredNotifications(stalePendingIdentifiers); + removePendingAndDeliveredNotifications(staleDeliveredIdentifiers); + + auto * const referencedCategoryIdentifiers = [NSMutableSet set]; + [referencedCategoryIdentifiers unionSet:serverNotificationCategoryIdentifiers(pendingRequests, [NSSet setWithArray:stalePendingIdentifiers])]; + [referencedCategoryIdentifiers unionSet:serverNotificationCategoryIdentifiers(deliveredRequests, [NSSet setWithArray:staleDeliveredIdentifiers])]; + pruneUnusedServerNotificationCategories(referencedCategoryIdentifiers); + }); + }]; + }]; +} + +} // namespace OCC::MacNotificationCenter diff --git a/src/gui/notifications/macnotificationcenter_p.h b/src/gui/notifications/macnotificationcenter_p.h new file mode 100644 index 0000000000000..eacccb7c07464 --- /dev/null +++ b/src/gui/notifications/macnotificationcenter_p.h @@ -0,0 +1,15 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +#pragma once + +#import + +namespace OCC::MacNotificationCenter { + +void willPresentNotification(void (^completionHandler)(UNNotificationPresentationOptions options)); +void didReceiveNotificationResponse(UNNotificationResponse *response, void (^completionHandler)(void)); + +} diff --git a/src/gui/notifications/ncnotificationcenterdelegate.h b/src/gui/notifications/ncnotificationcenterdelegate.h new file mode 100644 index 0000000000000..ca6e3dfa889bd --- /dev/null +++ b/src/gui/notifications/ncnotificationcenterdelegate.h @@ -0,0 +1,12 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +#pragma once + +#import + +/** @brief Forwards macOS notification center callbacks to the C++ notification module. */ +@interface NCNotificationCenterDelegate : NSObject +@end diff --git a/src/gui/notifications/ncnotificationcenterdelegate.mm b/src/gui/notifications/ncnotificationcenterdelegate.mm new file mode 100644 index 0000000000000..bf241ef3c87a0 --- /dev/null +++ b/src/gui/notifications/ncnotificationcenterdelegate.mm @@ -0,0 +1,31 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +#import "ncnotificationcenterdelegate.h" + +#include "macnotificationcenter_p.h" + +#include + +@implementation NCNotificationCenterDelegate + +- (void)userNotificationCenter:(UNUserNotificationCenter *)center + willPresentNotification:(UNNotification *)notification + withCompletionHandler:(void (^)(UNNotificationPresentationOptions options))completionHandler +{ + Q_UNUSED(center); + Q_UNUSED(notification); + OCC::MacNotificationCenter::willPresentNotification(completionHandler); +} + +- (void)userNotificationCenter:(UNUserNotificationCenter *)center +didReceiveNotificationResponse:(UNNotificationResponse *)response + withCompletionHandler:(void (^)(void))completionHandler +{ + Q_UNUSED(center); + OCC::MacNotificationCenter::didReceiveNotificationResponse(response, completionHandler); +} + +@end diff --git a/src/gui/notificationconfirmjob.cpp b/src/gui/notifications/notificationconfirmjob.cpp similarity index 96% rename from src/gui/notificationconfirmjob.cpp rename to src/gui/notifications/notificationconfirmjob.cpp index 8684e201c8c1b..33ced82a2094f 100644 --- a/src/gui/notificationconfirmjob.cpp +++ b/src/gui/notifications/notificationconfirmjob.cpp @@ -4,7 +4,7 @@ * SPDX-License-Identifier: GPL-2.0-or-later */ -#include "notificationconfirmjob.h" +#include "notifications/notificationconfirmjob.h" #include "networkjobs.h" #include "account.h" diff --git a/src/gui/notificationconfirmjob.h b/src/gui/notifications/notificationconfirmjob.h similarity index 90% rename from src/gui/notificationconfirmjob.h rename to src/gui/notifications/notificationconfirmjob.h index cb566bd1a6e95..6f3542b7ffa78 100644 --- a/src/gui/notificationconfirmjob.h +++ b/src/gui/notifications/notificationconfirmjob.h @@ -10,9 +10,7 @@ #include "accountfwd.h" #include "abstractnetworkjob.h" -#include -#include -#include +#include #include namespace OCC { @@ -30,6 +28,7 @@ class NotificationConfirmJob : public AbstractNetworkJob Q_OBJECT public: + /** @brief Create a notification action request for an account. */ explicit NotificationConfirmJob(AccountPtr account); /** @@ -62,4 +61,4 @@ private slots: }; } -#endif // NotificationConfirmJob_H +#endif // NOTIFICATIONCONFIRMJOB_H diff --git a/src/gui/notifications/notificationmanager.cpp b/src/gui/notifications/notificationmanager.cpp new file mode 100644 index 0000000000000..2cc6a09f7dcb1 --- /dev/null +++ b/src/gui/notifications/notificationmanager.cpp @@ -0,0 +1,526 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +#include "notificationmanager.h" + +#include "notifications/notificationconfirmjob.h" +#include "notifications/servernotificationhandler.h" +#include "notifications/talkreply.h" + +#include "account.h" +#include "accountmanager.h" +#include "accountstate.h" +#include "configfile.h" +#include "common/utility.h" +#include "folder.h" +#include "guiutility.h" +#include "logger.h" +#include "ocsjob.h" +#include "systray.h" +#include "tray/activitylistmodel.h" +#include "userstatusconnector.h" + +#if defined(Q_OS_MACOS) && __MAC_OS_X_VERSION_MIN_REQUIRED >= MAC_OS_X_VERSION_10_14 && defined(BUILD_OWNCLOUD_OSX_BUNDLE) +#include "notifications/macnotificationcenter.h" +#endif + +#include +#include +#include +#include +#include + +#include +#include +#include + +namespace { + +constexpr auto debugCallNotificationEnvVar = "NEXTCLOUD_DEBUG_CALL_NOTIFICATION"; +constexpr auto debugCallNotificationAvatarEnvVar = "NEXTCLOUD_DEBUG_CALL_NOTIFICATION_AVATAR_URL"; +constexpr qint64 clearNotificationHistoryIntervalMsecs = 60 * 60 * 1000; + +QHash> ¬ificationManagers() +{ + static auto managers = QHash>{}; + return managers; +} + +bool showDebugCallNotification(const OCC::AccountStatePtr &accountState) +{ + if (!qEnvironmentVariableIsSet(debugCallNotificationEnvVar)) { + return false; + } + + const auto systray = OCC::Systray::instance(); + if (!systray || !accountState || !accountState->account()) { + return true; + } + + auto activity = OCC::Activity{}; + activity._id = -QDateTime::currentMSecsSinceEpoch(); + activity._objectType = QStringLiteral("call"); + activity._subject = QStringLiteral("Iva Horn would like to talk with you"); + activity._shouldNotify = true; + activity._dateTime = QDateTime::currentDateTime(); + activity._accName = accountState->account()->displayName(); + activity._talkNotificationData.conversationToken = QStringLiteral("debug-call"); + + const auto avatarUrl = qEnvironmentVariable(debugCallNotificationAvatarEnvVar); + if (!avatarUrl.isEmpty()) { + activity._talkNotificationData.userAvatar = avatarUrl; + } else if (!accountState->account()->url().isEmpty() && !accountState->account()->davUser().isEmpty()) { + activity._talkNotificationData.userAvatar = accountState->account()->url().toString() + + QStringLiteral("/index.php/avatar/") + + accountState->account()->davUser() + + QStringLiteral("/128"); + } + + auto answer = OCC::ActivityLink{}; + answer._label = QObject::tr("Answer"); + answer._verb = "WEB"; + answer._link = accountState->account()->url().toString(); + answer._primary = true; + activity._links.append(answer); + + systray->createCallDialog(activity, accountState); + return true; +} + +} + +namespace OCC { + +NotificationManager::NotificationManager(AccountStatePtr accountState, ActivityListModel *activityModel, QObject *parent) + : QObject(parent) + , _accountState(std::move(accountState)) + , _activityModel(activityModel) +{ + Q_ASSERT(_accountState && _accountState->account()); + Q_ASSERT(_activityModel); + + if (_accountState && _accountState->account()) { + notificationManagers().insert(_accountState->account()->id(), this); + } + + if (_activityModel) { + connect(_activityModel, &ActivityListModel::sendNotificationRequest, + this, &NotificationManager::sendNotificationRequest); +#if defined(Q_OS_MACOS) && __MAC_OS_X_VERSION_MIN_REQUIRED >= MAC_OS_X_VERSION_10_14 && defined(BUILD_OWNCLOUD_OSX_BUNDLE) + connect(_activityModel, &ActivityListModel::notificationRemoved, this, [this](const qint64 notificationId) { + MacNotificationCenter::removeServerNotification(_accountState->account()->id(), notificationId); + }); +#endif + } +} + +NotificationManager::~NotificationManager() +{ + if (!_accountState || !_accountState->account()) { + return; + } + + const auto accountId = _accountState->account()->id(); + if (notificationManagers().value(accountId) == this) { + notificationManagers().remove(accountId); + } +} + +NotificationManager *NotificationManager::forAccountId(const QString &accountId) +{ + return notificationManagers().value(accountId); +} + +void NotificationManager::checkNotifiedNotifications() +{ + if (_notificationHistoryTimer.elapsed() > clearNotificationHistoryIntervalMsecs) { + _notifiedNotifications.clear(); + } +} + +bool NotificationManager::notificationAlreadyShown(const qint64 notificationId) +{ + checkNotifiedNotifications(); + return _notifiedNotifications.contains(notificationId); +} + +bool NotificationManager::canShowNotification(const qint64 notificationId) +{ + const auto cfg = ConfigFile{}; + return _accountState + && cfg.optionalServerNotifications() + && _accountState->isDesktopNotificationsAllowed() + && !notificationAlreadyShown(notificationId); +} + +void NotificationManager::showNotification(const QString &title, const QString &message, const qint64 notificationId) +{ + if (!canShowNotification(notificationId)) { + return; + } + + _notifiedNotifications.insert(notificationId); + Logger::instance()->postGuiLog(title, message); + _notificationHistoryTimer.start(); +} + +void NotificationManager::showNotification(const Activity &activity) +{ + const auto notificationId = activity._id; + const auto message = AccountManager::instance()->accounts().count() == 1 ? QString{} : activity._accName; + + if (!activity._links.isEmpty() && _activityModel) { + _activityModel->addNotificationToActivityList(activity); + } + + showNotification(activity._subject, message, notificationId); +} + +void NotificationManager::showServerNotifications(const ActivityList &activities) +{ +#if defined(Q_OS_MACOS) && __MAC_OS_X_VERSION_MIN_REQUIRED >= MAC_OS_X_VERSION_10_14 && defined(BUILD_OWNCLOUD_OSX_BUNDLE) + auto playSound = true; + for (const auto &activity : activities) { + MacNotificationCenter::sendServerNotification(activity, _accountState, playSound); + playSound = false; + } +#else + if (activities.size() == 1) { + const auto &activity = activities.constFirst(); + if (activity._objectType == QStringLiteral("chat")) { + Logger::instance()->postGuiLog(activity._subject, activity._message); + } else { + const auto multipleAccounts = AccountManager::instance()->accounts().size() > 1; + const auto message = multipleAccounts ? activity._accName : QString{}; + Logger::instance()->postGuiLog(activity._subject, message); + } + return; + } + + const auto subject = tr("%n notification(s)", nullptr, activities.size()); + const auto multipleAccounts = AccountManager::instance()->accounts().size() > 1; + const auto message = multipleAccounts ? activities.constFirst()._accName : QString{}; + Logger::instance()->postGuiLog(subject, message); +#endif +} + +void NotificationManager::buildNotificationDisplay(const ActivityList &list) +{ + if (!_activityModel) { + return; + } + + _activityModel->removeOutdatedNotifications(list); +#if defined(Q_OS_MACOS) && __MAC_OS_X_VERSION_MIN_REQUIRED >= MAC_OS_X_VERSION_10_14 && defined(BUILD_OWNCLOUD_OSX_BUNDLE) + auto activeNotificationIds = QSet{}; + activeNotificationIds.reserve(list.size()); + for (const auto &activity : list) { + activeNotificationIds.insert(activity._id); + } + MacNotificationCenter::reconcileServerNotifications(_accountState->account()->id(), activeNotificationIds); +#endif + + const auto cfg = ConfigFile{}; + if (!_accountState + || !cfg.optionalServerNotifications() + || !_accountState->isDesktopNotificationsAllowed()) { + return; + } + + checkNotifiedNotifications(); + const auto showChatNotifications = cfg.showChatNotifications(); + + auto toNotifyList = ActivityList{}; + std::copy_if(list.cbegin(), list.cend(), std::back_inserter(toNotifyList), [this, showChatNotifications](const Activity &activity) { + if (!activity._shouldNotify) { + qCDebug(lcActivity).nospace() << "No notification should be sent for activity with id=" << activity._id + << " objectType=" << activity._objectType; + return false; + } + if (activity._objectType == QStringLiteral("chat") && !showChatNotifications) { + qCDebug(lcActivity).nospace() << "Chat notification disabled for activity with id=" << activity._id; + return false; + } + if (_notifiedNotifications.contains(activity._id)) { + qCInfo(lcActivity).nospace() << "Ignoring already notified activity with id=" << activity._id + << " objectType=" << activity._objectType; + return false; + } + return true; + }); + + if (toNotifyList.isEmpty()) { + return; + } + + for (const auto &activity : std::as_const(toNotifyList)) { + _notifiedNotifications.insert(activity._id); + _activityModel->addNotificationToActivityList(activity); + } + _notificationHistoryTimer.start(); + + showServerNotifications(toNotifyList); +} + +void NotificationManager::buildIncomingCallDialogs(const ActivityList &list) +{ + const auto cfg = ConfigFile{}; + const auto userStatus = _accountState->account()->userStatusConnector()->userStatus().state(); + if (userStatus == UserStatus::OnlineStatus::DoNotDisturb + || !cfg.optionalServerNotifications() + || !cfg.showCallNotifications() + || !_accountState->isDesktopNotificationsAllowed()) { + return; + } + + const auto systray = Systray::instance(); + if (!systray) { + qCWarning(lcActivity) << "No systray instance available, can not notify about new calls"; + return; + } + + for (const auto &activity : list) { + if (!activity._shouldNotify) { + qCDebug(lcActivity).nospace() << "No notification should be sent for activity with id=" << activity._id + << " objectType=" << activity._objectType; + continue; + } + systray->createCallDialog(activity, _accountState); + } +} + +void NotificationManager::refresh() +{ + static auto debugCallNotificationShown = false; + if (!debugCallNotificationShown) { + debugCallNotificationShown = showDebugCallNotification(_accountState); + } + + if (_notificationRequestsRunning != 0) { + qCWarning(lcActivity) << "Notification request counter not zero."; + return; + } + if (_isNotificationFetchRunning) { + qCDebug(lcActivity) << "Notification fetch is already running."; + return; + } + + auto * const handler = new ServerNotificationHandler(_accountState.data()); + connect(handler, &ServerNotificationHandler::newNotificationList, + this, &NotificationManager::buildNotificationDisplay); + connect(handler, &ServerNotificationHandler::newIncomingCallsList, + this, &NotificationManager::buildIncomingCallDialogs); + connect(handler, &ServerNotificationHandler::jobFinished, + this, &NotificationManager::notificationFetchFinished); + _isNotificationFetchRunning = handler->startFetchNotifications(); +} + +void NotificationManager::handlePushNotification(Account *account) +{ + if (account && _accountState && account->id() == _accountState->account()->id()) { + refresh(); + } +} + +void NotificationManager::notificationFetchFinished() +{ + _isNotificationFetchRunning = false; +} + +void NotificationManager::sendNotificationRequest( + const QString &accountName, const QString &link, const QByteArray &verb, const int row) +{ + qCInfo(lcActivity) << "Server Notification Request" << verb << link << "on account" << accountName; + if (!_accountState || !_accountState->account() || accountName != _accountState->account()->displayName()) { + qCWarning(lcActivity) << "Notification action account does not match its notification manager."; + return; + } + + auto notificationId = qint64{-1}; + const auto &activities = _activityModel->activityList(); + if (row >= 0 && row < activities.size() && activities.at(row)._type == Activity::NotificationType) { + notificationId = activities.at(row)._id; + } + sendServerNotificationRequest(link, verb, row, notificationId); +} + +void NotificationManager::sendServerNotificationRequest( + const QString &link, const QByteArray &verb, const int row, const qint64 notificationId) +{ + static const auto validVerbs = QList{ + QByteArrayLiteral("GET"), + QByteArrayLiteral("PUT"), + QByteArrayLiteral("POST"), + QByteArrayLiteral("DELETE"), + }; + if (!validVerbs.contains(verb)) { + qCWarning(lcActivity) << "Notification Links: Invalid verb:" << verb; + return; + } + if (!_accountState || !_accountState->account()) { + return; + } + + const auto actionUrl = QUrl(link); + if (!actionUrl.isValid() || actionUrl.isEmpty()) { + qCWarning(lcActivity) << "Notification action has an invalid URL:" << link; + return; + } + + auto * const job = new NotificationConfirmJob(_accountState->account()); + job->setLinkAndVerb(actionUrl, verb); + job->setProperty("activityRow", row); + job->setProperty("notificationId", notificationId); + connect(job, &AbstractNetworkJob::networkError, this, &NotificationManager::notifyNetworkError); + connect(job, &NotificationConfirmJob::jobFinished, this, &NotificationManager::notifyServerFinished); + ++_notificationRequestsRunning; + job->start(); +} + +void NotificationManager::dismissServerNotification(const qint64 notificationId) +{ + if (notificationId < 0 || !_accountState || !_accountState->account()) { + return; + } + + const auto link = Utility::concatUrlPath( + _accountState->account()->url(), + QStringLiteral("ocs/v2.php/apps/notifications/api/v2/notifications/%1").arg(notificationId)); + sendServerNotificationRequest(link.toString(), QByteArrayLiteral("DELETE"), -1, notificationId); +} + +void NotificationManager::dismissNotification(const int activityIndex) +{ + if (_activityModel) { + _activityModel->slotTriggerDismiss(activityIndex); + } +} + +void NotificationManager::triggerNotificationAction(const int activityIndex, const int actionIndex) +{ + if (_activityModel) { + _activityModel->slotTriggerAction(activityIndex, actionIndex); + } +} + +void NotificationManager::triggerServerNotificationAction( + const qint64 notificationId, const QString &link, const QByteArray &verb) +{ + if (verb == QByteArrayLiteral("WEB")) { + Utility::openBrowser(QUrl(link)); + return; + } + sendServerNotificationRequest(link, verb, -1, notificationId); +} + +void NotificationManager::sendTalkReply( + const QString &reply, const QString &conversationToken, const QString &replyTo) +{ + sendTalkReplyMessage(conversationToken, reply, replyTo, std::nullopt, true); +} + +void NotificationManager::sendTalkReply( + const int activityIndex, const QString &conversationToken, const QString &message, const QString &replyTo) +{ + sendTalkReplyMessage(conversationToken, message, replyTo, activityIndex); +} + +void NotificationManager::sendTalkReplyMessage( + const QString &conversationToken, const QString &message, const QString &replyTo, + const std::optional activityIndex, const bool logNativeReply) +{ + if (!_accountState) { + return; + } + + if (logNativeReply) { + qCDebug(lcActivity) << "Sending Talk reply from native notification." + << "Replying to:" << replyTo + << "Token:" << conversationToken + << "Account:" << _accountState->account()->id(); + } + + const auto talkReply = new TalkReply(_accountState.data(), this); + if (activityIndex) { + connect(talkReply, &TalkReply::replyMessageSent, this, [this, activityIndex = *activityIndex](const QString &sentMessage) { + if (_activityModel) { + _activityModel->setReplyMessageSent(activityIndex, sentMessage); + } + }); + } + talkReply->sendReplyMessage(conversationToken, message, replyTo); +} + +void NotificationManager::showActivities() const +{ + if (_accountState) { + Systray::instance()->showActivitiesWindow(_accountState.data()); + } +} + +void NotificationManager::notificationRequestFinished(const int statusCode) +{ + const auto row = sender()->property("activityRow").toInt(); + const auto notificationId = sender()->property("notificationId").toLongLong(); + if (statusCode != OCS_SUCCESS_STATUS_CODE + && statusCode != OCS_SUCCESS_STATUS_CODE_V2 + && statusCode != OCS_ACCEPTED_STATUS_CODE) { + qCWarning(lcActivity) << "Notification Request to Server failed, leave notification visible."; + return; + } + + qCInfo(lcActivity) << "Notification Request to Server succeeded, removing notification."; + if (notificationId >= 0) { + _activityModel->removeNotificationFromActivityList(notificationId); + } else if (row >= 0 && row < _activityModel->rowCount()) { + _activityModel->removeActivityFromActivityList(row); + } +} + +void NotificationManager::endNotificationRequest(const int replyCode) +{ + --_notificationRequestsRunning; + notificationRequestFinished(replyCode); +} + +void NotificationManager::notifyNetworkError(QNetworkReply *reply) +{ + const auto job = qobject_cast(sender()); + if (!job) { + return; + } + + const auto resultCode = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); + job->setProperty("networkErrorStatusCode", resultCode); + qCWarning(lcActivity) << "Server notify job failed with code" << resultCode; +} + +void NotificationManager::notifyServerFinished(const QString &reply, const int replyCode) +{ + const auto job = qobject_cast(sender()); + if (!job) { + return; + } + + const auto networkErrorStatusCode = job->property("networkErrorStatusCode"); + const auto effectiveReplyCode = networkErrorStatusCode.isValid() ? networkErrorStatusCode.toInt() : replyCode; + endNotificationRequest(effectiveReplyCode); + qCInfo(lcActivity) << "Server Notification reply code" << effectiveReplyCode << reply; +} + +void NotificationManager::addNotification(const Folder *folder, const Activity &activity) +{ + if (!folder || folder->accountState() != _accountState.data() || _notifiedNotifications.contains(activity._id)) { + return; + } + + _notifiedNotifications.insert(activity._id); + if (_activityModel) { + _activityModel->addNotificationToActivityList(activity); + } + _notificationHistoryTimer.start(); +} + +} diff --git a/src/gui/notifications/notificationmanager.h b/src/gui/notifications/notificationmanager.h new file mode 100644 index 0000000000000..c51f5bf319b11 --- /dev/null +++ b/src/gui/notifications/notificationmanager.h @@ -0,0 +1,112 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +#pragma once + +#include "accountfwd.h" +#include "tray/activitydata.h" + +#include +#include +#include +#include + +#include + +class QNetworkReply; + +namespace OCC { + +class Account; +class ActivityListModel; +class Folder; + +/** + * @brief Owns notification fetching, delivery and action handling for one account. + * + * NotificationManager is the account-scoped boundary between server notifications, + * activity-list presentation and platform notification centers. + */ +class NotificationManager : public QObject +{ + Q_OBJECT + +public: + /** @brief Create the notification manager for an account and its activity model. */ + explicit NotificationManager(AccountStatePtr accountState, ActivityListModel *activityModel, QObject *parent = nullptr); + /** @brief Unregister this account-scoped manager. */ + ~NotificationManager() override; + + /** @brief Return the live manager for a stable account id, or `nullptr`. */ + [[nodiscard]] static NotificationManager *forAccountId(const QString &accountId); + + /** @brief Show a desktop notification unless this id was shown recently. */ + void showNotification(const QString &title, const QString &message, qint64 notificationId); + + /** @brief Show an activity as a desktop notification. */ + void showNotification(const Activity &activity); + + /** @brief Add an account-owned activity notification without displaying it twice. */ + void addNotification(const Folder *folder, const Activity &activity); + + /** @brief Dismiss the notification at an activity-model row. */ + void dismissNotification(int activityIndex); + + /** @brief Trigger an action from the notification at an activity-model row. */ + void triggerNotificationAction(int activityIndex, int actionIndex); + + /** @brief Dismiss a server notification using its stable server identifier. */ + void dismissServerNotification(qint64 notificationId); + + /** @brief Trigger a server notification action using stable action metadata. */ + void triggerServerNotificationAction(qint64 notificationId, const QString &link, const QByteArray &verb); + + /** @brief Send a reply from an inline Talk notification action. */ + void sendTalkReply(const QString &reply, const QString &conversationToken, const QString &replyTo); + + /** @brief Send a Talk reply for an activity row and store the returned message. */ + void sendTalkReply(int activityIndex, const QString &conversationToken, const QString &message, const QString &replyTo); + + /** @brief Open the standalone Activities window for this manager's account. */ + void showActivities() const; + +public slots: + /** @brief Fetch the latest server notifications if no notification request is active. */ + void refresh(); + + /** @brief Refresh when a push event belongs to this manager's account. */ + void handlePushNotification(OCC::Account *account); + + /** @brief Dispatch an activity-model notification action. */ + void sendNotificationRequest(const QString &accountName, const QString &link, const QByteArray &verb, int row); + +private slots: + void buildNotificationDisplay(const OCC::ActivityList &list); + void buildIncomingCallDialogs(const OCC::ActivityList &list); + void notificationFetchFinished(); + void notificationRequestFinished(int statusCode); + void notifyNetworkError(QNetworkReply *reply); + void notifyServerFinished(const QString &reply, int replyCode); + +private: + /** @brief Present server notifications using the platform-specific delivery strategy. */ + void showServerNotifications(const ActivityList &activities); + void sendTalkReplyMessage(const QString &conversationToken, const QString &message, const QString &replyTo, + std::optional activityIndex = std::nullopt, bool logNativeReply = false); + void sendServerNotificationRequest(const QString &link, const QByteArray &verb, int row, qint64 notificationId); + void endNotificationRequest(int replyCode); + void checkNotifiedNotifications(); + [[nodiscard]] bool notificationAlreadyShown(qint64 notificationId); + [[nodiscard]] bool canShowNotification(qint64 notificationId); + + AccountStatePtr _accountState; + QPointer _activityModel; + QElapsedTimer _notificationHistoryTimer; + QSet _notifiedNotifications; + int _notificationRequestsRunning = 0; + bool _isNotificationFetchRunning = false; +}; + +} diff --git a/src/gui/tray/notificationhandler.cpp b/src/gui/notifications/servernotificationhandler.cpp similarity index 99% rename from src/gui/tray/notificationhandler.cpp rename to src/gui/notifications/servernotificationhandler.cpp index cf0624367a3ca..9ac3f8af4747f 100644 --- a/src/gui/tray/notificationhandler.cpp +++ b/src/gui/notifications/servernotificationhandler.cpp @@ -3,7 +3,7 @@ * SPDX-License-Identifier: GPL-2.0-or-later */ -#include "notificationhandler.h" +#include "servernotificationhandler.h" #include "accountstate.h" #include "capabilities.h" @@ -11,6 +11,7 @@ #include "iconjob.h" +#include #include #include diff --git a/src/gui/tray/notificationhandler.h b/src/gui/notifications/servernotificationhandler.h similarity index 54% rename from src/gui/tray/notificationhandler.h rename to src/gui/notifications/servernotificationhandler.h index db5c05959677a..270a42aff2e2b 100644 --- a/src/gui/tray/notificationhandler.h +++ b/src/gui/notifications/servernotificationhandler.h @@ -3,29 +3,41 @@ * SPDX-License-Identifier: GPL-2.0-or-later */ -#ifndef NOTIFICATIONHANDLER_H -#define NOTIFICATIONHANDLER_H +#ifndef SERVERNOTIFICATIONHANDLER_H +#define SERVERNOTIFICATIONHANDLER_H -#include +#include "tray/activitydata.h" -#include "usermodel.h" +#include +#include +#include +#include class QJsonDocument; namespace OCC { +class AccountState; +class JsonApiJob; + +/** @brief Fetches and parses the Notifications app payload for one account. */ class ServerNotificationHandler : public QObject { Q_OBJECT public: + /** @brief Create a one-shot server notification fetcher. */ explicit ServerNotificationHandler(AccountState *accountState, QObject *parent = nullptr); signals: + /** @brief Emitted with the parsed Notifications app entries. */ void newNotificationList(OCC::ActivityList); + /** @brief Emitted with recent incoming-call entries. */ void newIncomingCallsList(OCC::ActivityList); + /** @brief Emitted when the one-shot fetch is complete. */ void jobFinished(); public: + /** @brief Start the fetch when the account supports notifications. */ bool startFetchNotifications(); private slots: @@ -39,4 +51,4 @@ private slots: }; } -#endif // NOTIFICATIONHANDLER_H +#endif // SERVERNOTIFICATIONHANDLER_H diff --git a/src/gui/tray/talkreply.cpp b/src/gui/notifications/talkreply.cpp similarity index 97% rename from src/gui/tray/talkreply.cpp rename to src/gui/notifications/talkreply.cpp index 8e8a1a8d61a01..7903b37de0dd0 100644 --- a/src/gui/tray/talkreply.cpp +++ b/src/gui/notifications/talkreply.cpp @@ -3,7 +3,7 @@ * SPDX-License-Identifier: GPL-2.0-or-later */ -#include "talkreply.h" +#include "notifications/talkreply.h" #include "accountstate.h" #include "networkjobs.h" diff --git a/src/gui/tray/talkreply.h b/src/gui/notifications/talkreply.h similarity index 61% rename from src/gui/tray/talkreply.h rename to src/gui/notifications/talkreply.h index 66450ab989214..089497bfd3c76 100644 --- a/src/gui/tray/talkreply.h +++ b/src/gui/notifications/talkreply.h @@ -5,22 +5,26 @@ #pragma once -#include -#include +#include +#include namespace OCC { class AccountState; +/** @brief Sends an inline reply to a Nextcloud Talk notification. */ class TalkReply : public QObject { Q_OBJECT public: + /** @brief Create a Talk reply sender for an account. */ explicit TalkReply(AccountState *accountState, QObject *parent = nullptr); + /** @brief Send a message to a conversation, optionally as a reply to a message id. */ void sendReplyMessage(const QString &conversationToken, const QString &message, const QString &replyTo = {}); signals: + /** @brief Emitted with the server-returned message after a successful request. */ void replyMessageSent(const QString &message); private: diff --git a/src/gui/systray.cpp b/src/gui/systray.cpp index f1ac5d5d3109f..6c25acab4a21c 100644 --- a/src/gui/systray.cpp +++ b/src/gui/systray.cpp @@ -22,6 +22,9 @@ #ifdef Q_OS_MACOS #include "foregroundbackground_interface.h" +#if __MAC_OS_X_VERSION_MIN_REQUIRED >= MAC_OS_X_VERSION_10_14 +#include "notifications/macnotificationcenter.h" +#endif #endif #include @@ -140,9 +143,7 @@ Systray::Systray() : QSystemTrayIcon(nullptr) { #if defined(Q_OS_MACOS) && __MAC_OS_X_VERSION_MIN_REQUIRED >= MAC_OS_X_VERSION_10_14 && defined(BUILD_OWNCLOUD_OSX_BUNDLE) - setUserNotificationCenterDelegate(); - checkNotificationAuth(MacNotificationAuthorizationOptions::Default); // No provisional auth, ask user explicitly first time - registerNotificationCategories(QString(tr("Download"))); + MacNotificationCenter::initialize(tr("Download")); #elif !defined(Q_OS_MACOS) connect(AccountManager::instance(), &AccountManager::accountAdded, this, &Systray::setupContextMenu); @@ -316,6 +317,16 @@ void Systray::showActivitiesWindow(int userIndex) userModel->fetchActivityModel(targetUserId); } +void Systray::showActivitiesWindow(AccountState *accountState) +{ + const auto userId = UserModel::instance()->findUserIdForAccount(accountState); + if (userId < 0) { + qCWarning(lcSystray) << "Could not find account for activities window."; + return; + } + showActivitiesWindow(userId); +} + void Systray::showAssistantWindow(int userIndex) { const auto userModel = UserModel::instance(); @@ -1118,8 +1129,8 @@ void Systray::showMessage(const QString &title, const QString &message, MessageI } else #endif #if defined(Q_OS_MACOS) && __MAC_OS_X_VERSION_MIN_REQUIRED >= MAC_OS_X_VERSION_10_14 && defined(BUILD_OWNCLOUD_OSX_BUNDLE) - if (canOsXSendUserNotification()) { - sendOsXUserNotification(title, message); + if (MacNotificationCenter::canSendNotification()) { + MacNotificationCenter::sendNotification(title, message); } else #endif { @@ -1130,25 +1141,13 @@ void Systray::showMessage(const QString &title, const QString &message, MessageI void Systray::showUpdateMessage(const QString &title, const QString &message, const QUrl &webUrl) { #if defined(Q_OS_MACOS) && __MAC_OS_X_VERSION_MIN_REQUIRED >= MAC_OS_X_VERSION_10_14 && defined(BUILD_OWNCLOUD_OSX_BUNDLE) - sendOsXUpdateNotification(title, message, webUrl); + MacNotificationCenter::sendUpdateNotification(title, message, webUrl); #else // TODO: Implement custom notifications (i.e. actionable) for other OSes Q_UNUSED(webUrl); showMessage(title, message); #endif } -void Systray::showTalkMessage(const QString &title, const QString &message, const QString &token, const QString &replyTo, const AccountStatePtr &accountState) -{ -#if defined(Q_OS_MACOS) && __MAC_OS_X_VERSION_MIN_REQUIRED >= MAC_OS_X_VERSION_10_14 && 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 -} - bool Systray::syncIsPaused() const { return _syncIsPaused; diff --git a/src/gui/systray.h b/src/gui/systray.h index 29ae435439979..c57afab718443 100644 --- a/src/gui/systray.h +++ b/src/gui/systray.h @@ -34,20 +34,6 @@ class AccessManagerFactory : public QQmlNetworkAccessManagerFactory }; #ifdef Q_OS_MACOS -#if __MAC_OS_X_VERSION_MIN_REQUIRED >= MAC_OS_X_VERSION_10_14 -enum MacNotificationAuthorizationOptions { - Default = 0, - Provisional -}; - -void setUserNotificationCenterDelegate(); -void checkNotificationAuth(MacNotificationAuthorizationOptions authOptions = MacNotificationAuthorizationOptions::Provisional); -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); -#endif void setTrayWindowLevelAndVisibleOnAllSpaces(QWindow *window); double menuBarThickness(); void showMacOSTrayPopup(const QRect &iconRect); @@ -128,7 +114,6 @@ 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 OCC::AccountStatePtr &accountState); void createCallDialog(const OCC::Activity &callNotification, const OCC::AccountStatePtr accountState); void createEditFileLocallyLoadingDialog(const QString &fileName); @@ -154,6 +139,8 @@ public slots: void hideWindow(); void showQMLWindow(); void showActivitiesWindow(int userIndex = -1); + /** @brief Show the standalone Activities window for an account state. */ + void showActivitiesWindow(OCC::AccountState *accountState); void showAssistantWindow(int userIndex = -1); void showSearchWindow(int userIndex = -1); void showUserStatusWindow(int userIndex); diff --git a/src/gui/systray_mac_usernotifications.mm b/src/gui/systray_mac_usernotifications.mm deleted file mode 100644 index 76d6629c6d46b..0000000000000 --- a/src/gui/systray_mac_usernotifications.mm +++ /dev/null @@ -1,263 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: GPL-2.0-or-later - */ - -#include -#include -#include - -#import -#import - -#include "account.h" -#include "accountstate.h" -#include "accountmanager.h" -#include "config.h" -#include "systray.h" -#include "tray/talkreply.h" - -Q_LOGGING_CATEGORY(lcMacSystrayUserNotifications, "nextcloud.gui.macsystrayusernotifications") - -/************************* Private utility functions *************************/ - -namespace { - -void sendTalkReply(UNNotificationResponse *response, UNNotificationContent* content) -{ - if (!response || !content) { - qCWarning(lcMacSystrayUserNotifications) << "Invalid notification response or content." - << "Can't send talk reply."; - return; - } - - UNTextInputNotificationResponse * const textInputResponse = (UNTextInputNotificationResponse*)response; - - if (!textInputResponse) { - qCWarning(lcMacSystrayUserNotifications) << "Notification response was not a text input response." - << "Can't send talk reply."; - return; - } - - NSString * const reply = textInputResponse.userText; - NSString * const token = [content.userInfo objectForKey:@"token"]; - NSString * const account = [content.userInfo objectForKey:@"account"]; - NSString * const 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(lcMacSystrayUserNotifications) << "Could not find account matching" << qAccount - << "Can't send talk reply."; - return; - } - - qCDebug(lcMacSystrayUserNotifications) << "Sending talk reply from macOS notification." - << "Reply is:" << qReply - << "Replying to:" << qReplyTo - << "Token:" << qToken - << "Account:" << qAccount; - - // OCC::TalkReply deletes itself once it's done, fire and forget - const auto 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 - -// Always show, even if app is active at the moment. -- (void)userNotificationCenter:(UNUserNotificationCenter *)center - willPresentNotification:(UNNotification *)notification - withCompletionHandler:(void (^)(UNNotificationPresentationOptions options))completionHandler -{ -#if __MAC_OS_X_VERSION_MIN_REQUIRED >= MAC_OS_X_VERSION_11_0 - completionHandler(UNNotificationPresentationOptionSound + UNNotificationPresentationOptionBanner); -#else - completionHandler(UNNotificationPresentationOptionSound + UNNotificationPresentationOptionAlert); -#endif -} - -- (void)userNotificationCenter:(UNUserNotificationCenter *)center -didReceiveNotificationResponse:(UNNotificationResponse *)response - withCompletionHandler:(void (^)(void))completionHandler -{ - qCDebug(lcMacSystrayUserNotifications) << "Received notification with category identifier:" - << response.notification.request.content.categoryIdentifier - << "and action identifier" - << response.actionIdentifier; - - UNNotificationContent * const content = response.notification.request.content; - if ([content.categoryIdentifier isEqualToString:@"UPDATE"]) { - - if ([response.actionIdentifier isEqualToString:@"DOWNLOAD_ACTION"] || [response.actionIdentifier isEqualToString:UNNotificationDefaultActionIdentifier]) { - qCDebug(lcMacSystrayUserNotifications) << "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 - -/********************* Methods accessible to C++ Systray *********************/ - -namespace OCC { - -// TODO: Get this to actually check for permissions -bool canOsXSendUserNotification() -{ - UNUserNotificationCenter * const center = UNUserNotificationCenter.currentNotificationCenter; - return center != nil; -} - -void registerNotificationCategories(const QString &localisedDownloadString) { - UNNotificationCategory * const generalCategory = [UNNotificationCategory - categoryWithIdentifier:@"GENERAL" - actions:@[] - intentIdentifiers:@[] - options:UNNotificationCategoryOptionCustomDismissAction]; - - // Create the custom actions for update notifications. - UNNotificationAction * const downloadAction = [UNNotificationAction - actionWithIdentifier:@"DOWNLOAD_ACTION" - title:localisedDownloadString.toNSString() - options:UNNotificationActionOptionNone]; - - // Create the category with the custom actions. - UNNotificationCategory * const updateCategory = [UNNotificationCategory - categoryWithIdentifier:@"UPDATE" - actions:@[downloadAction] - intentIdentifiers:@[] - options:UNNotificationCategoryOptionNone]; - - // Create the custom action for talk notifications - UNTextInputNotificationAction * const 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 * const talkReplyCategory = [UNNotificationCategory - categoryWithIdentifier:@"TALK_MESSAGE" - actions:@[talkReplyAction] - intentIdentifiers:@[] - options:UNNotificationCategoryOptionNone]; - - [UNUserNotificationCenter.currentNotificationCenter setNotificationCategories:[NSSet setWithObjects:generalCategory, updateCategory, talkReplyCategory, nil]]; -} - -void checkNotificationAuth(MacNotificationAuthorizationOptions additionalAuthOption) -{ - UNUserNotificationCenter * const center = UNUserNotificationCenter.currentNotificationCenter; - UNAuthorizationOptions authOptions = UNAuthorizationOptionAlert + UNAuthorizationOptionSound; - - if(additionalAuthOption == MacNotificationAuthorizationOptions::Provisional) { - authOptions += UNAuthorizationOptionProvisional; - } - - [center requestAuthorizationWithOptions:(authOptions) completionHandler:^(BOOL granted, NSError * _Nullable error) { - // Enable or disable features based on authorization. - if (granted) { - qCDebug(lcMacSystrayUserNotifications) << "Authorization for notifications has been granted, can display notifications."; - } else { - qCDebug(lcMacSystrayUserNotifications) << "Authorization for notifications not granted."; - if (error) { - const auto errorDescription = QString::fromNSString(error.localizedDescription); - qCWarning(lcMacSystrayUserNotifications) << "Error from notification center: " << errorDescription; - } - } - }]; -} - -void setUserNotificationCenterDelegate() -{ - UNUserNotificationCenter * const center = UNUserNotificationCenter.currentNotificationCenter; - - static dispatch_once_t once; - dispatch_once(&once, ^{ - id delegate = [[NotificationCenterDelegate alloc] init]; - [center setDelegate:delegate]; - }); -} - -UNMutableNotificationContent* basicNotificationContent(const QString &title, const QString &message) -{ - UNMutableNotificationContent * const content = [[UNMutableNotificationContent alloc] init]; - content.title = title.toNSString(); - content.body = message.toNSString(); - content.sound = [UNNotificationSound defaultSound]; - - return content; -} - -void sendOsXUserNotification(const QString &title, const QString &message) -{ - UNUserNotificationCenter * const center = UNUserNotificationCenter.currentNotificationCenter; - checkNotificationAuth(); - - UNMutableNotificationContent * const content = basicNotificationContent(title, message); - content.categoryIdentifier = @"GENERAL"; - - UNTimeIntervalNotificationTrigger * const trigger = [UNTimeIntervalNotificationTrigger triggerWithTimeInterval:1 repeats:NO]; - UNNotificationRequest * const request = [UNNotificationRequest requestWithIdentifier:@"NCUserNotification" content:content trigger:trigger]; - - [center addNotificationRequest:request withCompletionHandler:nil]; -} - -void sendOsXUpdateNotification(const QString &title, const QString &message, const QUrl &webUrl) -{ - UNUserNotificationCenter * const center = UNUserNotificationCenter.currentNotificationCenter; - checkNotificationAuth(); - - UNMutableNotificationContent * const content = basicNotificationContent(title, message); - content.categoryIdentifier = @"UPDATE"; - content.userInfo = [NSDictionary dictionaryWithObject:[webUrl.toNSURL() absoluteString] forKey:@"webUrl"]; - - UNTimeIntervalNotificationTrigger * const trigger = [UNTimeIntervalNotificationTrigger triggerWithTimeInterval:1 repeats:NO]; - UNNotificationRequest * const request = [UNNotificationRequest requestWithIdentifier:@"NCUpdateNotification" content:content trigger:trigger]; - - [center addNotificationRequest:request withCompletionHandler:nil]; -} - -void sendOsXTalkNotification(const QString &title, const QString &message, const QString &token, const QString &replyTo, const AccountStatePtr accountState) -{ - UNUserNotificationCenter * const center = UNUserNotificationCenter.currentNotificationCenter; - checkNotificationAuth(); - - if (!accountState || !accountState->account()) { - sendOsXUserNotification(title, message); - return; - } - - NSString * const accountNS = accountState->account()->displayName().toNSString(); - NSString * const tokenNS = token.toNSString(); - NSString * const replyToNS = replyTo.toNSString(); - - UNMutableNotificationContent * const content = basicNotificationContent(title, message); - content.categoryIdentifier = @"TALK_MESSAGE"; - content.userInfo = [NSDictionary dictionaryWithObjects:@[accountNS, tokenNS, replyToNS] forKeys:@[@"account", @"token", @"replyTo"]]; - - UNTimeIntervalNotificationTrigger * const trigger = [UNTimeIntervalNotificationTrigger triggerWithTimeInterval:1 repeats:NO]; - UNNotificationRequest * const request = [UNNotificationRequest requestWithIdentifier:@"NCTalkMessageNotification" content:content trigger:trigger]; - - [center addNotificationRequest:request withCompletionHandler:nil]; -} - -} // OCC namespace diff --git a/src/gui/tray/activitydata.cpp b/src/gui/tray/activitydata.cpp index 10baf7bef401c..51792d8f300c6 100644 --- a/src/gui/tray/activitydata.cpp +++ b/src/gui/tray/activitydata.cpp @@ -75,6 +75,7 @@ OCC::Activity Activity::fromActivityJson(const QJsonObject &json, const AccountP const auto activityUser = json.value("user"_L1).toString(); Activity activity; + activity._app = json.value("app"_L1).toString(); activity._type = Activity::ActivityType; activity._objectType = json.value("object_type"_L1).toString(); activity._objectId = json.value("object_id"_L1).toInt(); @@ -320,6 +321,23 @@ QVariantList Activity::notificationPreviewActions() const return actions; } +Activity::PreviewText Activity::notificationPreviewText() const +{ + auto title = compactNotificationTitle(); + auto subtitle = QString{}; + + if (_objectType == QStringLiteral("chat") + && _subjectRichParameters.contains(QStringLiteral("user"))) { + const auto user = _subjectRichParameters.value(QStringLiteral("user")).value(); + if (!user.name.isEmpty() && !_message.isEmpty()) { + title = user.name; + subtitle = _message; + } + } + + return {title, subtitle}; +} + QString Activity::activitySubjectText() const { if (!_subjectDisplay.isEmpty()) { @@ -404,7 +422,7 @@ QString Activity::recentActivitySystemIconName() const return QStringLiteral("doc"); } -Activity::RecentActivityPreviewText Activity::recentActivityPreviewText() const +Activity::PreviewText Activity::recentActivityPreviewText() const { auto title = QString{}; auto subtitle = QString{}; diff --git a/src/gui/tray/activitydata.h b/src/gui/tray/activitydata.h index 85be7e6515e41..ea59cc56170bc 100644 --- a/src/gui/tray/activitydata.h +++ b/src/gui/tray/activitydata.h @@ -152,7 +152,8 @@ class Activity QString label; }; - struct RecentActivityPreviewText { + /** @brief Title and subtitle shared by compact activity presentation surfaces. */ + struct PreviewText { QString title; QString subtitle; }; @@ -182,6 +183,7 @@ class Activity Type _type; qint64 _id = 0LL; + QString _app; //!< Server app that created this activity or notification. QString _fileAction; int _objectId = 0; TalkNotificationData _talkNotificationData; @@ -228,6 +230,8 @@ class Activity [[nodiscard]] bool isDismissableActivity() const; [[nodiscard]] QVariantList notificationPreviewActions() const; + /** @brief Return the title and subtitle shown by notification preview surfaces. */ + [[nodiscard]] PreviewText notificationPreviewText() const; [[nodiscard]] QVector activityActionMetadata(const int maximumActionIndex = -1) const; @@ -237,7 +241,7 @@ class Activity [[nodiscard]] QString subjectWithoutRichParameter(const QString ¶meterKey) const; - [[nodiscard]] RecentActivityPreviewText recentActivityPreviewText() const; + [[nodiscard]] PreviewText recentActivityPreviewText() const; [[nodiscard]] QString compactNotificationTitle() const; diff --git a/src/gui/tray/activitylistmodel.cpp b/src/gui/tray/activitylistmodel.cpp index 3a8ecf54afbae..585f28bf7839a 100644 --- a/src/gui/tray/activitylistmodel.cpp +++ b/src/gui/tray/activitylistmodel.cpp @@ -492,30 +492,21 @@ QVariantList ActivityListModel::buildNotificationPreviewData() const auto notifications = QVariantList{}; for (const auto &candidate : std::as_const(candidates)) { - auto title = candidate.activity.compactNotificationTitle(); - auto subtitle = QString{}; + const auto previewText = candidate.activity.notificationPreviewText(); auto systemIconName = candidate.activity.recentActivitySystemIconName(); - if (candidate.activity._objectType == QStringLiteral("chat") - && candidate.activity._subjectRichParameters.contains(QStringLiteral("user"))) { - const auto user = candidate.activity._subjectRichParameters.value(QStringLiteral("user")).value(); - if (!user.name.isEmpty() && !candidate.activity._message.isEmpty()) { - title = user.name; - subtitle = candidate.activity._message; - } - } if (candidate.activity._objectType == QStringLiteral("chat") || candidate.activity._objectType == QStringLiteral("room")) { systemIconName = QStringLiteral("message"); } - if (title.isEmpty()) { + if (previewText.title.isEmpty()) { continue; } const auto modelIndex = index(candidate.row); const auto actions = candidate.activity.notificationPreviewActions(); notifications.push_back(QVariantMap{ - {QStringLiteral("title"), title}, - {QStringLiteral("subtitle"), subtitle}, + {QStringLiteral("title"), previewText.title}, + {QStringLiteral("subtitle"), previewText.subtitle}, {QStringLiteral("icon"), data(modelIndex, IconRole).toString()}, {QStringLiteral("systemIconName"), systemIconName}, {QStringLiteral("dateTime"), data(modelIndex, PointInTimeRole).toString()}, @@ -819,12 +810,28 @@ void ActivityListModel::removeActivityFromActivityList(const Activity &activity) } if (activityWasRemoved) { + if (activity._type == Activity::NotificationType) { + emit notificationRemoved(activity._id); + } setHasSyncConflicts(!_conflictsList.isEmpty()); refreshPreviewData(); emit activityListChanged(); } } +void ActivityListModel::removeNotificationFromActivityList(const qint64 notificationId) +{ + const auto it = std::find_if(_finalList.cbegin(), _finalList.cend(), [notificationId](const Activity &activity) { + return activity._type == Activity::NotificationType && activity._id == notificationId; + }); + if (it == _finalList.cend()) { + return; + } + + const auto activity = *it; + removeActivityFromActivityList(activity); +} + #ifdef BUILD_FILE_PROVIDER_MODULE qint64 ActivityListModel::fileProviderQuotaSummaryActivityId(const QString &domainIdentifier) { @@ -1176,6 +1183,11 @@ void ActivityListModel::slotRefreshActivityInitial() void ActivityListModel::slotRemoveAccount() { + for (const auto &activity : std::as_const(_finalList)) { + if (activity._type == Activity::NotificationType) { + emit notificationRemoved(activity._id); + } + } _finalList.clear(); _conflictsList.clear(); _activityLists.clear(); diff --git a/src/gui/tray/activitylistmodel.h b/src/gui/tray/activitylistmodel.h index 21512cad943a6..2d24c0251bbab 100644 --- a/src/gui/tray/activitylistmodel.h +++ b/src/gui/tray/activitylistmodel.h @@ -129,6 +129,8 @@ public slots: void addSyncFileItemToActivityList(const OCC::Activity &activity); void removeActivityFromActivityList(int row); void removeActivityFromActivityList(const OCC::Activity &activity); + /** @brief Remove a Notifications-app activity by its stable server identifier. */ + void removeNotificationFromActivityList(qint64 notificationId); void removeOutdatedNotifications(const OCC::ActivityList &receivedNotifications); @@ -160,6 +162,8 @@ public slots: void recentActivityPreviewDataChanged(); void notificationPreviewDataChanged(); void activityListChanged(); + /** @brief Emitted after a Notifications-app activity is removed from the model. */ + void notificationRemoved(qint64 notificationId); void activityJobStatusCode(int statusCode); void sendNotificationRequest(const QString &accountName, const QString &link, const QByteArray &verb, int row); diff --git a/src/gui/tray/usermodel.cpp b/src/gui/tray/usermodel.cpp index e7509fe416651..259fac0ba3d7b 100644 --- a/src/gui/tray/usermodel.cpp +++ b/src/gui/tray/usermodel.cpp @@ -3,7 +3,6 @@ * SPDX-License-Identifier: GPL-2.0-or-later */ -#include "notificationhandler.h" #include "usermodel.h" #include "common/filesystembase.h" @@ -13,16 +12,13 @@ #include "userstatusselectormodel.h" #include "syncengine.h" #include "syncresult.h" -#include "ocsjob.h" #include "configfile.h" -#include "notificationconfirmjob.h" -#include "logger.h" #include "guiutility.h" #include "syncresult.h" #include "syncfileitem.h" #include "systray.h" #include "tray/activitylistmodel.h" -#include "tray/talkreply.h" +#include "notifications/notificationmanager.h" #include "userstatusconnector.h" #include "common/utility.h" #include "ocsassistantconnector.h" @@ -56,8 +52,6 @@ constexpr qint64 activityDefaultExpirationTimeMsecs = 1000 * 60 * 10; constexpr qint64 assistantPollIntervalMsecs = 2000; constexpr int assistantSuccessMinStatusCode = 200; constexpr int assistantSuccessMaxStatusCode = 300; -constexpr auto debugCallNotificationEnvVar = "NEXTCLOUD_DEBUG_CALL_NOTIFICATION"; -constexpr auto debugCallNotificationAvatarEnvVar = "NEXTCLOUD_DEBUG_CALL_NOTIFICATION_AVATAR_URL"; QString assistantTaskTypeIdFromResponse(const QJsonDocument &json) { @@ -457,47 +451,6 @@ bool accountNeedsSandboxReapproval(const OCC::AccountStatePtr &accountState) return false; } -bool showDebugCallNotification(const OCC::AccountStatePtr &account) -{ - if (!qEnvironmentVariableIsSet(debugCallNotificationEnvVar)) { - return false; - } - - const auto systray = OCC::Systray::instance(); - if (!systray || !account || !account->account()) { - return true; - } - - OCC::Activity activity; - activity._id = -QDateTime::currentMSecsSinceEpoch(); - activity._objectType = QStringLiteral("call"); - activity._subject = QStringLiteral("Iva Horn would like to talk with you"); - activity._shouldNotify = true; - activity._dateTime = QDateTime::currentDateTime(); - activity._accName = account->account()->displayName(); - activity._talkNotificationData.conversationToken = QStringLiteral("debug-call"); - - const auto avatarUrl = qEnvironmentVariable(debugCallNotificationAvatarEnvVar); - if (!avatarUrl.isEmpty()) { - activity._talkNotificationData.userAvatar = avatarUrl; - } else if (!account->account()->url().isEmpty() && !account->account()->davUser().isEmpty()) { - activity._talkNotificationData.userAvatar = account->account()->url().toString() - + QStringLiteral("/index.php/avatar/") - + account->account()->davUser() - + QStringLiteral("/128"); - } - - OCC::ActivityLink answer; - answer._label = QObject::tr("Answer"); - answer._verb = "WEB"; - answer._link = account->account()->url().toString(); - answer._primary = true; - activity._links.append(answer); - - systray->createCallDialog(activity, account); - return true; -} - } // namespace namespace OCC { @@ -520,6 +473,7 @@ User::User(AccountStatePtr &account, const bool &isCurrent, QObject *parent) , _account(account) , _isCurrentUser(isCurrent) , _activityModel(new ActivityListModel(_account.data(), this)) + , _notificationManager(new NotificationManager(_account, _activityModel, this)) { connect(ProgressDispatcher::instance(), &ProgressDispatcher::progressInfo, this, &User::slotProgressInfo); @@ -530,7 +484,7 @@ User::User(AccountStatePtr &account, const bool &isCurrent, QObject *parent) connect(ProgressDispatcher::instance(), &ProgressDispatcher::addErrorToGui, this, &User::slotAddErrorToGui); - connect(&_notificationCheckTimer, &QTimer::timeout, + connect(&_refreshTimer, &QTimer::timeout, this, &User::slotRefresh); connect(&_expiredActivitiesCheckTimer, &QTimer::timeout, @@ -559,14 +513,12 @@ User::User(AccountStatePtr &account, const bool &isCurrent, QObject *parent) connect(_account->account().data(), &Account::capabilitiesChanged, this, &User::slotAccountCapabilitiesChangedRefreshGroupFolders); - connect(_activityModel, &ActivityListModel::sendNotificationRequest, this, &User::slotSendNotificationRequest); connect(_activityModel, &ActivityListModel::showSettingsDialog, Systray::instance(), &Systray::openSettings); connect(_activityModel, &ActivityListModel::hasSyncConflictsChanged, this, &User::refreshAccountAlert); connect(_activityModel, &ActivityListModel::recentActivityPreviewDataChanged, this, &User::recentActivitiesChanged); connect(_activityModel, &ActivityListModel::notificationPreviewDataChanged, this, &User::trayNotificationsChanged); connect(_activityModel, &ActivityListModel::activityListChanged, this, &User::refreshAccountAlert); - connect(this, &User::sendReplyMessage, this, &User::slotSendReplyMessage); connect(_account->account().data(), &Account::userCertificateNeedsMigrationChanged, this, [this] () { @@ -582,7 +534,7 @@ User::User(AccountStatePtr &account, const bool &isCurrent, QObject *parent) if (_account->account()->e2e()->userCertificateNeedsMigration()) { _activityModel->addNotificationToActivityList(certificateNeedMigration); - showDesktopNotification(certificateNeedMigration); + _notificationManager->showNotification(certificateNeedMigration); } }); @@ -625,181 +577,11 @@ User::User(AccountStatePtr &account, const bool &isCurrent, QObject *parent) #endif } -void User::checkNotifiedNotifications() -{ - // clear the gui log notification store after one hour has passed since the last received notification - constexpr qint64 clearGuiLogInterval = 60 * 60 * 1000; - if (_guiLogTimer.elapsed() > clearGuiLogInterval) { - _notifiedNotifications.clear(); - } -} - -bool User::notificationAlreadyShown(const qint64 notificationId) -{ - checkNotifiedNotifications(); - return _notifiedNotifications.contains(notificationId); -} - -bool User::canShowNotification(const qint64 notificationId) -{ - ConfigFile cfg; - return cfg.optionalServerNotifications() && - isDesktopNotificationsAllowed() && - !notificationAlreadyShown(notificationId); -} - -void User::showDesktopNotification(const QString &title, const QString &message, const qint64 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; - - // the user needs to interact with this notification - if (activity._links.size() > 0) { - _activityModel->addNotificationToActivityList(activity); - } - - showDesktopNotification(activity._subject, message, notificationId); -} - -void User::showDesktopNotification(const ActivityList &activityList) -{ - const auto subject = tr("%n notification(s)", nullptr, 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 - // error notification. - // - // To ensure that we can still treat an unsigned int as normal, we use a long, which is 8 bytes. - - Logger::instance()->postGuiLog(subject, message); - - for (const auto &activity : activityList) { - _notifiedNotifications.insert(activity._id); - _activityModel->addNotificationToActivityList(activity); - } -} - -void User::showDesktopTalkNotification(const Activity &activity) -{ - const auto notificationId = activity._id; - - if (!canShowNotification(notificationId) || !ConfigFile().showChatNotifications()) { - return; - } - - if (activity._talkNotificationData.messageId.isEmpty()) { - showDesktopNotification(activity._subject, activity._message, notificationId); - return; - } - - _notifiedNotifications.insert(notificationId); - _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) -{ - ActivityList toNotifyList; - - _activityModel->removeOutdatedNotifications(list); - - std::copy_if(list.constBegin(), list.constEnd(), std::back_inserter(toNotifyList), [&](const Activity &activity) -> bool { - if (!activity._shouldNotify) { - qCDebug(lcActivity).nospace() << "No notification should be sent for activity with id=" << activity._id << " objectType=" << activity._objectType; - return false; - } - - if (_notifiedNotifications.contains(activity._id)) { - qCInfo(lcActivity).nospace() << "Ignoring already notified activity with id=" << activity._id << " objectType=" << activity._objectType; - return false; - } - - return true; - }); - - if (toNotifyList.isEmpty()) { - return; - } - - if (toNotifyList.size() == 1) { - const auto &activity = toNotifyList.constFirst(); - if (activity._objectType == QStringLiteral("chat")) { - // Talk's "call" type is handled in slotBuildIncomingCallDialogs - showDesktopTalkNotification(activity); - return; - } - - showDesktopNotification(activity); - return; - } - - showDesktopNotification(toNotifyList); -} - -void User::slotNotificationFetchFinished() -{ - _isNotificationFetchRunning = false; -} - -void User::slotBuildIncomingCallDialogs(const ActivityList &list) -{ - const ConfigFile cfg; - const auto userStatus = _account->account()->userStatusConnector()->userStatus().state(); - if (userStatus == OCC::UserStatus::OnlineStatus::DoNotDisturb || - !cfg.optionalServerNotifications() || - !cfg.showCallNotifications() || - !isDesktopNotificationsAllowed()) { - return; - } - - const auto systray = Systray::instance(); - if (!systray) { - qCWarning(lcActivity) << "No systray instance available, can not notify about new calls"; - return; - } - - for (const auto &activity : list) { - if (!activity._shouldNotify) { - qCDebug(lcActivity).nospace() << "No notification should be sent for activity with id=" << activity._id << " objectType=" << activity._objectType; - continue; - } - - systray->createCallDialog(activity, _account); - } -} - -void User::setNotificationRefreshInterval(std::chrono::milliseconds interval) +void User::setRefreshInterval(std::chrono::milliseconds interval) { if (!checkPushNotificationsAreReady()) { - qCDebug(lcActivity) << "Starting Notification refresh timer with " << interval.count() / 1000 << " sec interval"; - _notificationCheckTimer.start(interval.count()); + qCDebug(lcActivity) << "Starting account refresh timer with" << interval.count() / 1000 << "sec interval"; + _refreshTimer.start(interval.count()); } } @@ -807,9 +589,9 @@ void User::slotPushNotificationsReady() { qCInfo(lcActivity) << "Push notifications are ready."; - if (_notificationCheckTimer.isActive()) { + if (_refreshTimer.isActive()) { // as we are now able to use push notifications - let's stop the polling timer - _notificationCheckTimer.stop(); + _refreshTimer.stop(); } connectPushNotifications(); @@ -817,7 +599,8 @@ void User::slotPushNotificationsReady() void User::slotDisconnectPushNotifications() { - disconnect(_account->account()->pushNotifications(), &PushNotifications::notificationsChanged, this, &User::slotReceivedPushNotification); + disconnect(_account->account()->pushNotifications(), &PushNotifications::notificationsChanged, + _notificationManager, &NotificationManager::handlePushNotification); disconnect(_account->account()->pushNotifications(), &PushNotifications::activitiesChanged, this, &User::slotReceivedPushActivity); disconnect(_account->account()->pushNotifications(), &PushNotifications::filesChanged, this, &User::slotReceivedPushFilesChanges); disconnect(_account->account()->pushNotifications(), &PushNotifications::fileIdsChanged, this, &User::slotReceivedPushFileIdsChanges); @@ -825,7 +608,7 @@ void User::slotDisconnectPushNotifications() disconnect(_account->account().data(), &Account::pushNotificationsDisabled, this, &User::slotDisconnectPushNotifications); // connection to WebSocket may have dropped or an error occurred, so we need to bring back the polling until we have re-established the connection - setNotificationRefreshInterval(ConfigFile().notificationRefreshInterval()); + setRefreshInterval(ConfigFile().notificationRefreshInterval()); } void User::slotReceivedPushFilesChanges(Account *account) @@ -864,13 +647,6 @@ void User::slotReceivedPushFileIdsChanges(Account *account, const QList #endif } -void User::slotReceivedPushNotification(Account *account) -{ - if (account->id() == _account->account()->id()) { - slotRefreshNotifications(); - } -} - void User::slotReceivedPushActivity(Account *account) { if (account->id() == _account->account()->id()) { @@ -1096,7 +872,8 @@ void User::connectPushNotifications() const { connect(_account->account().data(), &Account::pushNotificationsDisabled, this, &User::slotDisconnectPushNotifications, Qt::UniqueConnection); - connect(_account->account()->pushNotifications(), &PushNotifications::notificationsChanged, this, &User::slotReceivedPushNotification, Qt::UniqueConnection); + connect(_account->account()->pushNotifications(), &PushNotifications::notificationsChanged, + _notificationManager, &NotificationManager::handlePushNotification, Qt::UniqueConnection); connect(_account->account()->pushNotifications(), &PushNotifications::activitiesChanged, this, &User::slotReceivedPushActivity, Qt::UniqueConnection); connect(_account->account()->pushNotifications(), &PushNotifications::filesChanged, this, &User::slotReceivedPushFilesChanges, Qt::UniqueConnection); connect(_account->account()->pushNotifications(), &PushNotifications::fileIdsChanged, this, &User::slotReceivedPushFileIdsChanges, Qt::UniqueConnection); @@ -1124,7 +901,7 @@ void User::slotRefreshImmediately() { if (_account.data() && _account.data()->isConnected() && Systray::instance()->isActivitySurfaceVisible()) { slotRefreshActivities(); } - slotRefreshNotifications(); + _notificationManager->refresh(); } void User::slotRefresh() @@ -1151,7 +928,7 @@ void User::slotRefresh() } if (_account.data() && _account.data()->isConnected()) { slotRefreshActivities(); - slotRefreshNotifications(); + _notificationManager->refresh(); timer.start(); } } @@ -1197,33 +974,6 @@ void User::slotRefreshUserStatus() } } -void User::slotRefreshNotifications() -{ - static auto debugCallNotificationShown = false; - if (!debugCallNotificationShown) { - debugCallNotificationShown = showDebugCallNotification(_account); - } - - // start a server notification handler if no notification requests - // are running - if (_notificationRequestsRunning == 0) { - if (_isNotificationFetchRunning) { - qCDebug(lcActivity) << "Notification fetch is already running."; - return; - } - auto *snh = new ServerNotificationHandler(_account.data()); - connect(snh, &ServerNotificationHandler::newNotificationList, - this, &User::slotBuildNotificationDisplay); - connect(snh, &ServerNotificationHandler::newIncomingCallsList, - this, &User::slotBuildIncomingCallDialogs); - connect(snh, &ServerNotificationHandler::jobFinished, - this, &User::slotNotificationFetchFinished); - _isNotificationFetchRunning = snh->startFetchNotifications(); - } else { - qCWarning(lcActivity) << "Notification request counter not zero."; - } -} - void User::slotRebuildNavigationAppList() { emit featuredAppChanged(); @@ -1231,83 +981,6 @@ void User::slotRebuildNavigationAppList() UserAppsModel::instance()->buildAppList(); } -void User::slotNotificationRequestFinished(int statusCode) -{ - int row = sender()->property("activityRow").toInt(); - - // the ocs API returns stat code 100 or 200 or 202 inside the xml if it succeeded. - if (statusCode != OCS_SUCCESS_STATUS_CODE - && statusCode != OCS_SUCCESS_STATUS_CODE_V2 - && statusCode != OCS_ACCEPTED_STATUS_CODE) { - qCWarning(lcActivity) << "Notification Request to Server failed, leave notification visible."; - } else { - // to do use the model to rebuild the list or remove the item - qCWarning(lcActivity) << "Notification Request to Server succeeded, rebuilding list."; - _activityModel->removeActivityFromActivityList(row); - } -} - -void User::slotEndNotificationRequest(int replyCode) -{ - _notificationRequestsRunning--; - slotNotificationRequestFinished(replyCode); -} - -void User::slotSendNotificationRequest(const QString &accountName, const QString &link, const QByteArray &verb, int row) -{ - qCInfo(lcActivity) << "Server Notification Request " << verb << link << "on account" << accountName; - - const QStringList validVerbs = QStringList() << "GET" - << "PUT" - << "POST" - << "DELETE"; - - if (validVerbs.contains(verb)) { - AccountStatePtr acc = AccountManager::instance()->account(accountName); - if (acc) { - auto *job = new NotificationConfirmJob(acc->account()); - QUrl l(link); - job->setLinkAndVerb(l, verb); - job->setProperty("activityRow", QVariant::fromValue(row)); - connect(job, &AbstractNetworkJob::networkError, - this, &User::slotNotifyNetworkError); - connect(job, &NotificationConfirmJob::jobFinished, - this, &User::slotNotifyServerFinished); - job->start(); - - // count the number of running notification requests. If this member var - // is larger than zero, no new fetching of notifications is started - _notificationRequestsRunning++; - } - } else { - qCWarning(lcActivity) << "Notification Links: Invalid verb:" << verb; - } -} - -void User::slotNotifyNetworkError(QNetworkReply *reply) -{ - auto *job = qobject_cast(sender()); - if (!job) { - return; - } - - int resultCode = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); - - slotEndNotificationRequest(resultCode); - qCWarning(lcActivity) << "Server notify job failed with code " << resultCode; -} - -void User::slotNotifyServerFinished(const QString &reply, int replyCode) -{ - auto *job = qobject_cast(sender()); - if (!job) { - return; - } - - slotEndNotificationRequest(replyCode); - qCInfo(lcActivity) << "Server Notification reply code" << replyCode << reply; -} - void User::slotProgressInfo(const QString &folder, const ProgressInfo &progress) { if (progress.status() == ProgressInfo::Reconcile) { @@ -1465,7 +1138,7 @@ void User::slotAddErrorToGui(const QString &folderAlias, const SyncFileItem::Sta _activityModel->addErrorToActivityList(activity, errorType); - showDesktopNotification(activity); + _notificationManager->showNotification(activity); if (!_expiredActivitiesCheckTimer.isActive()) { _expiredActivitiesCheckTimer.start(expiredActivitiesCheckIntervalMsecs); @@ -1475,12 +1148,7 @@ void User::slotAddErrorToGui(const QString &folderAlias, const SyncFileItem::Sta void User::slotAddNotification(const Folder *folder, const Activity &activity) { - if (!isActivityOfCurrentAccount(folder) || _notifiedNotifications.contains(activity._id)) { - return; - } - - _notifiedNotifications.insert(activity._id); - _activityModel->addNotificationToActivityList(activity); + _notificationManager->addNotification(folder, activity); } bool User::isActivityOfCurrentAccount(const Folder *folder) const @@ -1593,7 +1261,7 @@ void User::processCompletedSyncItem(const Folder *folder, const SyncFileItemPtr activity._links = {buttonActivityLink}; - showDesktopNotification(item->_file, activity._subject, activity._id); + _notificationManager->showNotification(item->_file, activity._subject, activity._id); } else if (item->_status == SyncFileItem::Conflict || item->_status == SyncFileItem::FileNameClash) { ActivityLink buttonActivityLink; buttonActivityLink._label = tr("Resolve conflict"); @@ -1666,6 +1334,11 @@ ActivityListModel *User::getActivityModel() return _activityModel; } +NotificationManager *User::notificationManager() const +{ + return _notificationManager; +} + QVariantList User::recentActivities() const { return _activityModel->recentActivityPreviewData(); @@ -2019,11 +1692,7 @@ void User::removeAccount() const void User::slotSendReplyMessage(const int activityIndex, const QString &token, const QString &message, const QString &replyTo) { - QPointer talkReply = new TalkReply(_account.data(), this); - talkReply->sendReplyMessage(token, message, replyTo); - connect(talkReply, &TalkReply::replyMessageSent, this, [&, activityIndex](const QString &message) { - _activityModel->setReplyMessageSent(activityIndex, message); - }); + _notificationManager->sendTalkReply(activityIndex, token, message, replyTo); } void User::submitAssistantQuestion(const QString &question) @@ -2371,7 +2040,7 @@ void User::slotQuotaChanged(const int64_t &usedBytes, const int64_t &availableBy _lastQuotaActivity._subject = tr("Quota Warning - %1 percent or more storage in use").arg(QString::number(thresholdPassed)); _lastQuotaActivity._accName = account()->displayName(); _lastQuotaActivity._id = qHash(QDateTime::currentMSecsSinceEpoch()); - showDesktopNotification(_lastQuotaActivity); + _notificationManager->showNotification(_lastQuotaActivity); _activityModel->addNotificationToActivityList(_lastQuotaActivity); } _lastQuotaPercent = percentInt; @@ -2679,7 +2348,7 @@ void UserModel::addUser(AccountStatePtr &user, const bool &isCurrent) } ConfigFile cfg; - u->setNotificationRefreshInterval(cfg.notificationRefreshInterval()); + u->setRefreshInterval(cfg.notificationRefreshInterval()); } updateSyncErrorUsers(); @@ -2977,24 +2646,6 @@ void UserModel::fetchActivityPreview(const int id) _users[id]->slotRefreshActivityPreview(); } -void UserModel::dismissNotification(const int id, const int activityIndex) -{ - if (id < 0 || id >= _users.size()) { - return; - } - - _users[id]->getActivityModel()->slotTriggerDismiss(activityIndex); -} - -void UserModel::triggerNotificationAction(const int id, const int activityIndex, const int actionIndex) -{ - if (id < 0 || id >= _users.size()) { - return; - } - - _users[id]->getActivityModel()->slotTriggerAction(activityIndex, actionIndex); -} - AccountAppList UserModel::appList() const { if (_currentUserId < 0 || _currentUserId >= _users.size()) diff --git a/src/gui/tray/usermodel.h b/src/gui/tray/usermodel.h index 4e52612d11a9c..b33dd8c779594 100644 --- a/src/gui/tray/usermodel.h +++ b/src/gui/tray/usermodel.h @@ -30,6 +30,7 @@ namespace OCC { class OcsAssistantConnector; +class NotificationManager; class TrayFolderInfo @@ -103,6 +104,8 @@ class User : public QObject void setCurrentUser(const bool &isCurrent); [[nodiscard]] Folder *getFolder() const; ActivityListModel *getActivityModel(); + /** @brief Return the account-scoped notification feature manager. */ + [[nodiscard]] NotificationManager *notificationManager() const; void openLocalFolder() const; #ifdef BUILD_FILE_PROVIDER_MODULE void openFileProviderDomain() const; @@ -184,22 +187,14 @@ public slots: void slotAddError(const QString &folderAlias, const QString &message, OCC::ErrorCategory category); void slotAddErrorToGui(const QString &folderAlias, const OCC::SyncFileItem::Status status, const QString &errorMessage, const QString &subject, const OCC::ErrorCategory category); void slotAddNotification(const OCC::Folder *folder, const OCC::Activity &activity); - void slotNotificationRequestFinished(int statusCode); - void slotNotifyNetworkError(QNetworkReply *reply); - void slotEndNotificationRequest(int replyCode); - void slotNotifyServerFinished(const QString &reply, int replyCode); - void slotSendNotificationRequest(const QString &accountName, const QString &link, const QByteArray &verb, int row); - void slotBuildNotificationDisplay(const OCC::ActivityList &list); - void slotNotificationFetchFinished(); - void slotBuildIncomingCallDialogs(const OCC::ActivityList &list); - void slotRefreshNotifications(); void slotRefreshActivitiesInitial(); void slotRefreshActivityPreview(); void slotRefreshActivities(); void slotRefresh(); void slotRefreshUserStatus(); void slotRefreshImmediately(); - void setNotificationRefreshInterval(std::chrono::milliseconds interval); + /** @brief Set the fallback polling interval used without push notifications. */ + void setRefreshInterval(std::chrono::milliseconds interval); void slotRebuildNavigationAppList(); void slotSendReplyMessage(const int activityIndex, const QString &conversationToken, const QString &message, const QString &replyTo); void forceSyncNow() const; @@ -235,7 +230,6 @@ private slots: void slotDisconnectPushNotifications(); void slotReceivedPushFilesChanges(Account *account); void slotReceivedPushFileIdsChanges(Account *account, const QList &fileIds); - void slotReceivedPushNotification(OCC::Account *account); void slotReceivedPushActivity(OCC::Account *account); void slotCheckExpiredActivities(); void slotGroupFoldersFetched(QNetworkReply *reply); @@ -246,12 +240,6 @@ private slots: void slotAssistantTaskScheduled(const QJsonDocument &json, int statusCode); void slotAssistantTaskDeleted(int statusCode); void slotAssistantRequestError(const QString &context, int statusCode); - void checkNotifiedNotifications(); - void showDesktopNotification(const QString &title, const QString &message, const qint64 notificationId); - void showDesktopNotification(const OCC::Activity &activity); - void showDesktopNotification(const OCC::ActivityList &activityList); - void showDesktopTalkNotification(const OCC::Activity &activity); - private: void prePendGroupFoldersWithLocalFolder(); void parseNewGroupFolderPath(const QString &path); @@ -264,26 +252,21 @@ private slots: [[nodiscard]] QVariantMap buildAccountAlert() const; void refreshAccountAlert(); - bool notificationAlreadyShown(const qint64 notificationId); - bool canShowNotification(const qint64 notificationId); - [[nodiscard]] bool serverHasTalk() const; AccountStatePtr _account; bool _isCurrentUser; ActivityListModel *_activityModel; + NotificationManager *_notificationManager; QVariantMap _accountAlert; QVariantList _trayFolderInfos; QTimer _expiredActivitiesCheckTimer; - QTimer _notificationCheckTimer; + QTimer _refreshTimer; QHash _timeSinceLastCheck; QElapsedTimer _timeSinceLastActivityPreviewCheck; - QElapsedTimer _guiLogTimer; - QSet _notifiedNotifications; - QSet _activeNotifications; #ifdef BUILD_FILE_PROVIDER_MODULE /// Rate-limit per relativePath so repeated bundle drops don't spam the activity view. /// Cleared by the existing `_expiredActivitiesCheckTimer` tick. @@ -303,14 +286,6 @@ private slots: #endif QMimeDatabase _mimeDb; - // number of currently running notification requests. If non zero, - // no query for notifications is started. - int _notificationRequestsRunning = 0; - - int _lastTalkNotificationsReceivedCount = 0; - - bool _isNotificationFetchRunning = false; - // used for quota warnings int _lastQuotaPercent = 0; Activity _lastQuotaActivity; @@ -411,8 +386,6 @@ public slots: void fetchCurrentActivityModel(); Q_INVOKABLE void fetchActivityModel(int id); Q_INVOKABLE void fetchActivityPreview(int id); - Q_INVOKABLE void dismissNotification(int id, int activityIndex); - Q_INVOKABLE void triggerNotificationAction(int id, int activityIndex, int actionIndex); void openCurrentAccountLocalFolder(); #ifdef BUILD_FILE_PROVIDER_MODULE void openCurrentAccountFileProviderDomain(); diff --git a/src/gui/trayaccountpopup_qt.cpp b/src/gui/trayaccountpopup_qt.cpp index 8b80b8dda14f1..4e404d5c8eef6 100644 --- a/src/gui/trayaccountpopup_qt.cpp +++ b/src/gui/trayaccountpopup_qt.cpp @@ -8,6 +8,7 @@ #include "accountmanager.h" #include "accountstate.h" #include "iconjob.h" +#include "notifications/notificationmanager.h" #include "theme.h" #include "tray/trayaccountappsmodel.h" #include "tray/trayimageutils.h" @@ -601,8 +602,13 @@ void populateNotificationActionsMenu(QMenu *menu, const int userId, const int ac const auto actionIndex = actionData.value(QStringLiteral("actionIndex")).toInt(); const auto action = addMenuAction(menu, QIcon{}, title); QObject::connect(action, &QAction::triggered, action, [userId, activityIndex, actionType, actionIndex] { + const auto user = UserModel::instance()->user(userId); + const auto notificationManager = user ? user->notificationManager() : nullptr; + if (!notificationManager) { + return; + } if (actionType == QStringLiteral("dismiss")) { - UserModel::instance()->dismissNotification(userId, activityIndex); + notificationManager->dismissNotification(activityIndex); return; } if (actionType == QStringLiteral("openActivities")) { @@ -610,7 +616,7 @@ void populateNotificationActionsMenu(QMenu *menu, const int userId, const int ac return; } closeTrayPopup(); - UserModel::instance()->triggerNotificationAction(userId, activityIndex, actionIndex); + notificationManager->triggerNotificationAction(activityIndex, actionIndex); }); } } diff --git a/test/activitylistmodeltestutils.cpp b/test/activitylistmodeltestutils.cpp index 197fa0317cc1a..35cd102913151 100644 --- a/test/activitylistmodeltestutils.cpp +++ b/test/activitylistmodeltestutils.cpp @@ -531,7 +531,8 @@ void TestingALM::slotProcessReceivedActivities() const auto activityJsonObject = FakeRemoteActivityStorage::instance()->activityById(activity._id); if (!activityJsonObject.isNull()) { - // because "_links" are normally populated within the notificationhandler.cpp, which we don't run as part of this unit test, we have to fill them here + // Because "_links" are normally populated within servernotificationhandler.cpp, + // which we don't run as part of this unit test, we have to fill them here. // TODO: move the logic to populate "_links" to "activitylistmodel.cpp" const auto actions = activityJsonObject.toObject().value("actions").toArray(); for (const auto &action : actions) { diff --git a/test/testtalkreply.cpp b/test/testtalkreply.cpp index dd8ca9b760e16..eaec50c7779a1 100644 --- a/test/testtalkreply.cpp +++ b/test/testtalkreply.cpp @@ -3,7 +3,7 @@ * SPDX-License-Identifier: GPL-2.0-or-later */ -#include "tray/talkreply.h" +#include "notifications/talkreply.h" #include "account.h" #include "accountstate.h"