Add ability to respond to notifications on macOS

This commit is contained in:
Joe Donofry 2022-11-04 16:42:09 +00:00
parent 9138119dc4
commit 07e8f64903
11 changed files with 307 additions and 99 deletions

View File

@ -13,3 +13,6 @@ KeepEmptyLinesAtTheStartOfBlocks: false
PointerAlignment: Right PointerAlignment: Right
Cpp11BracedListStyle: true Cpp11BracedListStyle: true
PenaltyReturnTypeOnItsOwnLine: 0 PenaltyReturnTypeOnItsOwnLine: 0
---
BasedOnStyle: WebKit
Language: ObjC

View File

@ -629,9 +629,9 @@ set(TRANSLATION_DEPS ${LANG_QRC} ${QRC} ${QM_SRC})
if (APPLE) if (APPLE)
set(CMAKE_EXE_LINKER_FLAGS "${CMAKE_EXE_LINKER_FLAGS} -framework Foundation -framework Cocoa -framework UserNotifications") 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") 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() endif()
elseif (WIN32) elseif (WIN32)
file(DOWNLOAD file(DOWNLOAD

View File

@ -152,16 +152,7 @@ ChatPage::ChatPage(QSharedPointer<UserSettings> userSettings, QObject *parent)
connect(notificationsManager, connect(notificationsManager,
&NotificationsManager::sendNotificationReply, &NotificationsManager::sendNotificationReply,
this, this,
[this](const QString &roomid, const QString &eventid, const QString &body) { &ChatPage::sendNotificationReply);
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();
}
});
connect( connect(
this, this,
@ -1583,6 +1574,19 @@ ChatPage::handleMatrixUri(QString uri)
return false; 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 bool
ChatPage::handleMatrixUri(const QUrl &uri) ChatPage::handleMatrixUri(const QUrl &uri)
{ {

View File

@ -105,6 +105,7 @@ public slots:
void receivedSessionKey(const std::string &room_id, const std::string &session_id); void receivedSessionKey(const std::string &room_id, const std::string &session_id);
void decryptDownloadedSecrets(mtx::secret_storage::AesHmacSha2KeyDescription keyDesc, void decryptDownloadedSecrets(mtx::secret_storage::AesHmacSha2KeyDescription keyDesc,
const SecretsToDecrypt &secrets); const SecretsToDecrypt &secrets);
void sendNotificationReply(const QString &roomid, const QString &eventid, const QString &body);
signals: signals:
void connectionLost(); void connectionLost();
void connectionRestored(); void connectionRestored();

View File

@ -33,6 +33,7 @@
#if defined(Q_OS_MAC) #if defined(Q_OS_MAC)
#include "emoji/MacHelper.h" #include "emoji/MacHelper.h"
#include "notifications/Manager.h"
#endif #endif
#if defined(GSTREAMER_AVAILABLE) && (defined(Q_OS_MAC) || defined(Q_OS_WINDOWS)) #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 // Temporary solution for the emoji picker until
// nheko has a proper menu bar with more functionality. // nheko has a proper menu bar with more functionality.
MacHelper::initializeMenus(); MacHelper::initializeMenus();
// Need to set up notification delegate so users can respond to messages from within the
// notification itself.
NotificationsManager::attachToMacNotifCenter();
#endif #endif
nhlog::ui()->info("starting nheko {}", nheko::version); nhlog::ui()->info("starting nheko {}", nheko::version);

View File

@ -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 <mtx/responses/notifications.hpp>
#import <Foundation/Foundation.h>
#import <UserNotifications/UserNotifications.h>
@interface MacNotificationDelegate : NSObject <UNUserNotificationCenterDelegate> {
std::unique_ptr<NotificationManagerProxy> mProxy;
}
- (id)initWithProxy:(std::unique_ptr<NotificationManagerProxy>&&)proxy;
@end

View File

@ -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 <QString.h>
#include "ChatPage.h"
@implementation MacNotificationDelegate
- (id)initWithProxy: (std::unique_ptr<NotificationManagerProxy>&&)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

View File

@ -78,7 +78,13 @@ private:
const QString &event_id, const QString &event_id,
const QString &subtitle, const QString &subtitle,
const QString &informativeText, const QString &informativeText,
const QString &bodyImagePath); const QString &bodyImagePath,
const QString &respondStr,
const QString &sendStr,
const QString &placeholder);
public:
static void attachToMacNotifCenter();
#endif #endif
#if defined(Q_OS_WINDOWS) #if defined(Q_OS_WINDOWS)

View File

@ -40,12 +40,20 @@ NotificationsManager::postNotification(const mtx::responses::Notification &notif
const auto isEncrypted = std::get_if<mtx::events::EncryptedEvent<mtx::events::msg::Encrypted>>( const auto isEncrypted = std::get_if<mtx::events::EncryptedEvent<mtx::events::msg::Encrypted>>(
&notification.event) != nullptr; &notification.event) != nullptr;
const auto isReply = utils::isReply(notification.event); 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) { if (isEncrypted) {
// TODO: decrypt this message if the decryption setting is on in the UserSettings // TODO: decrypt this message if the decryption setting is on in the UserSettings
const QString messageInfo = (isReply ? tr("%1 replied with an encrypted message") const QString messageInfo = (isReply ? tr("%1 replied with an encrypted message")
: tr("%1 sent an encrypted message")) : tr("%1 sent an encrypted message"))
.arg(sender); .arg(sender);
objCxxPostNotification(room_name, room_id, event_id, messageInfo, "", ""); objCxxPostNotification(
room_name, room_id, event_id, messageInfo, "", "", respondStr, sendStr, placeholder);
} else { } else {
const QString messageInfo = const QString messageInfo =
(isReply ? tr("%1 replied to a message") : tr("%1 sent a message")).arg(sender); (isReply ? tr("%1 replied to a message") : tr("%1 sent a message")).arg(sender);
@ -53,17 +61,34 @@ NotificationsManager::postNotification(const mtx::responses::Notification &notif
MxcImageProvider::download( MxcImageProvider::download(
QString::fromStdString(mtx::accessors::url(notification.event)).remove("mxc://"), QString::fromStdString(mtx::accessors::url(notification.event)).remove("mxc://"),
QSize(200, 80), QSize(200, 80),
[this, notification, room_name, room_id, event_id, messageInfo]( [this,
QString, QSize, QImage, QString imgPath) { notification,
room_name,
room_id,
event_id,
messageInfo,
respondStr,
sendStr,
placeholder](QString, QSize, QImage, QString imgPath) {
objCxxPostNotification(room_name, objCxxPostNotification(room_name,
room_id, room_id,
event_id, event_id,
messageInfo, messageInfo,
formatNotification(notification), formatNotification(notification),
imgPath); imgPath,
respondStr,
sendStr,
placeholder);
}); });
else else
objCxxPostNotification( objCxxPostNotification(room_name,
room_name, room_id, event_id, messageInfo, formatNotification(notification), ""); room_id,
event_id,
messageInfo,
formatNotification(notification),
"",
respondStr,
sendStr,
placeholder);
} }
} }

View File

@ -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" #include "notifications/Manager.h"
#import <Foundation/Foundation.h> #include "ChatPage.h"
#import <AppKit/NSImage.h> #import <AppKit/NSImage.h>
#import <Foundation/Foundation.h>
#import <UserNotifications/UserNotifications.h> #import <UserNotifications/UserNotifications.h>
#include <QtMac>
#include <QImage> #include <QImage>
#include <QtMac>
@interface UNNotificationAttachment (UNNotificationAttachmentAdditions) @interface UNNotificationAttachment (UNNotificationAttachmentAdditions)
+ (UNNotificationAttachment *) createFromImageData:(NSData*)imgData identifier:(NSString *)imageFileIdentifier options:(NSDictionary*)attachmentOptions; + (UNNotificationAttachment*)createFromImageData:(NSData*)imgData
identifier:(NSString*)imageFileIdentifier
options:
(NSDictionary*)attachmentOptions;
@end @end
@implementation UNNotificationAttachment (UNNotificationAttachmentAdditions) @implementation UNNotificationAttachment (UNNotificationAttachmentAdditions)
+ (UNNotificationAttachment *) createFromImageData:(NSData*)imgData identifier:(NSString *)imageFileIdentifier options:(NSDictionary*)attachmentOptions { + (UNNotificationAttachment*)createFromImageData:(NSData*)imgData
NSFileManager *fileManager = [NSFileManager defaultManager]; identifier:(NSString*)imageFileIdentifier
NSString *tmpSubFolderName = [[NSProcessInfo processInfo] globallyUniqueString]; options:
NSURL *tmpSubFolderURL = [NSURL fileURLWithPath:[NSTemporaryDirectory() stringByAppendingPathComponent:tmpSubFolderName] isDirectory:true]; (NSDictionary*)attachmentOptions
NSError *error = nil; {
[fileManager createDirectoryAtURL:tmpSubFolderURL withIntermediateDirectories:true attributes:nil error:&error]; NSFileManager* fileManager = [NSFileManager defaultManager];
if(error) { NSString* tmpSubFolderName =
NSLog(@"%@",[error localizedDescription]); [[NSProcessInfo processInfo] globallyUniqueString];
return nil; NSURL* tmpSubFolderURL = [NSURL
} fileURLWithPath:[NSTemporaryDirectory()
NSURL *fileURL = [tmpSubFolderURL URLByAppendingPathComponent:imageFileIdentifier]; stringByAppendingPathComponent:tmpSubFolderName]
[imgData writeToURL:fileURL atomically:true]; isDirectory:true];
UNNotificationAttachment *imageAttachment = [UNNotificationAttachment attachmentWithIdentifier:@"" URL:fileURL options:attachmentOptions error:&error]; NSError* error = nil;
if(error) { [fileManager createDirectoryAtURL:tmpSubFolderURL
NSLog(@"%@",[error localizedDescription]); withIntermediateDirectories:true
return nil; attributes:nil
} error:&error];
return imageAttachment; 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 @end
NotificationsManager::NotificationsManager(QObject *parent): QObject(parent) NotificationsManager::NotificationsManager(QObject* parent)
: QObject(parent)
{ {
} }
void void NotificationsManager::objCxxPostNotification(
NotificationsManager::objCxxPostNotification(const QString &room_name, const QString& room_name,
const QString &room_id, const QString& room_id,
const QString &event_id, const QString& event_id,
const QString &subtitle, const QString& subtitle,
const QString &informativeText, const QString& informativeText,
const QString &bodyImagePath) 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; 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 [center requestAuthorizationWithOptions:options
completionHandler:^(BOOL granted, NSError * _Nullable error) { completionHandler:^(BOOL granted,
if (!granted) { NSError* _Nullable error) {
NSLog(@"No notification access"); if (!granted) {
if (error) { NSLog(@"No notification access");
NSLog(@"%@",[error localizedDescription]); 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::attachToMacNotifCenter()
void
NotificationsManager::actionInvoked(uint, QString)
{ {
UNUserNotificationCenter* center =
[UNUserNotificationCenter currentNotificationCenter];
std::unique_ptr<NotificationManagerProxy> proxy = std::make_unique<NotificationManagerProxy>();
connect(proxy.get(), &NotificationManagerProxy::notificationReplied, ChatPage::instance(), &ChatPage::sendNotificationReply);
MacNotificationDelegate* notifDelegate = [[MacNotificationDelegate alloc] initWithProxy:std::move(proxy)];
center.delegate = notifDelegate;
} }
void // unused
NotificationsManager::notificationReplied(uint, QString) void NotificationsManager::actionInvoked(uint, QString) { }
{
}
void void NotificationsManager::notificationReplied(uint, QString) { }
NotificationsManager::notificationClosed(uint, uint)
{
}
void void NotificationsManager::notificationClosed(uint, uint) { }
NotificationsManager::removeNotification(const QString &, const QString &)
{}
void NotificationsManager::removeNotification(const QString&, const QString&) { }

View File

@ -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 <QObject>
#include <QString>
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);
};