diff --git a/Mac/AppDelegate.swift b/Mac/AppDelegate.swift index dd31e23ac..b5eb7e17e 100644 --- a/Mac/AppDelegate.swift +++ b/Mac/AppDelegate.swift @@ -455,7 +455,17 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSUserInterfaceValidations, } func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void) { - mainWindowController?.handle(response) + + let userInfo = response.notification.request.content.userInfo + + switch response.actionIdentifier { + case "MARK_AS_READ": + handleMarkAsRead(userInfo: userInfo) + case "MARK_AS_STARRED": + handleMarkAsStarred(userInfo: userInfo) + default: + mainWindowController?.handle(response) + } completionHandler() } @@ -791,3 +801,47 @@ extension AppDelegate: NSWindowRestoration { } } + +// Handle Notification Actions + +private extension AppDelegate { + + func handleMarkAsRead(userInfo: [AnyHashable: Any]) { + guard let articlePathUserInfo = userInfo[UserInfoKey.articlePath] as? [AnyHashable : Any], + let accountID = articlePathUserInfo[ArticlePathKey.accountID] as? String, + let articleID = articlePathUserInfo[ArticlePathKey.articleID] as? String else { + return + } + + let account = AccountManager.shared.existingAccount(with: accountID) + guard account != nil else { + os_log(.debug, "No account found from notification.") + return + } + let article = try? account!.fetchArticles(.articleIDs([articleID])) + guard article != nil else { + os_log(.debug, "No article found from search using %@", articleID) + return + } + account!.markArticles(article!, statusKey: .read, flag: true) + } + + func handleMarkAsStarred(userInfo: [AnyHashable: Any]) { + guard let articlePathUserInfo = userInfo[UserInfoKey.articlePath] as? [AnyHashable : Any], + let accountID = articlePathUserInfo[ArticlePathKey.accountID] as? String, + let articleID = articlePathUserInfo[ArticlePathKey.articleID] as? String else { + return + } + let account = AccountManager.shared.existingAccount(with: accountID) + guard account != nil else { + os_log(.debug, "No account found from notification.") + return + } + let article = try? account!.fetchArticles(.articleIDs([articleID])) + guard article != nil else { + os_log(.debug, "No article found from search using %@", articleID) + return + } + account!.markArticles(article!, statusKey: .starred, flag: true) + } +} diff --git a/NetNewsWire.xcodeproj/project.pbxproj b/NetNewsWire.xcodeproj/project.pbxproj index 445e0738f..efdd3fa05 100644 --- a/NetNewsWire.xcodeproj/project.pbxproj +++ b/NetNewsWire.xcodeproj/project.pbxproj @@ -1293,11 +1293,15 @@ dstPath = ""; dstSubfolderSpec = 10; files = ( + 17E0081625936DFF000C23F0 /* Secrets in Embed Frameworks */, 17A1598924E3DEDD005DA32A /* RSParser in Embed Frameworks */, 17A1598624E3DEDD005DA32A /* RSDatabase in Embed Frameworks */, 17A1598324E3DEDD005DA32A /* RSWeb in Embed Frameworks */, + 17E0081925936DFF000C23F0 /* SyncDatabase in Embed Frameworks */, + 17E0081025936DF6000C23F0 /* Articles in Embed Frameworks */, 17A1597D24E3DEDD005DA32A /* RSCore in Embed Frameworks */, 17A1598024E3DEDD005DA32A /* RSTree in Embed Frameworks */, + 17E0081325936DF6000C23F0 /* ArticlesDatabase in Embed Frameworks */, ); name = "Embed Frameworks"; runOnlyForDeploymentPostprocessing = 0; @@ -2046,7 +2050,6 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - 51BC2F4B24D343A500E90810 /* Account in Frameworks */, 513F32882593EF8F0003048F /* RSCore in Frameworks */, 51BC2F4D24D343AB00E90810 /* RSTree in Frameworks */, ); @@ -2079,7 +2082,11 @@ 17A1597C24E3DEDD005DA32A /* RSCore in Frameworks */, 516B695D24D2F28E00B5702F /* Account in Frameworks */, 17A1598524E3DEDD005DA32A /* RSDatabase in Frameworks */, + 17E0080F25936DF6000C23F0 /* Articles in Frameworks */, + 17E0081525936DFF000C23F0 /* Secrets in Frameworks */, 51E4989724A8065700B667CB /* CloudKit.framework in Frameworks */, + 17E0081225936DF6000C23F0 /* ArticlesDatabase in Frameworks */, + 17E0081825936DFF000C23F0 /* SyncDatabase in Frameworks */, 51E4989924A8067000B667CB /* WebKit.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -3799,6 +3806,10 @@ 17A1598124E3DEDD005DA32A /* RSWeb */, 17A1598424E3DEDD005DA32A /* RSDatabase */, 17A1598724E3DEDD005DA32A /* RSParser */, + 17E0080E25936DF6000C23F0 /* Articles */, + 17E0081125936DF6000C23F0 /* ArticlesDatabase */, + 17E0081425936DFF000C23F0 /* Secrets */, + 17E0081725936DFF000C23F0 /* SyncDatabase */, ); productName = iOS; productReference = 51C0513D24A77DF800194D5E /* NetNewsWire.app */; @@ -6162,6 +6173,48 @@ package = 51B0DF2324D2C7FA000AD99E /* XCRemoteSwiftPackageReference "RSParser" */; productName = RSParser; }; + 17E007F925936D7B000C23F0 /* RSCore */ = { + isa = XCSwiftPackageProductDependency; + package = 5102AE4324D17E820050839C /* XCRemoteSwiftPackageReference "RSCore" */; + productName = RSCore; + }; + 17E0080125936D89000C23F0 /* RSCore */ = { + isa = XCSwiftPackageProductDependency; + package = 5102AE4324D17E820050839C /* XCRemoteSwiftPackageReference "RSCore" */; + productName = RSCore; + }; + 17E0080E25936DF6000C23F0 /* Articles */ = { + isa = XCSwiftPackageProductDependency; + productName = Articles; + }; + 17E0081125936DF6000C23F0 /* ArticlesDatabase */ = { + isa = XCSwiftPackageProductDependency; + productName = ArticlesDatabase; + }; + 17E0081425936DFF000C23F0 /* Secrets */ = { + isa = XCSwiftPackageProductDependency; + productName = Secrets; + }; + 17E0081725936DFF000C23F0 /* SyncDatabase */ = { + isa = XCSwiftPackageProductDependency; + productName = SyncDatabase; + }; + 17E0081F25936E31000C23F0 /* Articles */ = { + isa = XCSwiftPackageProductDependency; + productName = Articles; + }; + 17E0082225936E31000C23F0 /* ArticlesDatabase */ = { + isa = XCSwiftPackageProductDependency; + productName = ArticlesDatabase; + }; + 17E0082525936E31000C23F0 /* Secrets */ = { + isa = XCSwiftPackageProductDependency; + productName = Secrets; + }; + 17E0082825936E31000C23F0 /* SyncDatabase */ = { + isa = XCSwiftPackageProductDependency; + productName = SyncDatabase; + }; 5102AE6824D17F7C0050839C /* RSCore */ = { isa = XCSwiftPackageProductDependency; package = 5102AE4324D17E820050839C /* XCRemoteSwiftPackageReference "RSCore" */; diff --git a/Shared/Extensions/ArticleUtilities.swift b/Shared/Extensions/ArticleUtilities.swift index aeee931ae..6f6cd5126 100644 --- a/Shared/Extensions/ArticleUtilities.swift +++ b/Shared/Extensions/ArticleUtilities.swift @@ -103,6 +103,22 @@ extension Article { return FaviconGenerator.favicon(webFeed) } + func iconImageUrl(webFeed: WebFeed) -> URL? { + if let image = iconImage() { + let fm = FileManager.default + var path = fm.urls(for: .cachesDirectory, in: .userDomainMask)[0] + #if os(macOS) + path.appendPathComponent(webFeed.webFeedID + "_smallIcon.tiff") + #else + path.appendPathComponent(webFeed.webFeedID + "_smallIcon.png") + #endif + fm.createFile(atPath: path.path, contents: image.image.dataRepresentation()!, attributes: nil) + return path + } else { + return nil + } + } + func byline() -> String { guard let authors = authors ?? webFeed?.authors, !authors.isEmpty else { return "" diff --git a/Shared/UserNotifications/UserNotificationManager.swift b/Shared/UserNotifications/UserNotificationManager.swift index 6ddbad025..50fe22227 100644 --- a/Shared/UserNotifications/UserNotificationManager.swift +++ b/Shared/UserNotifications/UserNotificationManager.swift @@ -17,6 +17,7 @@ final class UserNotificationManager: NSObject { super.init() NotificationCenter.default.addObserver(self, selector: #selector(accountDidDownloadArticles(_:)), name: .AccountDidDownloadArticles, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(statusesDidChange(_:)), name: .StatusesDidChange, object: nil) + registerCategoriesAndActions() } @objc func accountDidDownloadArticles(_ note: Notification) { @@ -43,26 +44,55 @@ final class UserNotificationManager: NSObject { private extension UserNotificationManager { - private func sendNotification(webFeed: WebFeed, article: Article) { + func sendNotification(webFeed: WebFeed, article: Article) { let content = UNMutableNotificationContent() content.title = webFeed.nameForDisplay - if !ArticleStringFormatter.truncatedTitle(article).isEmpty { content.subtitle = ArticleStringFormatter.truncatedTitle(article) } - content.body = ArticleStringFormatter.truncatedSummary(article) - content.threadIdentifier = webFeed.webFeedID content.summaryArgument = "\(webFeed.nameForDisplay)" content.summaryArgumentCount = 1 - content.sound = UNNotificationSound.default content.userInfo = [UserInfoKey.articlePath: article.pathUserInfo] + content.categoryIdentifier = "NEW_ARTICLE_NOTIFICATION_CATEGORY" + if let attachment = thumbnailAttachment(for: article, webFeed: webFeed) { + content.attachments.append(attachment) + } let request = UNNotificationRequest.init(identifier: "articleID:\(article.articleID)", content: content, trigger: nil) UNUserNotificationCenter.current().add(request) } + /// Determine if there is an available icon for the article. This will then move it to the caches directory and make it avialble for the notification. + /// - Parameters: + /// - article: `Article` + /// - webFeed: `WebFeed` + /// - Returns: A `UNNotifcationAttachment` if an icon is available. Otherwise nil. + /// - Warning: In certain scenarios, this will return the `faviconTemplateImage`. + func thumbnailAttachment(for article: Article, webFeed: WebFeed) -> UNNotificationAttachment? { + if let imageURL = article.iconImageUrl(webFeed: webFeed) { + let thumbnail = try? UNNotificationAttachment(identifier: webFeed.webFeedID, url: imageURL, options: nil) + return thumbnail + } + return nil + } + + func registerCategoriesAndActions() { + let readAction = UNNotificationAction(identifier: "MARK_AS_READ", title: NSLocalizedString("Mark as Read", comment: "Mark as Read"), options: []) + let starredAction = UNNotificationAction(identifier: "MARK_AS_STARRED", title: NSLocalizedString("Mark as Starred", comment: "Mark as Starred"), options: []) + let openAction = UNNotificationAction(identifier: "OPEN_ARTICLE", title: NSLocalizedString("Open", comment: "Open"), options: [.foreground]) + + let newArticleCategory = + UNNotificationCategory(identifier: "NEW_ARTICLE_NOTIFICATION_CATEGORY", + actions: [openAction, readAction, starredAction], + intentIdentifiers: [], + hiddenPreviewsBodyPlaceholder: "", + options: .customDismissAction) + + UNUserNotificationCenter.current().setNotificationCategories([newArticleCategory]) + } + } diff --git a/Widget/Widget Views/StarredWidget.swift b/Widget/Widget Views/StarredWidget.swift index 6a6b6dce9..0928a322a 100644 --- a/Widget/Widget Views/StarredWidget.swift +++ b/Widget/Widget Views/StarredWidget.swift @@ -24,7 +24,7 @@ struct StarredWidgetView : View { else { GeometryReader { metrics in HStack(alignment: .top, spacing: 4) { - VStack(alignment: .leading) { + VStack(alignment: .leading, spacing: -4) { starredImage Spacer() Text(L10n.localizedCount(entry.widgetData.currentStarredCount)).bold().font(.callout).minimumScaleFactor(0.5).lineLimit(1) diff --git a/Widget/Widget Views/TodayWidget.swift b/Widget/Widget Views/TodayWidget.swift index d4dc64fe2..436782689 100644 --- a/Widget/Widget Views/TodayWidget.swift +++ b/Widget/Widget Views/TodayWidget.swift @@ -24,7 +24,7 @@ struct TodayWidgetView : View { else { GeometryReader { metrics in HStack(alignment: .top, spacing: 4) { - VStack(alignment: .leading) { + VStack(alignment: .leading, spacing: -4) { todayImage Spacer() Text(L10n.localizedCount(entry.widgetData.currentTodayCount)).bold().font(.callout).minimumScaleFactor(0.5).lineLimit(1) diff --git a/Widget/Widget Views/UnreadWidget.swift b/Widget/Widget Views/UnreadWidget.swift index afd270986..38d0c179b 100644 --- a/Widget/Widget Views/UnreadWidget.swift +++ b/Widget/Widget Views/UnreadWidget.swift @@ -24,7 +24,7 @@ struct UnreadWidgetView : View { else { GeometryReader { metrics in HStack(alignment: .top, spacing: 4) { - VStack(alignment: .leading) { + VStack(alignment: .leading, spacing: -4) { unreadImage Spacer() Text(L10n.localizedCount(entry.widgetData.currentUnreadCount)).bold().font(.callout).minimumScaleFactor(0.5).lineLimit(1) diff --git a/iOS/AppDelegate.swift b/iOS/AppDelegate.swift index 180536ae5..da8c4dafd 100644 --- a/iOS/AppDelegate.swift +++ b/iOS/AppDelegate.swift @@ -191,10 +191,19 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void) { defer { completionHandler() } - if let sceneDelegate = response.targetScene?.delegate as? SceneDelegate { - sceneDelegate.handle(response) + let userInfo = response.notification.request.content.userInfo + + switch response.actionIdentifier { + case "MARK_AS_READ": + handleMarkAsRead(userInfo: userInfo) + case "MARK_AS_STARRED": + handleMarkAsStarred(userInfo: userInfo) + default: + if let sceneDelegate = response.targetScene?.delegate as? SceneDelegate { + sceneDelegate.handle(response) + } } - + } } @@ -397,3 +406,67 @@ private extension AppDelegate { } } + +// Handle Notification Actions + +private extension AppDelegate { + + func handleMarkAsRead(userInfo: [AnyHashable: Any]) { + guard let articlePathUserInfo = userInfo[UserInfoKey.articlePath] as? [AnyHashable : Any], + let accountID = articlePathUserInfo[ArticlePathKey.accountID] as? String, + let articleID = articlePathUserInfo[ArticlePathKey.articleID] as? String else { + return + } + resumeDatabaseProcessingIfNecessary() + let account = AccountManager.shared.existingAccount(with: accountID) + guard account != nil else { + os_log(.debug, "No account found from notification.") + return + } + let article = try? account!.fetchArticles(.articleIDs([articleID])) + guard article != nil else { + os_log(.debug, "No article found from search using %@", articleID) + return + } + account!.markArticles(article!, statusKey: .read, flag: true) + self.prepareAccountsForBackground() + account!.syncArticleStatus(completion: { [weak self] _ in + if !AccountManager.shared.isSuspended { + if #available(iOS 14, *) { + try? WidgetDataEncoder.shared.encodeWidgetData() + } + self?.prepareAccountsForBackground() + self?.suspendApplication() + } + }) + } + + func handleMarkAsStarred(userInfo: [AnyHashable: Any]) { + guard let articlePathUserInfo = userInfo[UserInfoKey.articlePath] as? [AnyHashable : Any], + let accountID = articlePathUserInfo[ArticlePathKey.accountID] as? String, + let articleID = articlePathUserInfo[ArticlePathKey.articleID] as? String else { + return + } + resumeDatabaseProcessingIfNecessary() + let account = AccountManager.shared.existingAccount(with: accountID) + guard account != nil else { + os_log(.debug, "No account found from notification.") + return + } + let article = try? account!.fetchArticles(.articleIDs([articleID])) + guard article != nil else { + os_log(.debug, "No article found from search using %@", articleID) + return + } + account!.markArticles(article!, statusKey: .starred, flag: true) + account!.syncArticleStatus(completion: { [weak self] _ in + if !AccountManager.shared.isSuspended { + if #available(iOS 14, *) { + try? WidgetDataEncoder.shared.encodeWidgetData() + } + self?.prepareAccountsForBackground() + self?.suspendApplication() + } + }) + } +}