Merge pull request #2686 from stuartbreckenridge/richer-notifications

Richer notifications
This commit is contained in:
Maurice Parker 2020-12-23 19:34:57 -06:00 committed by GitHub
commit 00ab05c95a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 239 additions and 13 deletions

View File

@ -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)
}
}

View File

@ -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" */;

View File

@ -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 ""

View File

@ -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])
}
}

View File

@ -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)

View File

@ -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)

View File

@ -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)

View File

@ -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()
}
})
}
}