/* This file is part of Telegram Desktop, the official desktop application for the Telegram messaging service. For license and copyright information please follow this link: https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL */ #include "platform/mac/notifications_manager_mac.h" #include "base/platform/base_platform_info.h" #include "base/options.h" #include "base/platform/mac/base_utilities_mac.h" #include "base/random.h" #include "base/unixtime.h" #include "core/application.h" #include "core/core_settings.h" #include "data/data_forum_topic.h" #include "data/data_peer.h" #include "data/data_saved_sublist.h" #include "history/history_item.h" #include "history/history.h" #include "lang/lang_cloud_manager.h" #include "lang/lang_instance.h" #include "lang/lang_keys.h" #include "main/main_session.h" #include "mainwindow.h" #include "platform/platform_specific.h" #include "ui/empty_userpic.h" #include "window/notifications_utilities.h" #include "styles/style_window.h" #include #include namespace { constexpr auto kQuerySettingsEachMs = crl::time(1000); constexpr auto kCacheExpirationWeeks = 5; constexpr auto kCacheExpirationSeconds = kCacheExpirationWeeks * 7 * 24 * 60 * 60; NSString *const kTelegramMarkAsReadText = @"TelegramMarkAsReadText"; NSString *const kTelegramMarkAsReadTimestamp = @"TelegramMarkAsReadTimestamp"; NSString *const kTelegramMarkAsReadLanguageCode = @"TelegramMarkAsReadLanguageCode"; crl::time LastSettingsQueryMs/* = 0*/; bool DoNotDisturbEnabled/* = false*/; [[nodiscard]] bool ShouldQuerySettings() { const auto now = crl::now(); if (LastSettingsQueryMs > 0 && now <= LastSettingsQueryMs + kQuerySettingsEachMs) { return false; } LastSettingsQueryMs = now; return true; } [[nodiscard]] QString LibraryPath() { static const auto result = [] { NSURL *url = [[NSFileManager defaultManager] URLForDirectory:NSLibraryDirectory inDomain:NSUserDomainMask appropriateForURL:nil create:NO error:nil]; return url ? QString::fromUtf8([[url path] fileSystemRepresentation]) : QString(); }(); return result; } void queryDoNotDisturbState() { if (!ShouldQuerySettings()) { return; } Boolean isKeyValid; const auto doNotDisturb = CFPreferencesGetAppBooleanValue( CFSTR("doNotDisturb"), CFSTR("com.apple.notificationcenterui"), &isKeyValid); DoNotDisturbEnabled = isKeyValid ? doNotDisturb : false; } using Manager = Platform::Notifications::Manager; } // namespace @interface NotificationDelegate : NSObject { } - (id) initWithManager:(base::weak_ptr)manager managerId:(uint64)managerId; - (void) userNotificationCenter:(NSUserNotificationCenter*)center didActivateNotification:(NSUserNotification*)notification; - (BOOL) userNotificationCenter:(NSUserNotificationCenter*)center shouldPresentNotification:(NSUserNotification*)notification; @end // @interface NotificationDelegate @implementation NotificationDelegate { base::weak_ptr _manager; uint64 _managerId; } - (id) initWithManager:(base::weak_ptr)manager managerId:(uint64)managerId { if (self = [super init]) { _manager = manager; _managerId = managerId; } return self; } - (void) userNotificationCenter:(NSUserNotificationCenter *)center didActivateNotification:(NSUserNotification *)notification { NSDictionary *notificationUserInfo = [notification userInfo]; NSNumber *managerIdObject = [notificationUserInfo objectForKey:@"manager"]; auto notificationManagerId = managerIdObject ? [managerIdObject unsignedLongLongValue] : 0ULL; DEBUG_LOG(("Received notification with instance %1, mine: %2").arg(notificationManagerId).arg(_managerId)); if (notificationManagerId != _managerId) { // other app instance notification crl::on_main([] { // Usually we show and activate main window when the application // is activated (receives applicationDidBecomeActive: notification). // // This is used for window show in Cmd+Tab switching to the application. // // But when a notification arrives sometimes macOS still activates the app // and we receive applicationDidBecomeActive: notification even if the // notification was sent by another instance of the application. In that case // we set a flag for a couple of seconds to ignore this app activation. objc_ignoreApplicationActivationRightNow(); }); return; } NSNumber *sessionObject = [notificationUserInfo objectForKey:@"session"]; const auto notificationSessionId = sessionObject ? [sessionObject unsignedLongLongValue] : 0; if (!notificationSessionId) { LOG(("App Error: A notification with unknown session was received")); return; } NSNumber *peerObject = [notificationUserInfo objectForKey:@"peer"]; const auto notificationPeerId = peerObject ? [peerObject unsignedLongLongValue] : 0ULL; if (!notificationPeerId) { LOG(("App Error: A notification with unknown peer was received")); return; } NSNumber *topicObject = [notificationUserInfo objectForKey:@"topic"]; if (!topicObject) { LOG(("App Error: A notification with unknown topic was received")); return; } const auto notificationTopicRootId = [topicObject longLongValue]; NSNumber *monoforumPeerObject = [notificationUserInfo objectForKey:@"monoforumpeer"]; if (!monoforumPeerObject) { LOG(("App Error: A notification with unknown monoforum peer was received")); return; } const auto notificationMonoforumPeerId = [monoforumPeerObject unsignedLongLongValue]; NSNumber *msgObject = [notificationUserInfo objectForKey:@"msgid"]; const auto notificationMsgId = msgObject ? [msgObject longLongValue] : 0LL; const auto my = Window::Notifications::Manager::NotificationId{ .contextId = Manager::ContextId{ .sessionId = notificationSessionId, .peerId = PeerId(notificationPeerId), .topicRootId = MsgId(notificationTopicRootId), .monoforumPeerId = PeerId(notificationMonoforumPeerId), }, .msgId = notificationMsgId, }; if (notification.activationType == NSUserNotificationActivationTypeReplied) { const auto notificationReply = QString::fromUtf8([[[notification response] string] UTF8String]); const auto manager = _manager; crl::on_main(manager, [=] { manager->notificationReplied(my, { notificationReply, {} }); }); } else if (notification.activationType == NSUserNotificationActivationTypeContentsClicked) { const auto manager = _manager; crl::on_main(manager, [=] { manager->notificationActivated(my); }); } if (notification.activationType == NSUserNotificationActivationTypeAdditionalActionClicked || notification.activationType == NSUserNotificationActivationTypeActionButtonClicked) { const auto manager = _manager; NSString *actionId = [notificationUserInfo objectForKey:@"actionId"]; if ([actionId isEqualToString:@"markAsRead"]) { crl::on_main(manager, [=] { manager->notificationReplied(my, {}); }); } } [center removeDeliveredNotification: notification]; } - (BOOL) userNotificationCenter:(NSUserNotificationCenter *)center shouldPresentNotification:(NSUserNotification *)notification { return YES; } @end // @implementation NotificationDelegate namespace Platform { namespace Notifications { bool SkipToastForCustom() { return false; } void MaybePlaySoundForCustom(Fn playSound) { playSound(); } void MaybeFlashBounceForCustom(Fn flashBounce) { flashBounce(); } bool WaitForInputForCustom() { return true; } bool Supported() { return true; } bool Enforced() { return Supported(); } bool ByDefault() { return Supported(); } bool VolumeSupported() { return false; } void Create(Window::Notifications::System *system) { system->setManager([=] { return std::make_unique(system); }); } class Manager::Private : public QObject { public: Private(Manager *manager); void showNotification( NotificationInfo &&info, Ui::PeerUserpicView &userpicView); void clearAll(); void clearFromItem(not_null item); void clearFromTopic(not_null topic); void clearFromSublist(not_null sublist); void clearFromHistory(not_null history); void clearFromSession(not_null session); void updateDelegate(); ~Private(); private: template void putClearTask(Task task); void clearingThreadLoop(); void initCachedMarkAsReadText(); const uint64 _managerId = 0; QString _managerIdString; NotificationDelegate *_delegate = nullptr; std::thread _clearingThread; std::mutex _clearingMutex; std::condition_variable _clearingCondition; struct ClearFromItem { NotificationId id; }; struct ClearFromTopic { ContextId contextId; }; struct ClearFromSublist { ContextId contextId; }; struct ClearFromHistory { ContextId partialContextId; }; struct ClearFromSession { uint64 sessionId = 0; }; struct ClearAll { }; struct ClearFinish { }; using ClearTask = std::variant< ClearFromItem, ClearFromTopic, ClearFromSublist, ClearFromHistory, ClearFromSession, ClearAll, ClearFinish>; std::vector _clearingTasks; Media::Audio::LocalDiskCache _sounds; NSString *_cachedMarkAsReadText = nil; rpl::lifetime _lifetime; }; [[nodiscard]] QString ResolveSoundsFolder() { NSArray *paths = NSSearchPathForDirectoriesInDomains( NSLibraryDirectory, NSUserDomainMask, YES); NSString *library = [paths firstObject]; NSString *sounds = [library stringByAppendingPathComponent : @"Sounds"]; return NS2QString(sounds); } void AddActionIdToNotification( NSUserNotification *notification, NSString *actionId) { NSMutableDictionary *mutableUserInfo = [[notification userInfo] mutableCopy]; [mutableUserInfo setObject:actionId forKey:@"actionId"]; [notification setUserInfo:mutableUserInfo]; [mutableUserInfo release]; } Manager::Private::Private(Manager *manager) : _managerId(base::RandomValue()) , _managerIdString(QString::number(_managerId)) , _delegate([[NotificationDelegate alloc] initWithManager:manager managerId:_managerId]) , _sounds(ResolveSoundsFolder()) { Core::App().settings().workModeValue( ) | rpl::on_next([=](Core::Settings::WorkMode mode) { // We need to update the delegate _after_ the tray icon change was done in Qt. // Because Qt resets the delegate. crl::on_main(this, [=] { updateDelegate(); }); }, _lifetime); initCachedMarkAsReadText(); } void Manager::Private::showNotification( NotificationInfo &&info, Ui::PeerUserpicView &userpicView) { @autoreleasepool { const auto peer = info.peer; NSUserNotification *notification = [[[NSUserNotification alloc] init] autorelease]; if ([notification respondsToSelector:@selector(setIdentifier:)]) { auto identifier = _managerIdString + '_' + QString::number(peer->id.value) + '_' + QString::number(info.itemId.bare); auto identifierValue = Q2NSString(identifier); [notification setIdentifier:identifierValue]; } [notification setUserInfo: [NSDictionary dictionaryWithObjectsAndKeys: [NSNumber numberWithUnsignedLongLong:peer->session().uniqueId()], @"session", [NSNumber numberWithUnsignedLongLong:peer->id.value], @"peer", [NSNumber numberWithLongLong:info.topicRootId.bare], @"topic", [NSNumber numberWithUnsignedLongLong:info.monoforumPeerId.value], @"monoforumpeer", [NSNumber numberWithLongLong:info.itemId.bare], @"msgid", [NSNumber numberWithUnsignedLongLong:_managerId], @"manager", nil]]; [notification setTitle:Q2NSString(info.title)]; [notification setSubtitle:Q2NSString(info.subtitle)]; [notification setInformativeText:Q2NSString(info.message)]; if (!info.options.hideNameAndPhoto && [notification respondsToSelector:@selector(setContentImage:)]) { NSImage *img = Q2NSImage( Window::Notifications::GenerateUserpic(peer, userpicView)); [notification setContentImage:img]; } if (!info.options.hideReplyButton && !info.options.hideMarkAsRead && [notification respondsToSelector:@selector(setHasReplyButton:)] && [notification respondsToSelector:@selector(setAdditionalActions:)]) { [notification setHasReplyButton:YES]; AddActionIdToNotification(notification, @"markAsRead"); [notification setAdditionalActions:@[ [NSUserNotificationAction actionWithIdentifier:@"markAsRead" title:_cachedMarkAsReadText] ]]; } else if (!info.options.hideReplyButton && [notification respondsToSelector:@selector(setHasReplyButton:)]) { [notification setHasReplyButton:YES]; } else if (!info.options.hideMarkAsRead && [notification respondsToSelector:@selector(setHasActionButton:)]) { [notification setHasActionButton:YES]; [notification setActionButtonTitle:_cachedMarkAsReadText]; AddActionIdToNotification(notification, @"markAsRead"); } const auto sound = info.sound ? info.sound() : Media::Audio::LocalSound(); if (sound) { [notification setSoundName:Q2NSString(_sounds.name(sound))]; } else { [notification setSoundName:nil]; } NSUserNotificationCenter *center = [NSUserNotificationCenter defaultUserNotificationCenter]; [center deliverNotification:notification]; } } void Manager::Private::clearingThreadLoop() { auto finished = false; while (!finished) { auto clearAll = false; auto clearFromItems = base::flat_set(); auto clearFromTopics = base::flat_set(); auto clearFromSublists = base::flat_set(); auto clearFromHistories = base::flat_set(); auto clearFromSessions = base::flat_set(); { std::unique_lock lock(_clearingMutex); while (_clearingTasks.empty()) { _clearingCondition.wait(lock); } for (auto &task : _clearingTasks) { v::match(task, [&](ClearFinish) { finished = true; clearAll = true; }, [&](ClearAll) { clearAll = true; }, [&](const ClearFromItem &value) { clearFromItems.emplace(value.id); }, [&](const ClearFromTopic &value) { clearFromTopics.emplace(value.contextId); }, [&](const ClearFromSublist &value) { clearFromSublists.emplace(value.contextId); }, [&](const ClearFromHistory &value) { clearFromHistories.emplace(value.partialContextId); }, [&](const ClearFromSession &value) { clearFromSessions.emplace(value.sessionId); }); } _clearingTasks.clear(); } @autoreleasepool { auto clearBySpecial = [&](NSDictionary *notificationUserInfo) { NSNumber *sessionObject = [notificationUserInfo objectForKey:@"session"]; const auto notificationSessionId = sessionObject ? [sessionObject unsignedLongLongValue] : 0; if (!notificationSessionId) { return true; } NSNumber *peerObject = [notificationUserInfo objectForKey:@"peer"]; const auto notificationPeerId = peerObject ? [peerObject unsignedLongLongValue] : 0; if (!notificationPeerId) { return true; } NSNumber *topicObject = [notificationUserInfo objectForKey:@"topic"]; if (!topicObject) { return true; } const auto notificationTopicRootId = [topicObject longLongValue]; NSNumber *monoforumPeerObject = [notificationUserInfo objectForKey:@"monoforumpeer"]; if (!monoforumPeerObject) { return true; } const auto notificationMonoforumPeerId = [monoforumPeerObject unsignedLongLongValue]; NSNumber *msgObject = [notificationUserInfo objectForKey:@"msgid"]; const auto msgId = msgObject ? [msgObject longLongValue] : 0LL; const auto partialContextId = ContextId{ .sessionId = notificationSessionId, .peerId = PeerId(notificationPeerId), }; const auto contextId = notificationTopicRootId ? ContextId{ .sessionId = notificationSessionId, .peerId = PeerId(notificationPeerId), .topicRootId = MsgId(notificationTopicRootId), } : notificationMonoforumPeerId ? ContextId{ .sessionId = notificationSessionId, .peerId = PeerId(notificationPeerId), .monoforumPeerId = PeerId(notificationMonoforumPeerId), } : partialContextId; const auto id = NotificationId{ contextId, MsgId(msgId) }; return clearFromSessions.contains(notificationSessionId) || clearFromHistories.contains(partialContextId) || clearFromTopics.contains(contextId) || clearFromSublists.contains(contextId) || (msgId && clearFromItems.contains(id)); }; NSUserNotificationCenter *center = [NSUserNotificationCenter defaultUserNotificationCenter]; NSArray *notificationsList = [center deliveredNotifications]; for (id notification in notificationsList) { NSDictionary *notificationUserInfo = [notification userInfo]; NSNumber *managerIdObject = [notificationUserInfo objectForKey:@"manager"]; auto notificationManagerId = managerIdObject ? [managerIdObject unsignedLongLongValue] : 0ULL; if (notificationManagerId == _managerId) { if (clearAll || clearBySpecial(notificationUserInfo)) { [center removeDeliveredNotification:notification]; } } } } } } template void Manager::Private::putClearTask(Task task) { if (!_clearingThread.joinable()) { _clearingThread = std::thread([this] { clearingThreadLoop(); }); } std::unique_lock lock(_clearingMutex); _clearingTasks.push_back(task); _clearingCondition.notify_one(); } void Manager::Private::clearAll() { putClearTask(ClearAll()); } void Manager::Private::clearFromItem(not_null item) { putClearTask(ClearFromItem{ ContextId{ .sessionId = item->history()->session().uniqueId(), .peerId = item->history()->peer->id, .topicRootId = item->topicRootId(), .monoforumPeerId = item->sublistPeerId(), }, item->id }); } void Manager::Private::clearFromTopic(not_null topic) { putClearTask(ClearFromTopic{ ContextId{ .sessionId = topic->session().uniqueId(), .peerId = topic->history()->peer->id, .topicRootId = topic->rootId(), } }); } void Manager::Private::clearFromSublist( not_null sublist) { putClearTask(ClearFromSublist{ ContextId{ .sessionId = sublist->session().uniqueId(), .peerId = sublist->owningHistory()->peer->id, .monoforumPeerId = sublist->sublistPeer()->id, } }); } void Manager::Private::clearFromHistory(not_null history) { putClearTask(ClearFromHistory{ ContextId{ .sessionId = history->session().uniqueId(), .peerId = history->peer->id, } }); } void Manager::Private::clearFromSession(not_null session) { putClearTask(ClearFromSession{ session->uniqueId() }); } void Manager::Private::updateDelegate() { NSUserNotificationCenter *center = [NSUserNotificationCenter defaultUserNotificationCenter]; [center setDelegate:_delegate]; } void Manager::Private::initCachedMarkAsReadText() { const auto langId = Lang::GetInstance().id(); const auto preferredLang = [[NSLocale preferredLanguages] firstObject]; const auto languageCode = [[NSLocale localeWithLocaleIdentifier:preferredLang] languageCode]; const auto defaults = [NSUserDefaults standardUserDefaults]; const auto cachedText = static_cast([defaults stringForKey:kTelegramMarkAsReadText]); const auto cachedTimestamp = static_cast([defaults objectForKey:kTelegramMarkAsReadTimestamp]); const auto cachedLanguageCode = static_cast([defaults stringForKey:kTelegramMarkAsReadLanguageCode]); const auto now = base::unixtime::now(); const auto shouldRefresh = !cachedTimestamp || (now - [cachedTimestamp longLongValue]) > kCacheExpirationSeconds || ![cachedLanguageCode isEqualToString:languageCode]; if (cachedText && !shouldRefresh) { _cachedMarkAsReadText = [cachedText retain]; } else { _cachedMarkAsReadText = [Q2NSString(tr::lng_context_mark_read(tr::now)) retain]; } if (langId == NS2QString(languageCode)) { [defaults setObject:Q2NSString(tr::lng_context_mark_read(tr::now)) forKey:kTelegramMarkAsReadText]; [defaults setObject:@(base::unixtime::now()) forKey:kTelegramMarkAsReadTimestamp]; [defaults setObject:languageCode forKey:kTelegramMarkAsReadLanguageCode]; } else if (shouldRefresh) { Lang::CurrentCloudManager().getValueForLang( u"lng_context_mark_read"_q, NS2QString(languageCode), [=](const QString &r) { if (r != NS2QString(_cachedMarkAsReadText)) { [_cachedMarkAsReadText release]; _cachedMarkAsReadText = [Q2NSString(r) retain]; } [defaults setObject:Q2NSString(r) forKey:kTelegramMarkAsReadText]; [defaults setObject:@(base::unixtime::now()) forKey:kTelegramMarkAsReadTimestamp]; [defaults setObject:languageCode forKey:kTelegramMarkAsReadLanguageCode]; }); } } Manager::Private::~Private() { if (_clearingThread.joinable()) { putClearTask(ClearFinish()); _clearingThread.join(); } NSUserNotificationCenter *center = [NSUserNotificationCenter defaultUserNotificationCenter]; [center setDelegate:nil]; [_delegate release]; [_cachedMarkAsReadText release]; } Manager::Manager(Window::Notifications::System *system) : NativeManager(system) , _private(std::make_unique(this)) { } Manager::~Manager() = default; void Manager::doShowNativeNotification( NotificationInfo &&info, Ui::PeerUserpicView &userpicView) { _private->showNotification(std::move(info), userpicView); } void Manager::doClearAllFast() { _private->clearAll(); } void Manager::doClearFromItem(not_null item) { _private->clearFromItem(item); } void Manager::doClearFromTopic(not_null topic) { _private->clearFromTopic(topic); } void Manager::doClearFromSublist(not_null sublist) { _private->clearFromSublist(sublist); } void Manager::doClearFromHistory(not_null history) { _private->clearFromHistory(history); } void Manager::doClearFromSession(not_null session) { _private->clearFromSession(session); } QString Manager::accountNameSeparator() { return QString::fromUtf8(" \xE2\x86\x92 "); } bool Manager::doSkipToast() const { return false; } void Manager::doMaybePlaySound(Fn playSound) { playSound(); } void Manager::doMaybeFlashBounce(Fn flashBounce) { flashBounce(); } } // namespace Notifications } // namespace Platform