From 0a23819428232f739c4afac8d5a82788e8e04791 Mon Sep 17 00:00:00 2001 From: relikd Date: Sat, 25 Oct 2025 11:32:38 +0200 Subject: [PATCH] feat: notifications --- baRSS.xcodeproj/project.pbxproj | 18 +- baRSS/AppHook.m | 5 + baRSS/Core Data/Feed+Ext.h | 2 + baRSS/Core Data/Feed+Ext.m | 18 ++ baRSS/Core Data/FeedArticle+Ext.h | 1 + baRSS/Core Data/FeedArticle+Ext.m | 9 + baRSS/Core Data/StoreCoordinator.h | 2 +- baRSS/Core Data/StoreCoordinator.m | 35 ++-- baRSS/Feed Import/UpdateScheduler.h | 2 +- baRSS/Feed Import/UpdateScheduler.m | 40 +++- baRSS/Helper/UserPrefs.h | 11 ++ baRSS/Helper/UserPrefs.m | 19 ++ baRSS/Notifications/NotifyEndpoint.h | 18 ++ baRSS/Notifications/NotifyEndpoint.m | 171 ++++++++++++++++++ .../Feeds Tab/SettingsFeeds+DragDrop.m | 2 +- .../Preferences/General Tab/SettingsGeneral.h | 1 + .../Preferences/General Tab/SettingsGeneral.m | 23 +++ .../General Tab/SettingsGeneralView.h | 1 + .../General Tab/SettingsGeneralView.m | 8 + baRSS/Status Bar Menu/BarStatusItem.m | 3 + baRSS/Status Bar Menu/NSMenu+Ext.m | 4 +- 21 files changed, 372 insertions(+), 21 deletions(-) create mode 100644 baRSS/Notifications/NotifyEndpoint.h create mode 100644 baRSS/Notifications/NotifyEndpoint.m diff --git a/baRSS.xcodeproj/project.pbxproj b/baRSS.xcodeproj/project.pbxproj index 7c00454..61d9544 100644 --- a/baRSS.xcodeproj/project.pbxproj +++ b/baRSS.xcodeproj/project.pbxproj @@ -22,6 +22,7 @@ 544F5A752E30EFC700674F81 /* style.css in Resources */ = {isa = PBXBuildFile; fileRef = 544F5A722E30EFC700674F81 /* style.css */; }; 544F5A762E30EFC700674F81 /* opml-lib.m in Sources */ = {isa = PBXBuildFile; fileRef = 544F5A702E30EFC700674F81 /* opml-lib.m */; }; 54501010230E9C8600F0B165 /* FeedDownload.m in Sources */ = {isa = PBXBuildFile; fileRef = 5450100F230E9C8600F0B165 /* FeedDownload.m */; }; + 5469E13C2EA90C6C00D46CE7 /* NotifyEndpoint.m in Sources */ = {isa = PBXBuildFile; fileRef = 5469E13B2EA90C6C00D46CE7 /* NotifyEndpoint.m */; }; 546A6A2922C583390034E806 /* SettingsGeneralView.m in Sources */ = {isa = PBXBuildFile; fileRef = 54D857D122802309001BA1C8 /* SettingsGeneralView.m */; }; 546A6A2C22C584AF0034E806 /* SettingsAppearanceView.m in Sources */ = {isa = PBXBuildFile; fileRef = 546A6A2A22C584AF0034E806 /* SettingsAppearanceView.m */; }; 546A6A2F22C585580034E806 /* SettingsAboutView.m in Sources */ = {isa = PBXBuildFile; fileRef = 546A6A2D22C585580034E806 /* SettingsAboutView.m */; }; @@ -149,6 +150,8 @@ 544F5A722E30EFC700674F81 /* style.css */ = {isa = PBXFileReference; lastKnownFileType = text.css; path = style.css; sourceTree = ""; }; 5450100E230E9C8600F0B165 /* FeedDownload.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = FeedDownload.h; sourceTree = ""; }; 5450100F230E9C8600F0B165 /* FeedDownload.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = FeedDownload.m; sourceTree = ""; }; + 5469E13A2EA90C6C00D46CE7 /* NotifyEndpoint.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = NotifyEndpoint.h; sourceTree = ""; }; + 5469E13B2EA90C6C00D46CE7 /* NotifyEndpoint.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = NotifyEndpoint.m; sourceTree = ""; }; 546A6A2A22C584AF0034E806 /* SettingsAppearanceView.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = SettingsAppearanceView.m; sourceTree = ""; }; 546A6A2B22C584AF0034E806 /* SettingsAppearanceView.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = SettingsAppearanceView.h; sourceTree = ""; }; 546A6A2D22C585580034E806 /* SettingsAboutView.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = SettingsAboutView.m; sourceTree = ""; }; @@ -305,6 +308,15 @@ path = NSCategories; sourceTree = ""; }; + 5469E1372EA90C3500D46CE7 /* Notifications */ = { + isa = PBXGroup; + children = ( + 5469E13A2EA90C6C00D46CE7 /* NotifyEndpoint.h */, + 5469E13B2EA90C6C00D46CE7 /* NotifyEndpoint.m */, + ); + path = Notifications; + sourceTree = ""; + }; 546FC44D2118B357007CC3A3 /* Preferences */ = { isa = PBXGroup; children = ( @@ -382,6 +394,7 @@ 54E9CF2F225913850023696F /* Helper */, 544936F721F1E51E00DEE9AA /* NSCategories */, 541A90EF21257D4F002680A6 /* Status Bar Menu */, + 5469E1372EA90C3500D46CE7 /* Notifications */, 54A07A8322105E0800082C51 /* Core Data */, 54AD4E04230084FD000AE386 /* Feed Import */, 54253C862C49A5A900742695 /* Regex Editor */, @@ -717,6 +730,7 @@ 548C6D0A230C33DE003A1AAF /* NSURL+Ext.m in Sources */, 54E88320211B509D00064188 /* ModalFeedEdit.m in Sources */, 54195883218A061100581B79 /* Feed+Ext.m in Sources */, + 5469E13C2EA90C6C00D46CE7 /* NotifyEndpoint.m in Sources */, 54501010230E9C8600F0B165 /* FeedDownload.m in Sources */, 54209E942117325100F3B5EF /* DrawImage.m in Sources */, 54253C942C49BFDC00742695 /* RegexConverterController.m in Sources */, @@ -791,7 +805,7 @@ CLANG_WARN_UNREACHABLE_CODE = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 16461; + CURRENT_PROJECT_VERSION = 16650; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = UY657LKNHJ; @@ -852,7 +866,7 @@ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; CODE_SIGN_INJECT_BASE_ENTITLEMENTS = NO; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 16461; + CURRENT_PROJECT_VERSION = 16650; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = UY657LKNHJ; diff --git a/baRSS/AppHook.m b/baRSS/AppHook.m index 4e9859e..88ca866 100644 --- a/baRSS/AppHook.m +++ b/baRSS/AppHook.m @@ -7,6 +7,7 @@ #import "StoreCoordinator.h" #import "SettingsFeeds+DragDrop.h" #import "URLScheme.h" +#import "NotifyEndpoint.h" #import "NSURL+Ext.h" #import "NSError+Ext.h" @@ -45,6 +46,10 @@ // mostly for version migration 0.9.4 ~> 1.0 (favicon storage) if (initial) [UpdateScheduler updateAllFavicons]; } + + // Notifications are disabled by default so this wont trigger for first app launch. + // Also, this will register the notification delegate and respond to click & open feed. + [NotifyEndpoint activate]; } - (void)applicationWillTerminate:(NSNotification *)aNotification { diff --git a/baRSS/Core Data/Feed+Ext.h b/baRSS/Core Data/Feed+Ext.h index 2a40be4..365e245 100644 --- a/baRSS/Core Data/Feed+Ext.h +++ b/baRSS/Core Data/Feed+Ext.h @@ -10,6 +10,7 @@ NS_ASSUME_NONNULL_BEGIN // Generator methods / Feed update + (instancetype)newFeedAndMetaInContext:(NSManagedObjectContext*)context; +- (NSString*)notificationID; - (void)updateWithRSS:(RSParsedFeed*)obj postUnreadCountChange:(BOOL)flag; - (NSMenuItem*)newMenuItem; // Getter & Setter @@ -17,6 +18,7 @@ NS_ASSUME_NONNULL_BEGIN - (void)setNewIcon:(NSURL*)location; // Article properties - (nullable NSArray*)sortedArticles; +- (NSUInteger)countUnread; @end NS_ASSUME_NONNULL_END diff --git a/baRSS/Core Data/Feed+Ext.m b/baRSS/Core Data/Feed+Ext.m index 609c627..e8a2274 100644 --- a/baRSS/Core Data/Feed+Ext.m +++ b/baRSS/Core Data/Feed+Ext.m @@ -6,6 +6,7 @@ #import "FeedGroup+Ext.h" #import "FeedArticle+Ext.h" #import "StoreCoordinator.h" +#import "NotifyEndpoint.h" #import "NSURL+Ext.h" @implementation Feed (Ext) @@ -17,6 +18,11 @@ return feed; } +/// unique ID used for notifications. returns @c objectID.URIRepresentation.absoluteString +- (NSString*)notificationID { + return self.objectID.URIRepresentation.absoluteString; +} + /// Call @c indexPathString on @c .group and update @c .indexPath if current value is different. - (void)calculateAndSetIndexPathString { NSString *pthStr = [self.group indexPathString]; @@ -110,10 +116,12 @@ - (NSUInteger)deleteArticles:(NSMutableSet*)localSet withRemoteSet:(NSArray*)remoteSet { NSUInteger c = 0; NSMutableSet *deletingSet = [NSMutableSet setWithCapacity:localSet.count]; + NSMutableArray *dismissed = [NSMutableArray array]; for (FeedArticle *fa in localSet) { if (![self findLocalArticle:fa inRemoteSet:remoteSet]) { if (fa.unread) ++c; // TODO: keep unread articles? + [dismissed addObject:fa.notificationID]; [self.managedObjectContext deleteObject:fa]; [deletingSet addObject:fa]; } @@ -121,6 +129,7 @@ if (deletingSet.count > 0) { [localSet minusSet:deletingSet]; [self removeArticles:deletingSet]; + [NotifyEndpoint dismiss:dismissed]; } return c; } @@ -176,6 +185,15 @@ return nil; } +/// Number of unread articles +- (NSUInteger)countUnread { + NSUInteger count = 0; + for (FeedArticle *article in self.articles) { + count += article.unread; + } + return count; +} + #pragma mark - Icon - diff --git a/baRSS/Core Data/FeedArticle+Ext.h b/baRSS/Core Data/FeedArticle+Ext.h index 8adac94..bb72e05 100644 --- a/baRSS/Core Data/FeedArticle+Ext.h +++ b/baRSS/Core Data/FeedArticle+Ext.h @@ -6,6 +6,7 @@ NS_ASSUME_NONNULL_BEGIN @interface FeedArticle (Ext) + (instancetype)newArticle:(RSParsedArticle*)entry inContext:(NSManagedObjectContext*)moc; +- (NSString*)notificationID; - (void)updateArticleIfChanged:(RSParsedArticle*)entry; - (NSMenuItem*)newMenuItem; @end diff --git a/baRSS/Core Data/FeedArticle+Ext.m b/baRSS/Core Data/FeedArticle+Ext.m index 2284955..cd99d3a 100644 --- a/baRSS/Core Data/FeedArticle+Ext.m +++ b/baRSS/Core Data/FeedArticle+Ext.m @@ -1,8 +1,10 @@ @import RSXML2.RSParsedArticle; #import "FeedArticle+Ext.h" +#import "Feed+Ext.h" #import "Constants.h" #import "UserPrefs.h" #import "StoreCoordinator.h" +#import "NotifyEndpoint.h" #import "NSString+Ext.h" @implementation FeedArticle (Ext) @@ -25,6 +27,11 @@ return fa; } +/// unique ID used for notifications. returns @c objectID.URIRepresentation.absoluteString +- (NSString*)notificationID { + return self.objectID.URIRepresentation.absoluteString; +} + - (void)updateArticleIfChanged:(RSParsedArticle*)entry { [self setGuidIfChanged:entry.guid]; [self setTitleIfChanged:entry.title]; @@ -77,6 +84,8 @@ [StoreCoordinator saveContext:moc andParent:YES]; NSNumber *num = (fa.unread ? @+1 : @-1); PostNotification(kNotificationTotalUnreadCountChanged, num); + + [NotifyEndpoint dismiss:fa.feed.countUnread > 0 ? @[fa.notificationID] : @[fa.notificationID, fa.feed.notificationID]]; } [moc reset]; } diff --git a/baRSS/Core Data/StoreCoordinator.h b/baRSS/Core Data/StoreCoordinator.h index 87cc44b..afd7b8c 100644 --- a/baRSS/Core Data/StoreCoordinator.h +++ b/baRSS/Core Data/StoreCoordinator.h @@ -30,7 +30,7 @@ NS_ASSUME_NONNULL_BEGIN // Unread articles list & mark articled read + (NSArray*)articlesAtPath:(nullable NSString*)path isFeed:(BOOL)feedFlag sorted:(BOOL)sortFlag unread:(BOOL)readFlag inContext:(NSManagedObjectContext*)moc limit:(NSUInteger)limit; -+ (void)updateArticles:(NSArray*)list markRead:(BOOL)markRead andOpen:(BOOL)openLinks inContext:(NSManagedObjectContext*)moc; ++ (NSArray*)updateArticles:(NSArray*)list markRead:(BOOL)markRead andOpen:(BOOL)openLinks inContext:(NSManagedObjectContext*)moc; // Restore sound state + (void)cleanupAndShowAlert:(BOOL)flag; diff --git a/baRSS/Core Data/StoreCoordinator.m b/baRSS/Core Data/StoreCoordinator.m index f27049b..034b8df 100644 --- a/baRSS/Core Data/StoreCoordinator.m +++ b/baRSS/Core Data/StoreCoordinator.m @@ -4,6 +4,7 @@ #import "FaviconDownload.h" #import "UserPrefs.h" #import "Feed+Ext.h" +#import "FeedArticle+Ext.h" #import "NSURL+Ext.h" #import "NSError+Ext.h" #import "NSFetchRequest+Ext.h" @@ -210,28 +211,40 @@ @param list Should only contain @c FeedArticle @param markRead Whether the articles should be marked read or unread. @param openLinks Whether to open the link or mark read without opening + + @return @c notificationID for all articles that were opened (empty if @c openLinks=NO or open failed). */ -+ (void)updateArticles:(NSArray*)list markRead:(BOOL)markRead andOpen:(BOOL)openLinks inContext:(NSManagedObjectContext*)moc { - BOOL success = NO; ++ (NSArray*)updateArticles:(NSArray*)list markRead:(BOOL)markRead andOpen:(BOOL)openLinks inContext:(NSManagedObjectContext*)moc { if (openLinks) { NSMutableArray *urls = [NSMutableArray arrayWithCapacity:list.count]; for (FeedArticle *fa in list) { if (fa.link.length > 0) [urls addObject:[NSURL URLWithString:fa.link]]; } - if (urls.count > 0) - success = UserPrefsOpenURLs(urls); + if (urls.count > 0 && !UserPrefsOpenURLs(urls)) + return nil; // if success == NO, do not modify unread state & exit } - // if success == NO, do not modify unread state - if (!openLinks || success) { - for (FeedArticle *fa in list) { + + for (FeedArticle *fa in list) { + if (fa.unread == markRead) { // only if differs fa.unread = !markRead; } - [self saveContext:moc andParent:YES]; - [moc reset]; - NSNumber *num = [NSNumber numberWithInteger: (markRead ? -1 : +1) * (NSInteger)list.count ]; - PostNotification(kNotificationTotalUnreadCountChanged, num); } + [self saveContext:moc andParent:YES]; + + // gather uri-ids for notification dismiss + NSMutableArray *dbRefs = [NSMutableArray array]; + if (markRead) { + for (FeedArticle *fa in list) { + [dbRefs addObject:fa.notificationID]; + [dbRefs addObject:fa.feed.notificationID]; + } + } + + [moc reset]; + NSNumber *num = [NSNumber numberWithInteger: (markRead ? -1 : +1) * (NSInteger)list.count ]; + PostNotification(kNotificationTotalUnreadCountChanged, num); + return dbRefs; } diff --git a/baRSS/Feed Import/UpdateScheduler.h b/baRSS/Feed Import/UpdateScheduler.h index 46d0b52..38b869c 100644 --- a/baRSS/Feed Import/UpdateScheduler.h +++ b/baRSS/Feed Import/UpdateScheduler.h @@ -17,7 +17,7 @@ NS_ASSUME_NONNULL_BEGIN // Scheduling + (void)scheduleNextFeed; + (void)forceUpdateAllFeeds; -+ (void)downloadList:(NSArray*)list userInitiated:(BOOL)flag finally:(nullable os_block_t)block; ++ (void)downloadList:(NSArray*)list userInitiated:(BOOL)flag notifications:(BOOL)notify finally:(nullable os_block_t)block; + (void)updateAllFavicons; // Auto Download & Parse Feed URL + (void)autoDownloadAndParseURL:(NSString*)url; diff --git a/baRSS/Feed Import/UpdateScheduler.m b/baRSS/Feed Import/UpdateScheduler.m index 0c924de..73b5522 100644 --- a/baRSS/Feed Import/UpdateScheduler.m +++ b/baRSS/Feed Import/UpdateScheduler.m @@ -2,11 +2,13 @@ #import "UpdateScheduler.h" #import "Constants.h" #import "StoreCoordinator.h" +#import "NotifyEndpoint.h" #import "NSDate+Ext.h" #import "FeedDownload.h" #import "FaviconDownload.h" #import "Feed+Ext.h" +#import "FeedArticle+Ext.h" #import "FeedMeta+Ext.h" #import "FeedGroup+Ext.h" @@ -129,7 +131,7 @@ static _Atomic(NSUInteger) _queueSize = 0; NSArray *list = [StoreCoordinator listOfFeedsThatNeedUpdate:updateAll inContext:moc]; //NSAssert(list.count > 0, @"ERROR: Something went wrong, timer fired too early."); - [self downloadList:list userInitiated:updateAll finally:^{ + [self downloadList:list userInitiated:updateAll notifications:YES finally:^{ [StoreCoordinator saveContext:moc andParent:YES]; // save parents too ... [moc reset]; [self scheduleNextFeed]; // always reset the timer @@ -147,7 +149,7 @@ static _Atomic(NSUInteger) _queueSize = 0; } /// Download list of feeds. Either silently in background or with alerts in foreground. -+ (void)downloadList:(NSArray*)list userInitiated:(BOOL)flag finally:(nullable os_block_t)block { ++ (void)downloadList:(NSArray*)list userInitiated:(BOOL)flag notifications:(BOOL)notify finally:(nullable os_block_t)block { if (![self allowNetworkConnection]) { if (block) block(); return; @@ -158,7 +160,7 @@ static _Atomic(NSUInteger) _queueSize = 0; dispatch_group_t group = dispatch_group_create(); for (Feed *f in list) { dispatch_group_enter(group); - [self updateFeed:f alert:flag isForced:flag finally:^{ + [self updateFeed:f alert:flag isForced:flag notifications:notify finally:^{ atomic_fetch_sub_explicit(&_queueSize, 1, memory_order_relaxed); PostNotification(kNotificationBackgroundUpdateInProgress, @(_queueSize)); dispatch_group_leave(group); @@ -178,7 +180,7 @@ static inline void AlertDownloadError(NSError *err, NSString *url) { Start download request with existing @c Feed object. Reuses etag and modified headers (unless articles count is 0). @note Will post a @c kNotificationArticlesUpdated notification if download was successful and status code is @b not 304. */ -+ (void)updateFeed:(Feed*)feed alert:(BOOL)alert isForced:(BOOL)forced finally:(nullable os_block_t)block { ++ (void)updateFeed:(Feed*)feed alert:(BOOL)alert isForced:(BOOL)forced notifications:(BOOL)notify finally:(nullable os_block_t)block { NSManagedObjectContext *moc = feed.managedObjectContext; NSManagedObjectID *oid = feed.objectID; [[FeedDownload withFeed:feed forced:forced] startWithBlock:^(FeedDownload *mem) { @@ -188,7 +190,37 @@ static inline void AlertDownloadError(NSError *err, NSString *url) { BOOL recentlyAdded = (f.articles.count == 0); // before copy values BOOL downloadIcon = (!f.hasIcon && (recentlyAdded || forced)); BOOL needsNotification = [mem copyValuesTo:f ignoreError:NO]; + + // need to gather object before save, because afterwards list will be empty + NSArray *inserted = notify ? moc.insertedObjects.allObjects : nil; + NSArray *deleted = moc.deletedObjects.allObjects; + [StoreCoordinator saveContext:moc andParent:YES]; + + // after save, update notifications + // dismiss previously delivered notifications + if (deleted) { + NSMutableArray *ids = [NSMutableArray array]; + for (FeedArticle *article in deleted) { // will contain non-articles too + if ([article isKindOfClass:[FeedArticle class]] || [article isKindOfClass:[Feed class]]) { + [ids addObject:article.notificationID]; + } + } + [NotifyEndpoint dismiss:ids]; // no-op if empty + } + // post new notification (if needed) + if (notify && inserted) { + BOOL didAddAny = NO; + for (FeedArticle *article in inserted) { // will contain non-articles too + if ([article isKindOfClass:[FeedArticle class]]) { + [NotifyEndpoint postArticle:article]; + didAddAny = YES; + } + } + if (didAddAny) + [NotifyEndpoint postFeed:f]; + } + if (needsNotification) PostNotification(kNotificationArticlesUpdated, oid); if (downloadIcon && !mem.error) { diff --git a/baRSS/Helper/UserPrefs.h b/baRSS/Helper/UserPrefs.h index 4d5aa19..823e483 100644 --- a/baRSS/Helper/UserPrefs.h +++ b/baRSS/Helper/UserPrefs.h @@ -13,6 +13,7 @@ /** default: @c nil */ static NSString* const Pref_modalSheetWidth = @"modalSheetWidth"; // ------ General settings ------ (Preferences > General Tab) ------ /** default: @c nil */ static NSString* const Pref_defaultHttpApplication = @"defaultHttpApplication"; +/** default: @c nil */ static NSString* const Pref_notificationType = @"notificationType"; // ------ Appearance matrix ------ (Preferences > Appearance Tab) ------ /** default: @c YES */ static NSString* const Pref_globalTintMenuIcon = @"globalTintMenuBarIcon"; /** default: @c YES */ static NSString* const Pref_globalUpdateAll = @"globalUpdateAll"; @@ -49,6 +50,16 @@ void UserPrefsInit(void); NSColor* UserPrefsColor(NSString *key, NSColor *defaultColor); // Change with: defaults write de.relikd.baRSS {KEY} -string "#FBA33A" + +typedef NS_ENUM(NSInteger, NotificationType) { + NotificationTypeDisabled, + NotificationTypePerArticle, + NotificationTypePerFeed, + NotificationTypeGlobal, +}; +NotificationType UserPrefsNotificationType(void); +NSString* NotificationTypeToString(NotificationType typ); + // ------ Getter ------ /// Helper method calls @c (standardUserDefaults)boolForKey: static inline BOOL UserPrefsBool(NSString* const key) { return [[NSUserDefaults standardUserDefaults] boolForKey:key]; } diff --git a/baRSS/Helper/UserPrefs.m b/baRSS/Helper/UserPrefs.m index a075e51..b580c41 100644 --- a/baRSS/Helper/UserPrefs.m +++ b/baRSS/Helper/UserPrefs.m @@ -44,3 +44,22 @@ NSColor* UserPrefsColor(NSString *key, NSColor *defaultColor) { } return defaultColor; } + +/// Convert stored notification type string into enum +NotificationType UserPrefsNotificationType(void) { + NSString *typ = UserPrefsString(Pref_notificationType); + if ([typ isEqualToString:@"article"]) return NotificationTypePerArticle; + if ([typ isEqualToString:@"feed"]) return NotificationTypePerFeed; + if ([typ isEqualToString:@"global"]) return NotificationTypeGlobal; + return NotificationTypeDisabled; +} + +/// Convert enum type to storable string +NSString* NotificationTypeToString(NotificationType typ) { + switch (typ) { + case NotificationTypePerArticle: return @"article"; + case NotificationTypePerFeed: return @"feed"; + case NotificationTypeGlobal: return @"global"; + default: return nil; + } +} diff --git a/baRSS/Notifications/NotifyEndpoint.h b/baRSS/Notifications/NotifyEndpoint.h new file mode 100644 index 0000000..382ed32 --- /dev/null +++ b/baRSS/Notifications/NotifyEndpoint.h @@ -0,0 +1,18 @@ +@import Cocoa; +@import UserNotifications; + +@class Feed, FeedArticle; + +NS_ASSUME_NONNULL_BEGIN + +@interface NotifyEndpoint : NSObject ++ (void)activate; + ++ (void)setGlobalCount:(NSUInteger)count; ++ (void)postFeed:(Feed*)feed; ++ (void)postArticle:(FeedArticle*)article; + ++ (void)dismiss:(nullable NSArray*)list; +@end + +NS_ASSUME_NONNULL_END diff --git a/baRSS/Notifications/NotifyEndpoint.m b/baRSS/Notifications/NotifyEndpoint.m new file mode 100644 index 0000000..9662805 --- /dev/null +++ b/baRSS/Notifications/NotifyEndpoint.m @@ -0,0 +1,171 @@ +#import "NotifyEndpoint.h" +#import "UserPrefs.h" +#import "StoreCoordinator.h" +#import "Feed+Ext.h" +#import "FeedArticle+Ext.h" + +/** + Sent for global unread count notification alert (Notification Center) + */ +static NSString* const kNotifyIdGlobal = @"global"; + + +@implementation NotifyEndpoint + +static NotifyEndpoint *singleton = nil; +static NotificationType notifyType; + +/// Ask user for permission to send notifications @b AND register delegate to respond to alert banner clicks. +/// @note Called every time user changes notification settings ++ (void)activate { + notifyType = UserPrefsNotificationType(); + + // even if disabled, register delegate. This allows to open previously sent notifications + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + singleton = [NotifyEndpoint new]; + UNUserNotificationCenter.currentNotificationCenter.delegate = singleton; + }); + + if (notifyType == NotificationTypeDisabled) { + return; + } + + [UNUserNotificationCenter.currentNotificationCenter requestAuthorizationWithOptions:UNAuthorizationOptionAlert | UNAuthorizationOptionSound completionHandler:^(BOOL granted, NSError * _Nullable error) { + if (error) { + dispatch_async(dispatch_get_main_queue(), ^{ + NSAlert *alert = [[NSAlert alloc] init]; + alert.messageText = NSLocalizedString(@"Notifications Disabled", nil); + alert.informativeText = NSLocalizedString(@"Either enable notifications in System Settings, or disable notifications in baRSS settings.", nil); + alert.alertStyle = NSAlertStyleInformational; + [alert runModal]; + }); + } + }]; +} + +/// Set (or update) global "X unread articles" ++ (void)setGlobalCount:(NSUInteger)count { + if (notifyType != NotificationTypeGlobal) { + return; + } + if (count > 0) { + // TODO: how to handle global count updates? + // ignore and keep old count until 0? + // or update count and show a new notification banner? + [self send:kNotifyIdGlobal + title:nil + body:[NSString stringWithFormat:@"%ld unread articles", count]]; + } else { + [self dismiss:@[kNotifyIdGlobal]]; + } +} + +/// Triggers feed notifications (if enabled) ++ (void)postFeed:(Feed*)feed { + if (notifyType != NotificationTypePerFeed) { + return; + } + NSUInteger count = feed.countUnread; + if (count > 0) { + [feed.managedObjectContext obtainPermanentIDsForObjects:@[feed] error:nil]; + [self send:feed.notificationID + title:feed.title + body:[NSString stringWithFormat:@"%ld unread articles", count]]; + } +} + +/// Triggers article notifications (if enabled) ++ (void)postArticle:(FeedArticle*)article { + if (notifyType != NotificationTypePerArticle) { + return; + } + [article.managedObjectContext obtainPermanentIDsForObjects:@[article] error:nil]; + [self send:article.notificationID + title:article.title + body:article.abstract ? article.abstract : article.body]; +} + +/// Close already posted notifications because they were opened via menu ++ (void)dismiss:(nullable NSArray*)list { + if (list.count > 0) { + [UNUserNotificationCenter.currentNotificationCenter removeDeliveredNotificationsWithIdentifiers:list]; + } +} + + +#pragma mark - Helper methods + +/// Post notification (immediatelly). +/// @param identifier Used to identify a specific instance (and dismiss a previously shown notification). ++ (void)send:(NSString *)identifier title:(nullable NSString *)title body:(nullable NSString *)body { + UNMutableNotificationContent *msg = [UNMutableNotificationContent new]; + msg.title = title; + msg.body = body; + // common settings: + // TODO: make sound configurable? + msg.sound = [UNNotificationSound defaultSound]; + [self send:identifier content: msg]; +} + +/// Internal method for queueing a new notification. ++ (void)send:(NSString *)identifier content:(UNMutableNotificationContent*)msg { + UNUserNotificationCenter *center = UNUserNotificationCenter.currentNotificationCenter; + + [center getNotificationSettingsWithCompletionHandler:^(UNNotificationSettings * _Nonnull settings) { + if (settings.authorizationStatus != UNAuthorizationStatusAuthorized) { + return; + } + + UNNotificationRequest *req = [UNNotificationRequest requestWithIdentifier:identifier content:msg trigger:nil]; + [center addNotificationRequest:req withCompletionHandler:^(NSError * _Nullable error) { + if (error) { + NSLog(@"Could not send notification: %@", error); + } + }]; + }]; +} + + +#pragma mark - Delegate + +/// Must be implemented to show notifications while the app is in foreground +- (void)userNotificationCenter:(UNUserNotificationCenter *)center willPresentNotification:(UNNotification *)notification withCompletionHandler:(void (^)(UNNotificationPresentationOptions))completionHandler { + // all the options + UNNotificationPresentationOptions common = UNNotificationPresentationOptionSound | UNNotificationPresentationOptionBadge; + if (@available(macOS 11.0, *)) { + completionHandler(common | UNNotificationPresentationOptionBanner | UNNotificationPresentationOptionList); + } else { + completionHandler(common | UNNotificationPresentationOptionAlert); + } +} + +/// Callback method when user clicks on alert banner +- (void)userNotificationCenter:(UNUserNotificationCenter *)center didReceiveNotificationResponse:(UNNotificationResponse *)response withCompletionHandler:(void (^)(void))completionHandler { + NSArray *articles; + + NSManagedObjectContext *moc = [StoreCoordinator createChildContext]; + NSString *theId = response.notification.request.identifier; + if ([theId isEqualToString:kNotifyIdGlobal]) { + // global notification + articles = [StoreCoordinator articlesAtPath:nil isFeed:NO sorted:YES unread:YES inContext:moc limit:0]; + } else { + NSURL *uri = [NSURL URLWithString:theId]; + NSManagedObjectID *oid = [moc.persistentStoreCoordinator managedObjectIDForURIRepresentation:uri]; + NSManagedObject *obj = [moc objectWithID:oid]; + if ([obj isKindOfClass:[FeedArticle class]]) { + // per-article notification + articles = @[(FeedArticle*)obj]; + } else if ([obj isKindOfClass:[Feed class]]) { + // per-feed notification + articles = [[[(Feed*)obj articles] + filteredSetUsingPredicate:[NSPredicate predicateWithFormat:@"unread = 1"]] + sortedArrayUsingDescriptors:@[[NSSortDescriptor sortDescriptorWithKey:@"sortIndex" ascending:NO]]]; + } else { + return; + } + } + [StoreCoordinator updateArticles:articles markRead:YES andOpen:YES inContext:moc]; +} + +@end diff --git a/baRSS/Preferences/Feeds Tab/SettingsFeeds+DragDrop.m b/baRSS/Preferences/Feeds Tab/SettingsFeeds+DragDrop.m index aa6a2c9..49b8862 100644 --- a/baRSS/Preferences/Feeds Tab/SettingsFeeds+DragDrop.m +++ b/baRSS/Preferences/Feeds Tab/SettingsFeeds+DragDrop.m @@ -138,7 +138,7 @@ const NSPasteboardType dragReorder = @"de.relikd.baRSS.drag-reorder"; if (selection.count > 0) [self.dataStore setSelectionIndexPaths:[selection sortedArrayUsingSelector:@selector(compare:)]]; - [UpdateScheduler downloadList:feedsList userInitiated:YES finally:^{ + [UpdateScheduler downloadList:feedsList userInitiated:YES notifications:NO finally:^{ [self endCoreDataChangeUndoEmpty:NO forceUndo:NO]; for (Feed *f in feedsList) [moc refreshObject:f.group mergeChanges:NO]; // fixes blank icon if imported with no inet conn diff --git a/baRSS/Preferences/General Tab/SettingsGeneral.h b/baRSS/Preferences/General Tab/SettingsGeneral.h index f10ce00..a725d41 100644 --- a/baRSS/Preferences/General Tab/SettingsGeneral.h +++ b/baRSS/Preferences/General Tab/SettingsGeneral.h @@ -5,6 +5,7 @@ NS_ASSUME_NONNULL_BEGIN @interface SettingsGeneral : NSViewController - (void)clickHowToDefaults:(NSButton *)sender; - (void)changeHttpApplication:(NSPopUpButton *)sender; +- (void)changeNotificationType:(NSPopUpButton *)sender; @end NS_ASSUME_NONNULL_END diff --git a/baRSS/Preferences/General Tab/SettingsGeneral.m b/baRSS/Preferences/General Tab/SettingsGeneral.m index cdf2fcc..5603df2 100644 --- a/baRSS/Preferences/General Tab/SettingsGeneral.m +++ b/baRSS/Preferences/General Tab/SettingsGeneral.m @@ -3,6 +3,7 @@ #import "StoreCoordinator.h" #import "Constants.h" #import "SettingsGeneralView.h" +#import "NotifyEndpoint.h" @interface SettingsGeneral() @property (strong) IBOutlet SettingsGeneralView *view; // override @@ -28,6 +29,23 @@ defaultApp.lastItem.representedObject = bundleID; } [defaultApp selectItemAtIndex:[defaultApp indexOfItemWithRepresentedObject:UserPrefsString(Pref_defaultHttpApplication)]]; + + // Notification settings (disabled, per article, per feed, total) + NSPopUpButton *notify = self.view.popupNotificationType; + [notify removeAllItems]; + [notify addItemWithTitle:NSLocalizedString(@"Disabled", @"Disable notifications")]; + notify.lastItem.representedObject = @""; + [notify addItemsWithTitles:@[ + NSLocalizedString(@"Disabled", @"Disable notifications"), + NSLocalizedString(@"Article title (per article)", @"Show article title in notification"), + NSLocalizedString(@"Number of articles (per feed)", @"Show “feed X: N new articles”"), + NSLocalizedString(@"Generic “new articles” (over all)", @"Show total “N new article”"), + ]]; + notify.itemArray[0].representedObject = NotificationTypeToString(NotificationTypeDisabled); + notify.itemArray[1].representedObject = NotificationTypeToString(NotificationTypePerArticle); + notify.itemArray[2].representedObject = NotificationTypeToString(NotificationTypePerFeed); + notify.itemArray[3].representedObject = NotificationTypeToString(NotificationTypeGlobal); + [notify selectItemAtIndex:[notify indexOfItemWithRepresentedObject:NotificationTypeToString(UserPrefsNotificationType())]]; } /// Get human readable application name such as 'Safari' or 'baRSS' @@ -65,4 +83,9 @@ UserPrefsSet(Pref_defaultHttpApplication, sender.selectedItem.representedObject); } +- (void)changeNotificationType:(NSPopUpButton *)sender { + UserPrefsSet(Pref_notificationType, sender.selectedItem.representedObject); + [NotifyEndpoint activate]; +} + @end diff --git a/baRSS/Preferences/General Tab/SettingsGeneralView.h b/baRSS/Preferences/General Tab/SettingsGeneralView.h index ca08d75..fb35fd0 100644 --- a/baRSS/Preferences/General Tab/SettingsGeneralView.h +++ b/baRSS/Preferences/General Tab/SettingsGeneralView.h @@ -6,6 +6,7 @@ NS_ASSUME_NONNULL_BEGIN @interface SettingsGeneralView : NSView @property (strong) IBOutlet NSTextField *defaultReader; @property (strong) IBOutlet NSPopUpButton* popupHttpApplication; +@property (strong) IBOutlet NSPopUpButton* popupNotificationType; - (instancetype)initWithController:(SettingsGeneral*)controller NS_DESIGNATED_INITIALIZER; - (instancetype)initWithFrame:(NSRect)frameRect NS_UNAVAILABLE; diff --git a/baRSS/Preferences/General Tab/SettingsGeneralView.m b/baRSS/Preferences/General Tab/SettingsGeneralView.m index 23c5fca..48d36c4 100644 --- a/baRSS/Preferences/General Tab/SettingsGeneralView.m +++ b/baRSS/Preferences/General Tab/SettingsGeneralView.m @@ -6,15 +6,23 @@ - (instancetype)initWithController:(SettingsGeneral*)controller { self = [super initWithFrame:NSMakeRect(0, 0, 320, 327)]; + // Change default feed reader application NSTextField *l1 = [[NSView label:NSLocalizedString(@"Default feed reader:", nil)] placeIn:self x:PAD_WIN yTop:PAD_WIN + 3]; NSButton *help = [[[NSView helpButton] action:@selector(clickHowToDefaults:) target:controller] placeIn:self xRight:PAD_WIN yTop:PAD_WIN]; self.defaultReader = [[[[NSView label:@""] bold] placeIn:self x:NSMaxX(l1.frame) + PAD_S yTop:PAD_WIN + 3] sizeToRight:NSWidth(help.frame) + PAD_WIN]; + // Popup button 'Open URLs with:' CGFloat y = YFromTop(help) + PAD_M; NSTextField *l2 = [[NSView label:NSLocalizedString(@"Open URLs with:", nil)] placeIn:self x:PAD_WIN yTop:y + 1]; self.popupHttpApplication = [[[[NSView popupButton:0] placeIn:self x:NSMaxX(l2.frame) + PAD_S yTop:y] sizeToRight:PAD_WIN] action:@selector(changeHttpApplication:) target:controller]; + + // Notification type + y = YFromTop(self.popupHttpApplication) + PAD_M; + NSTextField *l3 = [[NSView label:NSLocalizedString(@"Notifications:", nil)] placeIn:self x:PAD_WIN yTop:y + 1]; + self.popupNotificationType = [[[[NSView popupButton:0] placeIn:self x:NSMaxX(l3.frame) + PAD_S yTop:y] sizeToRight:PAD_WIN] + action:@selector(changeNotificationType:) target:controller]; return self; } diff --git a/baRSS/Status Bar Menu/BarStatusItem.m b/baRSS/Status Bar Menu/BarStatusItem.m index e530a5a..4b0f503 100644 --- a/baRSS/Status Bar Menu/BarStatusItem.m +++ b/baRSS/Status Bar Menu/BarStatusItem.m @@ -5,6 +5,7 @@ #import "UserPrefs.h" #import "BarMenu.h" #import "AppHook.h" +#import "NotifyEndpoint.h" #import "NSView+Ext.h" #import "NSColor+Ext.h" @@ -71,12 +72,14 @@ - (void)setUnreadCountAbsolute:(NSUInteger)count { _unreadCountTotal = (NSInteger)count; [self updateBarIcon]; + [NotifyEndpoint setGlobalCount:count]; } /// Assign new value by adding @c count to total unread count (may be negative). - (void)setUnreadCountRelative:(NSInteger)count { _unreadCountTotal += count; [self updateBarIcon]; + [NotifyEndpoint setGlobalCount:(NSUInteger)_unreadCountTotal]; } /// Fetch new total unread count from core data and assign it as new value (dispatch async on main thread). diff --git a/baRSS/Status Bar Menu/NSMenu+Ext.m b/baRSS/Status Bar Menu/NSMenu+Ext.m index 758d922..380f9c4 100644 --- a/baRSS/Status Bar Menu/NSMenu+Ext.m +++ b/baRSS/Status Bar Menu/NSMenu+Ext.m @@ -5,6 +5,7 @@ #import "FeedGroup+Ext.h" #import "Constants.h" #import "MapUnreadTotal.h" +#import "NotifyEndpoint.h" typedef NS_ENUM(NSInteger, MenuItemTag) { /// Used in @c allowDisplayOfHeaderItem: to identify and enable items @@ -181,7 +182,8 @@ typedef NS_ENUM(NSInteger, MenuItemTag) { } NSManagedObjectContext *moc = [StoreCoordinator createChildContext]; NSArray *list = [StoreCoordinator articlesAtPath:path isFeed:isFeedMenu sorted:openLinks unread:markRead inContext:moc limit:limit]; - [StoreCoordinator updateArticles:list markRead:markRead andOpen:openLinks inContext:moc]; + [NotifyEndpoint dismiss: + [StoreCoordinator updateArticles:list markRead:markRead andOpen:openLinks inContext:moc]]; } @end