Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add interactive NC Talk notifications on macOS #5143

Merged
merged 3 commits into from
Dec 9, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions src/gui/systray.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -486,6 +486,18 @@ void Systray::showUpdateMessage(const QString &title, const QString &message, co
#endif
}

void Systray::showTalkMessage(const QString &title, const QString &message, const QString &token, const QString &replyTo, const AccountStatePtr &accountState)
{
#if defined(Q_OS_MACOS) && defined(BUILD_OWNCLOUD_OSX_BUNDLE)
sendOsXTalkNotification(title, message, token, replyTo, accountState);
#else // TODO: Implement custom notifications (i.e. actionable) for other OSes
Q_UNUSED(replyTo)
Q_UNUSED(token)
Q_UNUSED(accountState)
showMessage(title, message);
#endif
}

void Systray::setToolTip(const QString &tip)
{
QSystemTrayIcon::setToolTip(tr("%1: %2").arg(Theme::instance()->appNameGUI(), tip));
Expand Down
2 changes: 2 additions & 0 deletions src/gui/systray.h
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ void registerNotificationCategories(const QString &localizedDownloadString);
bool canOsXSendUserNotification();
void sendOsXUserNotification(const QString &title, const QString &message);
void sendOsXUpdateNotification(const QString &title, const QString &message, const QUrl &webUrl);
void sendOsXTalkNotification(const QString &title, const QString &message, const QString &token, const QString &replyTo, const AccountStatePtr accountState);
void setTrayWindowLevelAndVisibleOnAllSpaces(QWindow *window);
double menuBarThickness();
#endif
Expand Down Expand Up @@ -113,6 +114,7 @@ public slots:

void showMessage(const QString &title, const QString &message, QSystemTrayIcon::MessageIcon icon = Information);
void showUpdateMessage(const QString &title, const QString &message, const QUrl &webUrl);
void showTalkMessage(const QString &title, const QString &message, const QString &replyTo, const QString &token, const AccountStatePtr &accountState);
void setToolTip(const QString &tip);

void createCallDialog(const OCC::Activity &callNotification, const OCC::AccountStatePtr accountState);
Expand Down
117 changes: 106 additions & 11 deletions src/gui/systray.mm
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
#include "QtCore/qurl.h"
#include "account.h"
#include "accountstate.h"
#include "accountmanager.h"
#include "config.h"
#include "systray.h"
#include "tray/talkreply.h"
#include <QString>
#include <QWindow>
#include <QLoggingCategory>
Expand All @@ -9,6 +14,58 @@

Q_LOGGING_CATEGORY(lcMacSystray, "nextcloud.gui.macsystray")

/************************* Private utility functions *************************/

namespace {

void sendTalkReply(UNNotificationResponse *response, UNNotificationContent* content)
{
if (!response || !content) {
qCWarning(lcMacSystray()) << "Invalid notification response or content."
<< "Can't send talk reply.";
return;
}

UNTextInputNotificationResponse *textInputResponse = (UNTextInputNotificationResponse*)response;

if (!textInputResponse) {
qCWarning(lcMacSystray()) << "Notification response was not a text input response."
<< "Can't send talk reply.";
return;
}

NSString *reply = textInputResponse.userText;
NSString *token = [content.userInfo objectForKey:@"token"];
NSString *account = [content.userInfo objectForKey:@"account"];
NSString *replyTo = [content.userInfo objectForKey:@"replyTo"];

const auto qReply = QString::fromNSString(reply);
const auto qReplyTo = QString::fromNSString(replyTo);
const auto qToken = QString::fromNSString(token);
const auto qAccount = QString::fromNSString(account);

const auto accountState = OCC::AccountManager::instance()->accountFromUserId(qAccount);

if (!accountState) {
qCWarning(lcMacSystray()) << "Could not find account matching" << qAccount
<< "Can't send talk reply.";
return;
}

qCDebug(lcMacSystray()) << "Sending talk reply from macOS notification."
<< "Reply is:" << qReply
<< "Replying to:" << qReplyTo
<< "Token:" << qToken
<< "Account:" << qAccount;

QPointer<OCC::TalkReply> talkReply = new OCC::TalkReply(accountState.data(), OCC::Systray::instance());
talkReply->sendReplyMessage(qToken, qReply, qReplyTo);
}

} // anonymous namespace

/**************************** Objective-C classes ****************************/

@interface NotificationCenterDelegate : NSObject
@end
@implementation NotificationCenterDelegate
Expand All @@ -34,23 +91,24 @@ - (void)userNotificationCenter:(UNUserNotificationCenter *)center
UNNotificationContent* content = response.notification.request.content;
if ([content.categoryIdentifier isEqualToString:@"UPDATE"]) {

if ([response.actionIdentifier isEqualToString:@"DOWNLOAD_ACTION"] || [response.actionIdentifier isEqualToString:UNNotificationDefaultActionIdentifier])
{
if ([response.actionIdentifier isEqualToString:@"DOWNLOAD_ACTION"] || [response.actionIdentifier isEqualToString:UNNotificationDefaultActionIdentifier]) {
qCDebug(lcMacSystray()) << "Opening update download url in browser.";
[[NSWorkspace sharedWorkspace] openURL:[NSURL URLWithString:[content.userInfo objectForKey:@"webUrl"]]];
}
} else if ([content.categoryIdentifier isEqualToString:@"TALK_MESSAGE"]) {

if ([response.actionIdentifier isEqualToString:@"TALK_REPLY_ACTION"]) {
sendTalkReply(response, content);
}
}

completionHandler();
}
@end

namespace OCC {
/********************* Methods accessible to C++ Systray *********************/

enum MacNotificationAuthorizationOptions {
Default = 0,
Provisional
};
namespace OCC {

double menuBarThickness()
{
Expand Down Expand Up @@ -93,10 +151,24 @@ void registerNotificationCategories(const QString &localisedDownloadString) {
intentIdentifiers:@[]
options:UNNotificationCategoryOptionNone];

[[UNUserNotificationCenter currentNotificationCenter] setNotificationCategories:[NSSet setWithObjects:generalCategory, updateCategory, nil]];
// Create the custom action for talk notifications
UNTextInputNotificationAction* talkReplyAction = [UNTextInputNotificationAction
actionWithIdentifier:@"TALK_REPLY_ACTION"
title:QObject::tr("Reply").toNSString()
options:UNNotificationActionOptionNone
textInputButtonTitle:QObject::tr("Reply").toNSString()
textInputPlaceholder:QObject::tr("Send a Nextcloud Talk reply").toNSString()];

UNNotificationCategory* talkReplyCategory = [UNNotificationCategory
categoryWithIdentifier:@"TALK_MESSAGE"
actions:@[talkReplyAction]
intentIdentifiers:@[]
options:UNNotificationCategoryOptionNone];

[[UNUserNotificationCenter currentNotificationCenter] setNotificationCategories:[NSSet setWithObjects:generalCategory, updateCategory, talkReplyCategory, nil]];
}

void checkNotificationAuth(MacNotificationAuthorizationOptions additionalAuthOption = MacNotificationAuthorizationOptions::Provisional)
void checkNotificationAuth(MacNotificationAuthorizationOptions additionalAuthOption)
{
UNUserNotificationCenter* center = [UNUserNotificationCenter currentNotificationCenter];
UNAuthorizationOptions authOptions = UNAuthorizationOptionAlert + UNAuthorizationOptionSound;
Expand Down Expand Up @@ -170,6 +242,30 @@ void sendOsXUpdateNotification(const QString &title, const QString &message, con
[center addNotificationRequest:request withCompletionHandler:nil];
}

void sendOsXTalkNotification(const QString &title, const QString &message, const QString &token, const QString &replyTo, const AccountStatePtr accountState)
{
UNUserNotificationCenter* center = [UNUserNotificationCenter currentNotificationCenter];
checkNotificationAuth();

if (!accountState || !accountState->account()) {
sendOsXUserNotification(title, message);
return;
}

NSString *accountNS = accountState->account()->displayName().toNSString();
NSString *tokenNS = token.toNSString();
NSString *replyToNS = replyTo.toNSString();

UNMutableNotificationContent* content = basicNotificationContent(title, message);
content.categoryIdentifier = @"TALK_MESSAGE";
content.userInfo = [NSDictionary dictionaryWithObjects:@[accountNS, tokenNS, replyToNS] forKeys:@[@"account", @"token", @"replyTo"]];

UNTimeIntervalNotificationTrigger* trigger = [UNTimeIntervalNotificationTrigger triggerWithTimeInterval:1 repeats: NO];
UNNotificationRequest* request = [UNNotificationRequest requestWithIdentifier:@"NCTalkMessageNotification" content:content trigger:trigger];

[center addNotificationRequest:request withCompletionHandler:nil];
}

void setTrayWindowLevelAndVisibleOnAllSpaces(QWindow *window)
{
NSView *nativeView = (NSView *)window->winId();
Expand All @@ -185,5 +281,4 @@ bool osXInDarkMode()
return [osxMode containsString:@"Dark"];
}

}

} // OCC namespace
107 changes: 80 additions & 27 deletions src/gui/tray/usermodel.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -82,39 +82,101 @@ User::User(AccountStatePtr &account, const bool &isCurrent, QObject *parent)
connect(this, &User::sendReplyMessage, this, &User::slotSendReplyMessage);
}

void User::checkNotifiedNotifications()
{
// after one hour, clear the gui log notification store
constexpr qint64 clearGuiLogInterval = 60 * 60 * 1000;
if (_guiLogTimer.elapsed() > clearGuiLogInterval) {
_notifiedNotifications.clear();
}
}

bool User::notificationAlreadyShown(const long notificationId)
{
checkNotifiedNotifications();
return _notifiedNotifications.contains(notificationId);
}

bool User::canShowNotification(const long notificationId)
{
ConfigFile cfg;
return cfg.optionalServerNotifications() &&
isDesktopNotificationsAllowed() &&
!notificationAlreadyShown(notificationId);
}

void User::showDesktopNotification(const QString &title, const QString &message, const long notificationId)
{
if(!canShowNotification(notificationId)) {
return;
}

_notifiedNotifications.insert(notificationId);
Logger::instance()->postGuiLog(title, message);
// restart the gui log timer now that we show a new notification
_guiLogTimer.start();
}

void User::showDesktopNotification(const Activity &activity)
{
const auto notificationId = activity._id;
const auto message = AccountManager::instance()->accounts().count() == 1 ? "" : activity._accName;
showDesktopNotification(activity._subject, message, notificationId);
}

void User::showDesktopNotification(const ActivityList &activityList)
{
const auto subject = QStringLiteral("%1 notifications").arg(activityList.count());
const auto notificationId = -static_cast<int>(qHash(subject));

if (!canShowNotification(notificationId)) {
return;
}

const auto multipleAccounts = AccountManager::instance()->accounts().count() > 1;
const auto message = multipleAccounts ? activityList.constFirst()._accName : QString();

// Notification ids are uints, which are 4 bytes. Error activities don't have ids, however, so we generate one.
// To avoid possible collisions between the activity ids which are actually the notification ids received from
// the server (which are always positive) and our "fake" error activity ids, we assign a negative id to the
// error notification.
//
// To ensure that we can still treat an unsigned int as normal, we use a long, which is 8 bytes.

ConfigFile cfg;
if (!cfg.optionalServerNotifications() || !isDesktopNotificationsAllowed()) {
return;
Logger::instance()->postGuiLog(subject, message);

for(const auto &activity : activityList) {
_notifiedNotifications.insert(activity._id);
_activityModel->addNotificationToActivityList(activity);
}
}

// after one hour, clear the gui log notification store
constexpr qint64 clearGuiLogInterval = 60 * 60 * 1000;
if (_guiLogTimer.elapsed() > clearGuiLogInterval) {
_notifiedNotifications.clear();
void User::showDesktopTalkNotification(const Activity &activity)
{
const auto notificationId = activity._id;

if (!canShowNotification(notificationId)) {
return;
}

if (_notifiedNotifications.contains(notificationId)) {
if (activity._talkNotificationData.messageId.isEmpty()) {
showDesktopNotification(activity._subject, activity._message, notificationId);
return;
}

_notifiedNotifications.insert(notificationId);
Logger::instance()->postGuiLog(title, message);
// restart the gui log timer now that we show a new notification
_activityModel->addNotificationToActivityList(activity);

Systray::instance()->showTalkMessage(activity._subject,
activity._message,
activity._talkNotificationData.conversationToken,
activity._talkNotificationData.messageId,
_account);
_guiLogTimer.start();
}

void User::slotBuildNotificationDisplay(const ActivityList &list)
{
const auto multipleAccounts = AccountManager::instance()->accounts().count() > 1;
ActivityList toNotifyList;

std::copy_if(list.constBegin(), list.constEnd(), std::back_inserter(toNotifyList), [&](const Activity &activity) {
Expand All @@ -131,25 +193,16 @@ void User::slotBuildNotificationDisplay(const ActivityList &list)
});

if(toNotifyList.count() > 2) {
const auto subject = QStringLiteral("%1 notifications").arg(toNotifyList.count());
const auto message = multipleAccounts ? toNotifyList.constFirst()._accName : QString();
showDesktopNotification(subject, message, -static_cast<int>(qHash(subject)));

// Set these activities as notified here, rather than in showDesktopNotification
for(const auto &activity : qAsConst(toNotifyList)) {
_notifiedNotifications.insert(activity._id);
_activityModel->addNotificationToActivityList(activity);
}

showDesktopNotification(toNotifyList);
return;
}

for(const auto &activity : qAsConst(toNotifyList)) {
const auto message = activity._objectType == QStringLiteral("chat")
? activity._message : AccountManager::instance()->accounts().count() == 1 ? "" : activity._accName;

showDesktopNotification(activity._subject, message, activity._id); // We assigned the notif. id to the activity id
_activityModel->addNotificationToActivityList(activity);
if (activity._objectType == QStringLiteral("chat")) {
showDesktopTalkNotification(activity);
} else {
showDesktopNotification(activity);
}
}
}

Expand Down Expand Up @@ -548,7 +601,7 @@ void User::slotAddErrorToGui(const QString &folderAlias, SyncFileItem::Status st
// add 'other errors' to activity list
_activityModel->addErrorToActivityList(activity);

showDesktopNotification(activity._subject, activity._message, activity._id);
showDesktopNotification(activity);

if (!_expiredActivitiesCheckTimer.isActive()) {
_expiredActivitiesCheckTimer.start(expiredActivitiesCheckIntervalMsecs);
Expand Down
13 changes: 10 additions & 3 deletions src/gui/tray/usermodel.h
Original file line number Diff line number Diff line change
Expand Up @@ -110,22 +110,29 @@ public slots:
void slotSendReplyMessage(const int activityIndex, const QString &conversationToken, const QString &message, const QString &replyTo);
void forceSyncNow() const;

private:
private slots:
void slotPushNotificationsReady();
void slotDisconnectPushNotifications();
void slotReceivedPushNotification(Account *account);
void slotReceivedPushActivity(Account *account);
void slotCheckExpiredActivities();

void checkNotifiedNotifications();
void showDesktopNotification(const QString &title, const QString &message, const long notificationId);
void showDesktopNotification(const Activity &activity);
void showDesktopNotification(const ActivityList &activityList);
void showDesktopTalkNotification(const Activity &activity);

private:
void connectPushNotifications() const;
[[nodiscard]] bool checkPushNotificationsAreReady() const;

bool isActivityOfCurrentAccount(const Folder *folder) const;
[[nodiscard]] bool isUnsolvableConflict(const SyncFileItemPtr &item) const;

void showDesktopNotification(const QString &title, const QString &message, const long notificationId);
bool notificationAlreadyShown(const long notificationId);
bool canShowNotification(const long notificationId);

private:
AccountStatePtr _account;
bool _isCurrentUser;
ActivityListModel *_activityModel;
Expand Down