From 07e8f64903a6d4c496c7eb53f95325c92a427286 Mon Sep 17 00:00:00 2001 From: Joe Donofry Date: Fri, 4 Nov 2022 16:42:09 +0000 Subject: [PATCH] Add ability to respond to notifications on macOS --- .clang-format | 3 + CMakeLists.txt | 4 +- src/ChatPage.cpp | 24 +- src/ChatPage.h | 1 + src/main.cpp | 5 + src/notifications/MacNotificationDelegate.h | 20 ++ src/notifications/MacNotificationDelegate.mm | 47 ++++ src/notifications/Manager.h | 8 +- src/notifications/ManagerMac.cpp | 37 ++- src/notifications/ManagerMac.mm | 235 ++++++++++++------- src/notifications/NotificationManagerProxy.h | 22 ++ 11 files changed, 307 insertions(+), 99 deletions(-) create mode 100644 src/notifications/MacNotificationDelegate.h create mode 100644 src/notifications/MacNotificationDelegate.mm create mode 100644 src/notifications/NotificationManagerProxy.h diff --git a/.clang-format b/.clang-format index e9ebcb6d..adb43177 100644 --- a/.clang-format +++ b/.clang-format @@ -13,3 +13,6 @@ KeepEmptyLinesAtTheStartOfBlocks: false PointerAlignment: Right Cpp11BracedListStyle: true PenaltyReturnTypeOnItsOwnLine: 0 +--- +BasedOnStyle: WebKit +Language: ObjC \ No newline at end of file diff --git a/CMakeLists.txt b/CMakeLists.txt index 153f8cb9..37ee6feb 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -629,9 +629,9 @@ set(TRANSLATION_DEPS ${LANG_QRC} ${QRC} ${QM_SRC}) if (APPLE) set(CMAKE_EXE_LINKER_FLAGS "${CMAKE_EXE_LINKER_FLAGS} -framework Foundation -framework Cocoa -framework UserNotifications") - set(SRC_FILES ${SRC_FILES} src/notifications/ManagerMac.mm src/notifications/ManagerMac.cpp src/emoji/MacHelper.mm src/emoji/MacHelper.h) + set(SRC_FILES ${SRC_FILES} src/notifications/NotificationManagerProxy.h src/notifications/MacNotificationDelegate.h src/notifications/MacNotificationDelegate.mm src/notifications/ManagerMac.mm src/notifications/ManagerMac.cpp src/emoji/MacHelper.mm src/emoji/MacHelper.h) if(${CMAKE_VERSION} VERSION_GREATER_EQUAL "3.16.0") - set_source_files_properties( src/notifications/ManagerMac.mm src/emoji/MacHelper.mm src/emoji/MacHelper.h PROPERTIES SKIP_PRECOMPILE_HEADERS ON) + set_source_files_properties( src/notifications/NotificationManagerProxy.h src/notifications/MacNotificationDelegate.h src/notifications/MacNotificationDelegate.mm src/notifications/ManagerMac.mm src/emoji/MacHelper.mm src/emoji/MacHelper.h PROPERTIES SKIP_PRECOMPILE_HEADERS ON) endif() elseif (WIN32) file(DOWNLOAD diff --git a/src/ChatPage.cpp b/src/ChatPage.cpp index e40274cb..f87c2738 100644 --- a/src/ChatPage.cpp +++ b/src/ChatPage.cpp @@ -152,16 +152,7 @@ ChatPage::ChatPage(QSharedPointer userSettings, QObject *parent) connect(notificationsManager, &NotificationsManager::sendNotificationReply, this, - [this](const QString &roomid, const QString &eventid, const QString &body) { - view_manager_->queueReply(roomid, eventid, body); - auto exWin = MainWindow::instance()->windowForRoom(roomid); - if (exWin) { - exWin->requestActivate(); - } else { - view_manager_->rooms()->setCurrentRoom(roomid); - MainWindow::instance()->requestActivate(); - } - }); + &ChatPage::sendNotificationReply); connect( this, @@ -1583,6 +1574,19 @@ ChatPage::handleMatrixUri(QString uri) return false; } +void +ChatPage::sendNotificationReply(const QString &roomid, const QString &eventid, const QString &body) +{ + view_manager_->queueReply(roomid, eventid, body); + auto exWin = MainWindow::instance()->windowForRoom(roomid); + if (exWin) { + exWin->requestActivate(); + } else { + view_manager_->rooms()->setCurrentRoom(roomid); + MainWindow::instance()->requestActivate(); + } +} + bool ChatPage::handleMatrixUri(const QUrl &uri) { diff --git a/src/ChatPage.h b/src/ChatPage.h index 1bb25dc2..bae4401f 100644 --- a/src/ChatPage.h +++ b/src/ChatPage.h @@ -105,6 +105,7 @@ public slots: void receivedSessionKey(const std::string &room_id, const std::string &session_id); void decryptDownloadedSecrets(mtx::secret_storage::AesHmacSha2KeyDescription keyDesc, const SecretsToDecrypt &secrets); + void sendNotificationReply(const QString &roomid, const QString &eventid, const QString &body); signals: void connectionLost(); void connectionRestored(); diff --git a/src/main.cpp b/src/main.cpp index 3937c6b7..d1b4b769 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -33,6 +33,7 @@ #if defined(Q_OS_MAC) #include "emoji/MacHelper.h" +#include "notifications/Manager.h" #endif #if defined(GSTREAMER_AVAILABLE) && (defined(Q_OS_MAC) || defined(Q_OS_WINDOWS)) @@ -389,6 +390,10 @@ main(int argc, char *argv[]) // Temporary solution for the emoji picker until // nheko has a proper menu bar with more functionality. MacHelper::initializeMenus(); + + // Need to set up notification delegate so users can respond to messages from within the + // notification itself. + NotificationsManager::attachToMacNotifCenter(); #endif nhlog::ui()->info("starting nheko {}", nheko::version); diff --git a/src/notifications/MacNotificationDelegate.h b/src/notifications/MacNotificationDelegate.h new file mode 100644 index 00000000..e5bbe23b --- /dev/null +++ b/src/notifications/MacNotificationDelegate.h @@ -0,0 +1,20 @@ +// SPDX-FileCopyrightText: 2021 Nheko Contributors +// SPDX-FileCopyrightText: 2022 Nheko Contributors +// +// SPDX-License-Identifier: GPL-3.0-or-later + +#pragma once + +#include "notifications/Manager.h" +#include "notifications/NotificationManagerProxy.h" +#include + +#import +#import + +@interface MacNotificationDelegate : NSObject { + std::unique_ptr mProxy; +} + +- (id)initWithProxy:(std::unique_ptr&&)proxy; +@end diff --git a/src/notifications/MacNotificationDelegate.mm b/src/notifications/MacNotificationDelegate.mm new file mode 100644 index 00000000..9047efe3 --- /dev/null +++ b/src/notifications/MacNotificationDelegate.mm @@ -0,0 +1,47 @@ +// SPDX-FileCopyrightText: 2021 Nheko Contributors +// SPDX-FileCopyrightText: 2022 Nheko Contributors +// +// SPDX-License-Identifier: GPL-3.0-or-later + +#import "notifications/MacNotificationDelegate.h" + +#include + +#include "ChatPage.h" + +@implementation MacNotificationDelegate + +- (id)initWithProxy: (std::unique_ptr&&)proxy +{ + if(self = [super init]) { + mProxy = std::move(proxy); + } + + return self; +} + +- (void)userNotificationCenter:(UNUserNotificationCenter*)center + didReceiveNotificationResponse:(UNNotificationResponse*)response + withCompletionHandler:(void (^)())completionHandler +{ + if ([response.actionIdentifier isEqualToString:@"ReplyAction"]) { + if ([response respondsToSelector:@selector(userText)]) { + UNTextInputNotificationResponse* textResponse = (UNTextInputNotificationResponse*)response; + NSString* textValue = [textResponse userText]; + NSString* eventId = [[[textResponse notification] request] identifier]; + NSString* roomId = [[[[textResponse notification] request] content] threadIdentifier]; + mProxy->notificationReplied(QString::fromNSString(roomId), QString::fromNSString(eventId), QString::fromNSString(textValue)); + } + } + completionHandler(); +} + +- (void)userNotificationCenter:(UNUserNotificationCenter*)center + willPresentNotification:(UNNotification*)notification + withCompletionHandler:(void (^)(UNNotificationPresentationOptions))completionHandler +{ + + completionHandler(UNAuthorizationOptionAlert | UNAuthorizationOptionBadge | UNAuthorizationOptionSound); +} + +@end \ No newline at end of file diff --git a/src/notifications/Manager.h b/src/notifications/Manager.h index 8a5f1725..de678738 100644 --- a/src/notifications/Manager.h +++ b/src/notifications/Manager.h @@ -78,7 +78,13 @@ private: const QString &event_id, const QString &subtitle, const QString &informativeText, - const QString &bodyImagePath); + const QString &bodyImagePath, + const QString &respondStr, + const QString &sendStr, + const QString &placeholder); + +public: + static void attachToMacNotifCenter(); #endif #if defined(Q_OS_WINDOWS) diff --git a/src/notifications/ManagerMac.cpp b/src/notifications/ManagerMac.cpp index d5faaf59..75ea838c 100644 --- a/src/notifications/ManagerMac.cpp +++ b/src/notifications/ManagerMac.cpp @@ -40,12 +40,20 @@ NotificationsManager::postNotification(const mtx::responses::Notification ¬if const auto isEncrypted = std::get_if>( ¬ification.event) != nullptr; const auto isReply = utils::isReply(notification.event); + + // Putting these here to pass along since I'm not sure how + // our translate step interacts with .mm files + const auto respondStr = QObject::tr("Respond"); + const auto sendStr = QObject::tr("Send"); + const auto placeholder = QObject::tr("Write a message..."); + if (isEncrypted) { // TODO: decrypt this message if the decryption setting is on in the UserSettings const QString messageInfo = (isReply ? tr("%1 replied with an encrypted message") : tr("%1 sent an encrypted message")) .arg(sender); - objCxxPostNotification(room_name, room_id, event_id, messageInfo, "", ""); + objCxxPostNotification( + room_name, room_id, event_id, messageInfo, "", "", respondStr, sendStr, placeholder); } else { const QString messageInfo = (isReply ? tr("%1 replied to a message") : tr("%1 sent a message")).arg(sender); @@ -53,17 +61,34 @@ NotificationsManager::postNotification(const mtx::responses::Notification ¬if MxcImageProvider::download( QString::fromStdString(mtx::accessors::url(notification.event)).remove("mxc://"), QSize(200, 80), - [this, notification, room_name, room_id, event_id, messageInfo]( - QString, QSize, QImage, QString imgPath) { + [this, + notification, + room_name, + room_id, + event_id, + messageInfo, + respondStr, + sendStr, + placeholder](QString, QSize, QImage, QString imgPath) { objCxxPostNotification(room_name, room_id, event_id, messageInfo, formatNotification(notification), - imgPath); + imgPath, + respondStr, + sendStr, + placeholder); }); else - objCxxPostNotification( - room_name, room_id, event_id, messageInfo, formatNotification(notification), ""); + objCxxPostNotification(room_name, + room_id, + event_id, + messageInfo, + formatNotification(notification), + "", + respondStr, + sendStr, + placeholder); } } diff --git a/src/notifications/ManagerMac.mm b/src/notifications/ManagerMac.mm index d5d900a7..4865e30d 100644 --- a/src/notifications/ManagerMac.mm +++ b/src/notifications/ManagerMac.mm @@ -1,112 +1,187 @@ +// SPDX-FileCopyrightText: 2021 Nheko Contributors +// SPDX-FileCopyrightText: 2022 Nheko Contributors +// +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "notifications/NotificationManagerProxy.h" +#include "notifications/MacNotificationDelegate.h" #include "notifications/Manager.h" -#import +#include "ChatPage.h" + #import +#import #import -#include #include +#include @interface UNNotificationAttachment (UNNotificationAttachmentAdditions) - + (UNNotificationAttachment *) createFromImageData:(NSData*)imgData identifier:(NSString *)imageFileIdentifier options:(NSDictionary*)attachmentOptions; ++ (UNNotificationAttachment*)createFromImageData:(NSData*)imgData + identifier:(NSString*)imageFileIdentifier + options: + (NSDictionary*)attachmentOptions; @end @implementation UNNotificationAttachment (UNNotificationAttachmentAdditions) - + (UNNotificationAttachment *) createFromImageData:(NSData*)imgData identifier:(NSString *)imageFileIdentifier options:(NSDictionary*)attachmentOptions { - NSFileManager *fileManager = [NSFileManager defaultManager]; - NSString *tmpSubFolderName = [[NSProcessInfo processInfo] globallyUniqueString]; - NSURL *tmpSubFolderURL = [NSURL fileURLWithPath:[NSTemporaryDirectory() stringByAppendingPathComponent:tmpSubFolderName] isDirectory:true]; - NSError *error = nil; - [fileManager createDirectoryAtURL:tmpSubFolderURL withIntermediateDirectories:true attributes:nil error:&error]; - if(error) { - NSLog(@"%@",[error localizedDescription]); - return nil; - } - NSURL *fileURL = [tmpSubFolderURL URLByAppendingPathComponent:imageFileIdentifier]; - [imgData writeToURL:fileURL atomically:true]; - UNNotificationAttachment *imageAttachment = [UNNotificationAttachment attachmentWithIdentifier:@"" URL:fileURL options:attachmentOptions error:&error]; - if(error) { - NSLog(@"%@",[error localizedDescription]); - return nil; - } - return imageAttachment; - ++ (UNNotificationAttachment*)createFromImageData:(NSData*)imgData + identifier:(NSString*)imageFileIdentifier + options: + (NSDictionary*)attachmentOptions +{ + NSFileManager* fileManager = [NSFileManager defaultManager]; + NSString* tmpSubFolderName = + [[NSProcessInfo processInfo] globallyUniqueString]; + NSURL* tmpSubFolderURL = [NSURL + fileURLWithPath:[NSTemporaryDirectory() + stringByAppendingPathComponent:tmpSubFolderName] + isDirectory:true]; + NSError* error = nil; + [fileManager createDirectoryAtURL:tmpSubFolderURL + withIntermediateDirectories:true + attributes:nil + error:&error]; + if (error) { + NSLog(@"%@", [error localizedDescription]); + return nil; } + NSURL* fileURL = + [tmpSubFolderURL URLByAppendingPathComponent:imageFileIdentifier]; + [imgData writeToURL:fileURL atomically:true]; + UNNotificationAttachment* imageAttachment = + [UNNotificationAttachment attachmentWithIdentifier:@"" + URL:fileURL + options:attachmentOptions + error:&error]; + if (error) { + NSLog(@"%@", [error localizedDescription]); + return nil; + } + return imageAttachment; +} @end -NotificationsManager::NotificationsManager(QObject *parent): QObject(parent) +NotificationsManager::NotificationsManager(QObject* parent) + : QObject(parent) { - } -void -NotificationsManager::objCxxPostNotification(const QString &room_name, - const QString &room_id, - const QString &event_id, - const QString &subtitle, - const QString &informativeText, - const QString &bodyImagePath) +void NotificationsManager::objCxxPostNotification( + const QString& room_name, + const QString& room_id, + const QString& event_id, + const QString& subtitle, + const QString& informativeText, + const QString& bodyImagePath, + const QString& respondStr, + const QString& sendStr, + const QString& placeholder) { + // Request permissions for alerts (the generic type of notification), sound playback, + // and badges (which allows the Nheko app icon to show the little red bubble with unread count). + // NOTE: Possible macOS bug... the 'Play sound for notification checkbox' doesn't appear in + // the Notifications and Focus settings unless UNAuthorizationOptionBadges is also + // specified UNAuthorizationOptions options = UNAuthorizationOptionAlert + UNAuthorizationOptionSound + UNAuthorizationOptionBadge; - UNUserNotificationCenter *center = [UNUserNotificationCenter currentNotificationCenter]; + UNUserNotificationCenter* center = + [UNUserNotificationCenter currentNotificationCenter]; + // TODO: Move this somewhere that isn't dependent on receiving a notification + // to actually request notification access. [center requestAuthorizationWithOptions:options - completionHandler:^(BOOL granted, NSError * _Nullable error) { - if (!granted) { - NSLog(@"No notification access"); - if (error) { - NSLog(@"%@",[error localizedDescription]); + completionHandler:^(BOOL granted, + NSError* _Nullable error) { + if (!granted) { + NSLog(@"No notification access"); + if (error) { + NSLog(@"%@", [error localizedDescription]); + } + } + }]; + + UNTextInputNotificationAction* replyAction = [UNTextInputNotificationAction actionWithIdentifier:@"ReplyAction" + title:respondStr.toNSString() + options:UNNotificationActionOptionNone + textInputButtonTitle:sendStr.toNSString() + textInputPlaceholder:placeholder.toNSString()]; + + UNNotificationCategory* category = [UNNotificationCategory categoryWithIdentifier:@"ReplyCategory" + actions:@[ replyAction ] + intentIdentifiers:@[] + options:UNNotificationCategoryOptionNone]; + + NSString* title = room_name.toNSString(); + NSString* sub = subtitle.toNSString(); + NSString* body = informativeText.toNSString(); + NSString* threadIdentifier = room_id.toNSString(); + NSString* identifier = event_id.toNSString(); + NSString* imgUrl = bodyImagePath.toNSString(); + + NSSet* categories = [NSSet setWithObject:category]; + [center setNotificationCategories:categories]; + [center getNotificationSettingsWithCompletionHandler:^( + UNNotificationSettings* _Nonnull settings) { + if (settings.authorizationStatus == UNAuthorizationStatusAuthorized) { + UNMutableNotificationContent* content = + [[UNMutableNotificationContent alloc] init]; + + content.title = title; + content.subtitle = sub; + content.body = body; + content.sound = [UNNotificationSound defaultSound]; + content.threadIdentifier = threadIdentifier; + content.categoryIdentifier = @"ReplyCategory"; + + if ([imgUrl length] != 0) { + NSURL* imageURL = [NSURL fileURLWithPath:imgUrl]; + NSData* img = [NSData dataWithContentsOfURL:imageURL]; + NSArray* attachments = [NSMutableArray array]; + UNNotificationAttachment* attachment = [UNNotificationAttachment + createFromImageData:img + identifier:@"attachment_image.jpeg" + options:nil]; + if (attachment) { + attachments = [NSMutableArray arrayWithObjects:attachment, nil]; + content.attachments = attachments; + } } + + UNNotificationRequest* notificationRequest = + [UNNotificationRequest requestWithIdentifier:identifier + content:content + trigger:nil]; + + [center addNotificationRequest:notificationRequest + withCompletionHandler:^(NSError* _Nullable error) { + if (error != nil) { + NSLog(@"Unable to Add Notification Request: %@", [error localizedDescription]); + } + }]; + + [content autorelease]; } }]; - - UNMutableNotificationContent *content = [[UNMutableNotificationContent alloc] init]; - - content.title = room_name.toNSString(); - content.subtitle = subtitle.toNSString(); - content.body = informativeText.toNSString(); - content.sound = [UNNotificationSound defaultSound]; - content.threadIdentifier = room_id.toNSString(); - - if (!bodyImagePath.isEmpty()) { - NSURL *imageURL = [NSURL fileURLWithPath:bodyImagePath.toNSString()]; - NSData *img = [NSData dataWithContentsOfURL:imageURL]; - NSArray *attachments = [NSMutableArray array]; - UNNotificationAttachment *attachment = [UNNotificationAttachment createFromImageData:img identifier:@"attachment_image.jpeg" options:nil]; - if (attachment) { - attachments = [NSMutableArray arrayWithObjects: attachment, nil]; - content.attachments = attachments; - } - } - - UNNotificationRequest *notificationRequest = [UNNotificationRequest requestWithIdentifier:event_id.toNSString() content:content trigger:nil]; - - [center addNotificationRequest:notificationRequest withCompletionHandler:^(NSError * _Nullable error) { - if (error != nil) { - NSLog(@"Unable to Add Notification Request"); - } - }]; - - [content autorelease]; } -//unused -void -NotificationsManager::actionInvoked(uint, QString) +void NotificationsManager::attachToMacNotifCenter() { + UNUserNotificationCenter* center = + [UNUserNotificationCenter currentNotificationCenter]; + + std::unique_ptr proxy = std::make_unique(); + + connect(proxy.get(), &NotificationManagerProxy::notificationReplied, ChatPage::instance(), &ChatPage::sendNotificationReply); + + MacNotificationDelegate* notifDelegate = [[MacNotificationDelegate alloc] initWithProxy:std::move(proxy)]; + + center.delegate = notifDelegate; } -void -NotificationsManager::notificationReplied(uint, QString) -{ -} +// unused +void NotificationsManager::actionInvoked(uint, QString) { } -void -NotificationsManager::notificationClosed(uint, uint) -{ -} +void NotificationsManager::notificationReplied(uint, QString) { } -void -NotificationsManager::removeNotification(const QString &, const QString &) -{} +void NotificationsManager::notificationClosed(uint, uint) { } +void NotificationsManager::removeNotification(const QString&, const QString&) { } diff --git a/src/notifications/NotificationManagerProxy.h b/src/notifications/NotificationManagerProxy.h new file mode 100644 index 00000000..c7a2e234 --- /dev/null +++ b/src/notifications/NotificationManagerProxy.h @@ -0,0 +1,22 @@ +// SPDX-FileCopyrightText: 2021 Nheko Contributors +// SPDX-FileCopyrightText: 2022 Nheko Contributors +// +// SPDX-License-Identifier: GPL-3.0-or-later + +#pragma once + +#include +#include + +class NotificationManagerProxy final : public QObject +{ + Q_OBJECT +public: + NotificationManagerProxy(QObject *parent = nullptr) + : QObject(parent) + { + } + +signals: + void notificationReplied(const QString &room, const QString &event, const QString &reply); +}; \ No newline at end of file