diff --git a/CHANGELOG.md b/CHANGELOG.md index 46e9a14f9..e111fc0f2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Improved naming of a couple list-related classes. - Track TestFlight vs AppStore installations in Posthog. [#130](https://github.com/verse-pbc/issues/issues/130) - Track breadcrumbs in Sentry for all analytics events. [#125](https://github.com/verse-pbc/issues/issues/125) +- Added functionality to get follows notifications in the Notifications tab. [#127](https://github.com/verse-pbc/issues/issues/127) - Refactored the way the ProfileView downloads data and logs analytics events. [#1748](https://github.com/planetary-social/nos/pull/1748) ## [1.1] - 2025-01-03Z diff --git a/Nos.xcodeproj/project.pbxproj b/Nos.xcodeproj/project.pbxproj index 4ddedaf76..8c532b009 100644 --- a/Nos.xcodeproj/project.pbxproj +++ b/Nos.xcodeproj/project.pbxproj @@ -152,6 +152,8 @@ 04368D2B2C99A2C400DEAA2E /* FlagOption.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04368D2A2C99A2C400DEAA2E /* FlagOption.swift */; }; 04368D312C99A78800DEAA2E /* NosRadioButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04368D302C99A78800DEAA2E /* NosRadioButton.swift */; }; 04368D4B2C99CFC700DEAA2E /* ContentFlagView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04368D4A2C99CFC700DEAA2E /* ContentFlagView.swift */; }; + 045027492D318E1300DA9835 /* NosNotificationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 045027482D318E1300DA9835 /* NosNotificationTests.swift */; }; + 045028002D35484400DA9835 /* FollowsNotificationCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = 045027FF2D35484400DA9835 /* FollowsNotificationCard.swift */; }; 045EDCF32CAAF47600B67964 /* FlagSuccessView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 045EDCF22CAAF47600B67964 /* FlagSuccessView.swift */; }; 045EDD052CAC025700B67964 /* ScrollViewProxy+Animate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 045EDD042CAC025700B67964 /* ScrollViewProxy+Animate.swift */; }; 0496D6312C975E6900D29375 /* FlagOptionPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0496D6302C975E6900D29375 /* FlagOptionPicker.swift */; }; @@ -741,6 +743,9 @@ 04368D2A2C99A2C400DEAA2E /* FlagOption.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FlagOption.swift; sourceTree = ""; }; 04368D302C99A78800DEAA2E /* NosRadioButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NosRadioButton.swift; sourceTree = ""; }; 04368D4A2C99CFC700DEAA2E /* ContentFlagView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentFlagView.swift; sourceTree = ""; }; + 045027482D318E1300DA9835 /* NosNotificationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NosNotificationTests.swift; sourceTree = ""; }; + 045027622D31A08D00DA9835 /* Nos 24.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "Nos 24.xcdatamodel"; sourceTree = ""; }; + 045027FF2D35484400DA9835 /* FollowsNotificationCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FollowsNotificationCard.swift; sourceTree = ""; }; 045EDCF22CAAF47600B67964 /* FlagSuccessView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FlagSuccessView.swift; sourceTree = ""; }; 045EDD042CAC025700B67964 /* ScrollViewProxy+Animate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ScrollViewProxy+Animate.swift"; sourceTree = ""; }; 0496D6302C975E6900D29375 /* FlagOptionPicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FlagOptionPicker.swift; sourceTree = ""; }; @@ -1387,6 +1392,7 @@ children = ( C94437E529B0DB83004D8C86 /* NotificationsView.swift */, C98B8B3F29FBF83B009789C8 /* NotificationCard.swift */, + 045027FF2D35484400DA9835 /* FollowsNotificationCard.swift */, ); path = Notifications; sourceTree = ""; @@ -1452,6 +1458,7 @@ 035729A12BE4167E005FEE85 /* AuthorTests.swift */, 035729A32BE4167E005FEE85 /* EventTests.swift */, 035729A42BE4167E005FEE85 /* FollowTests.swift */, + 045027482D318E1300DA9835 /* NosNotificationTests.swift */, ); path = CoreData; sourceTree = ""; @@ -2645,6 +2652,7 @@ C9CDBBA429A8FA2900C555C7 /* GoldenPostView.swift in Sources */, C92F01582AC4D6F700972489 /* NosTextField.swift in Sources */, C9C2B77C29E072E400548B4A /* WebSocket+Nos.swift in Sources */, + 045028002D35484400DA9835 /* FollowsNotificationCard.swift in Sources */, 503CA9532D19ACCC00805EF8 /* HorizontalLine.swift in Sources */, C9DEC003298945150078B43A /* String+Lorem.swift in Sources */, 04C9D7912CC29D5000EAAD4D /* FeaturedAuthor+Cohort1.swift in Sources */, @@ -2811,6 +2819,7 @@ 035729AC2BE4167E005FEE85 /* Bech32Tests.swift in Sources */, C936B4632A4CB01C00DF1EB9 /* PushNotificationService.swift in Sources */, C9C5475A2A4F1D8C006B0741 /* NosNotification+CoreDataClass.swift in Sources */, + 045027492D318E1300DA9835 /* NosNotificationTests.swift in Sources */, 508133DC2C7A007700DFBF75 /* AttributedString+Quotation.swift in Sources */, CD09A74929A521210063464F /* Router.swift in Sources */, C90B16B82AFED96300CB4B85 /* URLExtensionTests.swift in Sources */, @@ -4006,6 +4015,7 @@ C936B4572A4C7B7C00DF1EB9 /* Nos.xcdatamodeld */ = { isa = XCVersionGroup; children = ( + 045027622D31A08D00DA9835 /* Nos 24.xcdatamodel */, 5022F9472D2188650012FF4B /* Nos 23.xcdatamodel */, 503CAB7B2D1DA6DB00805EF8 /* Nos 22.xcdatamodel */, 0303B11E2D0257D400077929 /* Nos 21.xcdatamodel */, @@ -4022,7 +4032,7 @@ C9C547562A4F1D1A006B0741 /* Nos 9.xcdatamodel */, 5BFF66AF2A4B55FC00AA79DD /* Nos 10.xcdatamodel */, ); - currentVersion = 5022F9472D2188650012FF4B /* Nos 23.xcdatamodel */; + currentVersion = 045027622D31A08D00DA9835 /* Nos 24.xcdatamodel */; path = Nos.xcdatamodeld; sourceTree = ""; versionGroupType = wrapper.xcdatamodel; diff --git a/Nos/AppDelegate.swift b/Nos/AppDelegate.swift index c2ff0ed3d..581cd1f66 100644 --- a/Nos/AppDelegate.swift +++ b/Nos/AppDelegate.swift @@ -5,6 +5,7 @@ import Dependencies import SDWebImage import SDWebImageWebPCoder +@MainActor class AppDelegate: NSObject, UIApplicationDelegate { @Dependency(\.currentUser) private var currentUser @@ -45,6 +46,8 @@ class AppDelegate: NSObject, UIApplicationDelegate { do { Log.info("PushNotifications: Received background notification. Subscribing to relays.") analytics.receivedNotification() + // Call PushNotificationService to handle follow notifications + await pushNotificationService.application(application, didReceiveRemoteNotification: userInfo) await currentUser.subscribe() try await Task.sleep(for: .seconds(10)) Log.info("PushNotifications: Sync complete") diff --git a/Nos/Controller/PersistenceController.swift b/Nos/Controller/PersistenceController.swift index 782efe8fc..994a758f9 100644 --- a/Nos/Controller/PersistenceController.swift +++ b/Nos/Controller/PersistenceController.swift @@ -11,6 +11,7 @@ final class PersistenceController { /// Increment this to delete core data on update private static let version = 3 private static let versionKey = "NosPersistenceControllerVersion" + private static let createdNosNotifications = "CreatedNosNotifications" static var preview: PersistenceController = { let controller = PersistenceController(inMemory: true) @@ -41,6 +42,14 @@ final class PersistenceController { private(set) var container: NSPersistentContainer private let model: NSManagedObjectModel private let inMemory: Bool + private var recreateNosNotifications: Bool { + get { + UserDefaults.standard.bool(forKey: Self.createdNosNotifications) + } + set { + UserDefaults.standard.set(newValue, forKey: Self.createdNosNotifications) + } + } init(containerName: String = "Nos", inMemory: Bool = false, erase: Bool = false) { self.inMemory = inMemory @@ -48,6 +57,14 @@ final class PersistenceController { model = NSManagedObjectModel(contentsOf: modelURL)! container = NSPersistentContainer(name: containerName, managedObjectModel: model) setUp(erasingPrevious: erase) + + if !recreateNosNotifications { + Task { + let context = newBackgroundContext() + try await DatabaseCleaner.deleteNotificationsAndEvents(in: context) + recreateNosNotifications = true + } + } } private func setUp(erasingPrevious: Bool) { diff --git a/Nos/Models/CoreData/Author+CoreDataClass.swift b/Nos/Models/CoreData/Author+CoreDataClass.swift index 2ef579c85..9ff069e08 100644 --- a/Nos/Models/CoreData/Author+CoreDataClass.swift +++ b/Nos/Models/CoreData/Author+CoreDataClass.swift @@ -406,4 +406,13 @@ import Logger // Publish the modified list await currentUser.publishMuteList(keys: Array(Set(mutedList))) } + + // Checks if this author has received a follow notification from the specified author. + /// - Parameter author: The author to check for a follow relationship + /// - Returns: `true` if the specified author follows this author, `false` otherwise + func hasReceivedFollowNotification( from author: Author) -> Bool { + followNotifications.contains(where: { element in + (element as? NosNotification)?.follower == author + }) + } } diff --git a/Nos/Models/CoreData/Event+Fetching.swift b/Nos/Models/CoreData/Event+Fetching.swift index c1e2a4ec4..b9bf40feb 100644 --- a/Nos/Models/CoreData/Event+Fetching.swift +++ b/Nos/Models/CoreData/Event+Fetching.swift @@ -185,7 +185,9 @@ extension Event { let readStoryClause = "(isRead = 1 AND receivedAt > %@)" let userReportClause = "(kind == \(EventKind.report.rawValue) AND " + "authorReferences.@count > 0 AND eventReferences.@count == 0)" + let notificationClause = "(notifications.@count = 0)" let clauses = "\(oldUnreferencedEventsClause) AND" + + "\(notificationClause) AND " + "\(notOwnEventClause) AND " + "NOT \(readStoryClause) AND " + "NOT \(userReportClause)" diff --git a/Nos/Models/CoreData/Generated/Event+CoreDataProperties.swift b/Nos/Models/CoreData/Generated/Event+CoreDataProperties.swift index d67047420..9e16288dd 100644 --- a/Nos/Models/CoreData/Generated/Event+CoreDataProperties.swift +++ b/Nos/Models/CoreData/Generated/Event+CoreDataProperties.swift @@ -27,6 +27,7 @@ extension Event { @NSManaged public var seenOnRelays: Set @NSManaged public var shouldBePublishedTo: Set @NSManaged public var isRead: Bool + @NSManaged public var notifications: NosNotification? } // MARK: Generated accessors for authorReferences diff --git a/Nos/Models/CoreData/Generated/NosNotification+CoreDataProperties.swift b/Nos/Models/CoreData/Generated/NosNotification+CoreDataProperties.swift index 3eecc9be3..5a6bc30b4 100644 --- a/Nos/Models/CoreData/Generated/NosNotification+CoreDataProperties.swift +++ b/Nos/Models/CoreData/Generated/NosNotification+CoreDataProperties.swift @@ -8,10 +8,10 @@ extension NosNotification { } @NSManaged public var isRead: Bool - @NSManaged public var eventID: String? @NSManaged public var user: Author? @NSManaged public var follower: Author? @NSManaged public var createdAt: Date? + @NSManaged public var event: Event? } extension NosNotification: Identifiable {} diff --git a/Nos/Models/CoreData/Nos.xcdatamodeld/.xccurrentversion b/Nos/Models/CoreData/Nos.xcdatamodeld/.xccurrentversion index 559c03b9e..5dac361fb 100644 --- a/Nos/Models/CoreData/Nos.xcdatamodeld/.xccurrentversion +++ b/Nos/Models/CoreData/Nos.xcdatamodeld/.xccurrentversion @@ -3,6 +3,6 @@ _XCCurrentVersionName - Nos 23.xcdatamodel + Nos 24.xcdatamodel diff --git a/Nos/Models/CoreData/Nos.xcdatamodeld/Nos 24.xcdatamodel/.xccurrentversion b/Nos/Models/CoreData/Nos.xcdatamodeld/Nos 24.xcdatamodel/.xccurrentversion new file mode 100644 index 000000000..6c8a1eef9 --- /dev/null +++ b/Nos/Models/CoreData/Nos.xcdatamodeld/Nos 24.xcdatamodel/.xccurrentversion @@ -0,0 +1,8 @@ + + + + + _XCCurrentVersionName + Nos.xcdatamodel + + diff --git a/Nos/Models/CoreData/Nos.xcdatamodeld/Nos 24.xcdatamodel/Nos.xcdatamodel/contents b/Nos/Models/CoreData/Nos.xcdatamodeld/Nos 24.xcdatamodel/Nos.xcdatamodel/contents new file mode 100644 index 000000000..1a418ef2c --- /dev/null +++ b/Nos/Models/CoreData/Nos.xcdatamodeld/Nos 24.xcdatamodel/Nos.xcdatamodel/contents @@ -0,0 +1,56 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Nos/Models/CoreData/Nos.xcdatamodeld/Nos 24.xcdatamodel/contents b/Nos/Models/CoreData/Nos.xcdatamodeld/Nos 24.xcdatamodel/contents new file mode 100644 index 000000000..e4af25563 --- /dev/null +++ b/Nos/Models/CoreData/Nos.xcdatamodeld/Nos 24.xcdatamodel/contents @@ -0,0 +1,112 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Nos/Models/CoreData/NosNotification+CoreDataClass.swift b/Nos/Models/CoreData/NosNotification+CoreDataClass.swift index 03475b1d5..2d15f5645 100644 --- a/Nos/Models/CoreData/NosNotification+CoreDataClass.swift +++ b/Nos/Models/CoreData/NosNotification+CoreDataClass.swift @@ -1,10 +1,10 @@ import Foundation import CoreData -/// Represents a notification we will display to the user. We save records of them to the database in order to +/// Represents a notification we will display to the user. We save records of them to the database in order to /// de-duplicate them and keep track of whether they have been seen by the user. @objc(NosNotification) -public class NosNotification: NSManagedObject { +public class NosNotification: NosManagedObject { static func createIfNecessary( from eventID: RawEventID, @@ -20,36 +20,43 @@ public class NosNotification: NSManagedObject { return nil } else { let notification = NosNotification(context: context) - notification.eventID = eventID - notification.user = author notification.createdAt = date + notification.user = author + // Only set follower relationship if this is a follow event + if let event = Event.find(by: eventID, context: context) { + notification.event = event + if event.kind == EventKind.contactList.rawValue { + notification.follower = event.author + } + } + return notification } } - - static func find(by eventID: RawEventID, in context: NSManagedObjectContext) throws -> NosNotification? { + + static func find(by eventID: RawNostrID, in context: NSManagedObjectContext) throws -> NosNotification? { let fetchRequest = request(by: eventID) if let notification = try context.fetch(fetchRequest).first { return notification } - + return nil } - - static func request(by eventID: RawEventID) -> NSFetchRequest { + + static func request(by eventID: RawNostrID) -> NSFetchRequest { let fetchRequest = NSFetchRequest(entityName: String(describing: NosNotification.self)) - fetchRequest.predicate = NSPredicate(format: "eventID = %@", eventID) + fetchRequest.predicate = NSPredicate(format: "event.identifier = %@", eventID) fetchRequest.fetchLimit = 1 - fetchRequest.sortDescriptors = [NSSortDescriptor(keyPath: \NosNotification.eventID, ascending: false)] + fetchRequest.sortDescriptors = [NSSortDescriptor(keyPath: \NosNotification.event, ascending: false)] return fetchRequest } - + static func unreadCount(in context: NSManagedObjectContext) throws -> Int { let fetchRequest = NSFetchRequest(entityName: String(describing: NosNotification.self)) fetchRequest.predicate = NSPredicate(format: "isRead != 1") return try context.count(for: fetchRequest) } - + // TODO: user is unused; is this a bug? static func markAllAsRead(in context: NSManagedObjectContext) async throws { try await context.perform { @@ -59,7 +66,7 @@ public class NosNotification: NSManagedObject { for notification in unreadNotifications { notification.isRead = true } - + try? context.saveIfNeeded() } } @@ -75,4 +82,101 @@ public class NosNotification: NSManagedObject { static func staleNotificationCutoff() -> Date { Calendar.current.date(byAdding: .month, value: -2, to: .now) ?? .now } + + @nonobjc public class func emptyRequest() -> NSFetchRequest { + let fetchRequest = NSFetchRequest(entityName: "NosNotification") + fetchRequest.sortDescriptors = [NSSortDescriptor(keyPath: \NosNotification.createdAt, ascending: true)] + fetchRequest.predicate = NSPredicate.false + return fetchRequest + } + + /// A request for all notifications that the given user should receive a notification for. + /// - Parameters: + /// - currentUser: the author you want to view notifications for. + /// - since: a date that will be used as a lower bound for the request. + /// - limit: a max number of notifications to fetch. + /// - Returns: A fetch request for all notifications. + @nonobjc public class func allRequest( + for currentUser: Author, + since: Date? = nil, + limit: Int? = nil + ) -> NSFetchRequest { + let fetchRequest = NSFetchRequest(entityName: "NosNotification") + fetchRequest.sortDescriptors = [NSSortDescriptor(keyPath: \NosNotification.createdAt, ascending: false)] + if let limit { + fetchRequest.fetchLimit = limit + } + return fetchRequest + } + + /// A request for all out-Of-Network notifications that the given user should receive. + /// - Parameters: + /// - currentUser: the author you want to view notifications for. + /// - limit: a max number of notifications to fetch. + /// - Returns: A fetch request for outOfNetwork notifications. + @nonobjc public class func outOfNetworkRequest( + for currentUser: Author, + limit: Int? = nil + ) -> NSFetchRequest { + let fetchRequest = NSFetchRequest(entityName: String(describing: NosNotification.self)) + fetchRequest.sortDescriptors = [NSSortDescriptor(keyPath: \NosNotification.createdAt, ascending: false)] + if let limit { + fetchRequest.fetchLimit = limit + } + + fetchRequest.predicate = NSPredicate( + format: "follower == nil " + + "AND (event.author.followers.@count == 0 " + + "OR NOT (ANY event.author.followers.source IN %@.follows.destination " + + "OR event.author IN %@.follows.destination))", + currentUser, + currentUser + ) + return fetchRequest + } + /// A request for all in-Network notifications that the given user should receive. + /// - Parameters: + /// - currentUser: the author you want to view notifications for. + /// - limit: a max number of notifications to fetch. + /// - Returns: A fetch request for inNetwork notifications. + @nonobjc public class func inNetworkRequest( + for currentUser: Author, + limit: Int? = nil + ) -> NSFetchRequest { + let fetchRequest = NSFetchRequest(entityName: String(describing: NosNotification.self)) + fetchRequest.sortDescriptors = [NSSortDescriptor(keyPath: \NosNotification.createdAt, ascending: false)] + + if let limit { + fetchRequest.fetchLimit = limit + } + + fetchRequest.predicate = NSPredicate( + format: "follower == nil " + + "AND (ANY event.author.followers.source IN %@.follows.destination) " + + "OR event.author IN %@.follows.destination AND follower == nil", + currentUser, + currentUser + ) + return fetchRequest + } + + /// A request for all follow notifications that the given user should receive. + /// - Parameters: + /// - currentUser: the author you want to view notifications for. + /// - limit: a max number of notifications to fetch. + /// - Returns: A fetch request for follow notifications. + @nonobjc public class func followsRequest( + for currentUser: Author, + limit: Int? = nil + ) -> NSFetchRequest { + let fetchRequest = NSFetchRequest(entityName: "NosNotification") + fetchRequest.sortDescriptors = [NSSortDescriptor(keyPath: \NosNotification.createdAt, ascending: false)] + if let limit { + fetchRequest.fetchLimit = limit + } + + fetchRequest.predicate = NSPredicate(format: "follower != nil") + + return fetchRequest + } } diff --git a/Nos/Models/NotificationViewModel.swift b/Nos/Models/NotificationViewModel.swift index 838bafb13..9da063444 100644 --- a/Nos/Models/NotificationViewModel.swift +++ b/Nos/Models/NotificationViewModel.swift @@ -2,21 +2,28 @@ import Foundation import CoreData import UIKit +enum NotificationType { + case event + case follow +} + /// A model that turns an Event into something that can be displayed in a NotificationCard or via NotificationCenter /// /// The view model is designed to be initialized synchronously so that it can be used /// in the .init method of a View. Because of this you must call the async function /// `loadContent()` to populate the `content` variable because it relies on some /// database queries. -@Observable final class NotificationViewModel: Identifiable { - let noteID: RawEventID +class NotificationViewModel: ObservableObject, Identifiable { + let noteID: RawNostrID? + let authorID: RawAuthorID? let authorProfilePhotoURL: URL? let actionText: AttributedString + let notificationType: NotificationType private(set) var content: AttributedString? - let date: Date - - var id: RawEventID { - noteID + let date: Date? + + var id: String { + noteID ?? authorID ?? UUID().uuidString } /// Generates a notification request that can be sent to the UNNotificationCenter to display a banner notification. @@ -30,26 +37,53 @@ import UIKit content.userInfo = ["eventID": id] let trigger = UNTimeIntervalNotificationTrigger(timeInterval: 1, repeats: false) return UNNotificationRequest( - identifier: noteID, + identifier: id, content: content, trigger: trigger ) } - - convenience init?(coreDataModel: NosNotification, context: NSManagedObjectContext) { - guard let eventID = coreDataModel.eventID, - let note = Event.find(by: eventID, context: context), - let user = coreDataModel.user else { + + convenience init?(coreDataModel: NosNotification, context: NSManagedObjectContext, createdAt: Date) { + guard let user = coreDataModel.user else { return nil } - self.init(note: note, user: user) + if let eventID = coreDataModel.event?.identifier, let note = Event.find(by: eventID, context: context) { + self.init(note: note, user: user, date: createdAt) + } else if let follower = coreDataModel.follower { + self.init(follower: follower, date: createdAt) + } else { + return nil + } } - - init(note: Event, user: Author) { - self.noteID = note.identifier ?? "" - self.date = note.createdAt ?? .distantPast + + init(follower: Author, date: Date) { + self.authorID = follower.hexadecimalPublicKey + self.noteID = nil + self.authorProfilePhotoURL = follower.profilePhotoURL + self.date = date + self.notificationType = .follow + + // Compute action text + var actionText: AttributedString + var authorName = AttributedString("\(follower.safeName) ") + var range = Range(uncheckedBounds: (authorName.startIndex, authorName.endIndex)) + authorName[range].font = .boldSystemFont(ofSize: 17) + + actionText = authorName + AttributedString(String(localized: "startedFollowingYou")) + range = Range(uncheckedBounds: (actionText.startIndex, actionText.endIndex)) + actionText[range].foregroundColor = .primaryTxt + self.actionText = actionText + + self.content = nil + } + + init(note: Event, user: Author, date: Date) { + self.noteID = note.identifier + self.authorID = note.author?.hexadecimalPublicKey + self.date = date self.authorProfilePhotoURL = note.author?.profilePhotoURL + self.notificationType = .event // Compute action text var actionText: AttributedString @@ -85,6 +119,9 @@ import UIKit /// Populates the `content` variable. This is not done at init in order to keep /// it synchronous for use in a View. @MainActor @discardableResult func loadContent(in context: NSManagedObjectContext) async -> AttributedString? { + if notificationType == .follow { + return nil + } content = await Event.attributedContent(noteID: noteID, context: context) return content } diff --git a/Nos/Service/DatabaseCleaner.swift b/Nos/Service/DatabaseCleaner.swift index d924bd252..99a707318 100644 --- a/Nos/Service/DatabaseCleaner.swift +++ b/Nos/Service/DatabaseCleaner.swift @@ -56,6 +56,7 @@ enum DatabaseCleaner { try batchDelete( objectsMatching: [ + NosNotification.oldNotificationsRequest(), // delete all events before deleteBefore that aren't protected or referenced Event.cleanupRequest(before: deleteBefore, for: currentUser), Event.expiredRequest(), @@ -65,7 +66,6 @@ enum DatabaseCleaner { Author.orphaned(for: currentUser), Follow.orphanedRequest(), Relay.orphanedRequest(), - NosNotification.oldNotificationsRequest(), ], in: context ) @@ -156,4 +156,17 @@ enum DatabaseCleaner { return deleteBefore } + + /// Deletes all `NosNotification` and `Event` entities from the database. + /// This is necessary because `NotificationsView` previously displayed events, but now displays `NosNotifications`. + /// Without this reset, old notifications might not appear since they were not initially + /// created as `NosNotifications`. + /// After this operation, `NosNotifications` will be recreated when the Notifications tab is tapped. + static func deleteNotificationsAndEvents(in context: NSManagedObjectContext) async throws { + try batchDelete(objectsMatching: [ + NosNotification.fetchRequest(), + Event.fetchRequest() + ], in: context) + try context.saveIfNeeded() + } } diff --git a/Nos/Service/PushNotificationService.swift b/Nos/Service/PushNotificationService.swift index 63e65f375..04fbdaa97 100644 --- a/Nos/Service/PushNotificationService.swift +++ b/Nos/Service/PushNotificationService.swift @@ -10,16 +10,16 @@ import Combine /// all new events and creates `NosNotification`s and displays them when appropriate. @MainActor @Observable class PushNotificationService: NSObject, NSFetchedResultsControllerDelegate, UNUserNotificationCenterDelegate { - + // MARK: - Public Properties - + /// The number of unread notifications that should be displayed as a badge private(set) var badgeCount = 0 private let showPushNotificationsAfterKey = "PushNotificationService.notificationCutoff" /// Used to limit which notifications are displayed to the user as push notifications. - /// + /// /// When the user first opens the app it is initialized to Date.now. /// This is to prevent us showing tons of push notifications on first login. /// Then after that it is set to the date of the last notification that we showed. @@ -37,9 +37,9 @@ import Combine userDefaults.set(newValue.timeIntervalSince1970, forKey: showPushNotificationsAfterKey) } } - + // MARK: - Private Properties - + @ObservationIgnored @Dependency(\.relayService) private var relayService @ObservationIgnored @Dependency(\.persistenceController) private var persistenceController @ObservationIgnored @Dependency(\.router) private var router @@ -47,50 +47,50 @@ import Combine @ObservationIgnored @Dependency(\.crashReporting) private var crashReporting @ObservationIgnored @Dependency(\.userDefaults) private var userDefaults @ObservationIgnored @Dependency(\.currentUser) private var currentUser - + private var notificationWatcher: NSFetchedResultsController? private var relaySubscription: SubscriptionCancellable? - private var currentAuthor: Author? + private var currentAuthor: Author? @ObservationIgnored private lazy var modelContext = persistenceController.newBackgroundContext() - + @ObservationIgnored private lazy var registrar = PushNotificationRegistrar() - + // MARK: - Setup - + func listen(for user: CurrentUser) async { relaySubscription = nil - + guard let author = user.author, let authorKey = author.hexadecimalPublicKey else { notificationWatcher = NSFetchedResultsController( - fetchRequest: Event.emptyRequest(), + fetchRequest: Event.emptyRequest(), managedObjectContext: modelContext, sectionNameKeyPath: nil, cacheName: nil ) return } - + currentAuthor = author notificationWatcher = NSFetchedResultsController( - fetchRequest: Event.all(notifying: author, since: showPushNotificationsAfter), + fetchRequest: Event.all(notifying: author, since: showPushNotificationsAfter), managedObjectContext: modelContext, sectionNameKeyPath: nil, cacheName: nil ) notificationWatcher?.delegate = self await modelContext.perform { [weak self] in - do { + do { try self?.notificationWatcher?.performFetch() } catch { Log.error("Error watching notifications:") self?.crashReporting.report(error) } } - + let userMentionsFilter = Filter( - kinds: [.text], - pTags: [authorKey], + kinds: [.text], + pTags: [authorKey], limit: 50, keepSubscriptionOpen: true ) @@ -99,18 +99,18 @@ import Combine ) await updateBadgeCount() - + do { try await registrar.register(user, context: modelContext) } catch { Log.optional(error, "failed to register for push notifications") } } - + func registerForNotifications(_ user: CurrentUser, with deviceToken: Data) async throws { try await registrar.register(user, with: deviceToken, context: modelContext) } - + // MARK: - Helpers func requestNotificationPermissionsFromUser() { @@ -124,7 +124,7 @@ import Combine } } } - + /// Recomputes the number of unread notifications for the `currentAuthor`, publishes the new value to /// `badgeCount`, and updates the application badge icon. func updateBadgeCount() async { @@ -134,52 +134,50 @@ import Combine (try? NosNotification.unreadCount(in: self.modelContext)) ?? 0 } } - + self.badgeCount = badgeCount try? await UNUserNotificationCenter.current().setBadgeCount(badgeCount) } - + // MARK: - Internal - - /// Tells the system to display a notification for the given event if it's appropriate. This will create a + /// Tells the system to display a notification for the given event if it's appropriate. This will create a /// NosNotification record in the database. @MainActor private func showNotificationIfNecessary(for eventID: RawEventID) async { guard let authorKey = currentAuthor?.hexadecimalPublicKey else { return } - + let viewModel: NotificationViewModel? = await modelContext.perform { () -> NotificationViewModel? in guard let event = Event.find(by: eventID, context: self.modelContext), - let eventCreated = event.createdAt, - let coreDataNotification = try? NosNotification.createIfNecessary( - from: eventID, - date: eventCreated, - authorKey: authorKey, - in: self.modelContext - ) else { - // We already have a notification for this event. + let eventCreated = event.createdAt else { return nil } - - defer { try? self.modelContext.save() } - - // Don't alert for old notifications or muted authors - guard let eventCreated = event.createdAt, - eventCreated > self.showPushNotificationsAfter, - event.author?.muted == false else { - coreDataNotification.isRead = true - return nil + + // For follow events, create a follow notification + if event.kind == EventKind.contactList.rawValue { + return self.handleFollowEvent( + event: event, + authorKey: authorKey, + eventCreated: eventCreated + ) } - - return NotificationViewModel(coreDataModel: coreDataNotification, context: self.modelContext) + + // Handle other event notifications + return self.handleGenericNotificationEvent( + eventID: eventID, + event: event, + authorKey: authorKey, + eventCreated: eventCreated + ) } - + if let viewModel { - // Leave an hour of margin on the showPushNotificationsAfter date to allow for events arriving slightly + // Leave an hour of margin on the showPushNotificationsAfter date to allow for events arriving slightly // out of order. - showPushNotificationsAfter = viewModel.date.addingTimeInterval(-60 * 60) + guard let date = viewModel.date else { return } + showPushNotificationsAfter = date.addingTimeInterval(-60 * 60) await viewModel.loadContent(in: self.persistenceController.backgroundViewContext) - + do { try await UNUserNotificationCenter.current().add(viewModel.notificationCenterRequest) await updateBadgeCount() @@ -188,64 +186,157 @@ import Combine } } } - + + /// Processes a contact list event and creates a notification for new followers + /// - Parameters: + /// - event: The Nostr event containing the contact list information + /// - authorKey: The public key of the author receiving the follow + /// - eventCreated: The timestamp when the event was created + /// - Returns: A `NotificationViewModel` if the notification should be displayed, nil otherwise + private func handleFollowEvent(event: Event, authorKey: String, eventCreated: Date) -> NotificationViewModel? { + guard let follower = event.author else { return nil } + + // Get the current author in this context + guard let currentAuthorInContext = try? Author.findOrCreate(by: authorKey, context: self.modelContext) else { + return nil + } + + let notification = NosNotification(context: self.modelContext) + notification.user = currentAuthorInContext + notification.follower = follower + notification.createdAt = eventCreated + + try? self.modelContext.save() + + // Don't alert for old notifications or muted authors + guard eventCreated > self.showPushNotificationsAfter, follower.muted == false else { + notification.isRead = true + return nil + } + + return NotificationViewModel(coreDataModel: notification, context: self.modelContext, createdAt: eventCreated) + } + + /// Processes a generic notification event and creates a notification if necessary + /// - Parameters: + /// - eventID: The unique identifier of the Nostr event + /// - event: The Nostr event to process + /// - authorKey: The public key of the author receiving the notification + /// - eventCreated: The timestamp when the event was created + /// - Returns: A `NotificationViewModel` if the notification should be displayed, nil otherwise + private func handleGenericNotificationEvent( + eventID: String, + event: Event, + authorKey: String, + eventCreated: Date + ) -> NotificationViewModel? { + guard let coreDataNotification = try? NosNotification.createIfNecessary( + from: eventID, + date: eventCreated, + authorKey: authorKey, + in: self.modelContext + ) else { + // We already have a notification for this event. + return nil + } + + defer { try? self.modelContext.save() } + + // Don't alert for old notifications or muted authors + guard eventCreated > self.showPushNotificationsAfter, + event.author?.muted == false else { + coreDataNotification.isRead = true + return nil + } + + return NotificationViewModel( + coreDataModel: coreDataNotification, + context: self.modelContext, + createdAt: eventCreated + ) + } + // MARK: - UNUserNotificationCenterDelegate - + func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse) async { if response.actionIdentifier == UNNotificationDefaultActionIdentifier { analytics.tappedNotification() - + let userInfo = response.notification.request.content.userInfo if let eventID = userInfo["eventID"] as? String, - !eventID.isEmpty { - + !eventID.isEmpty { + Task { @MainActor in - guard let event = Event.find(by: eventID, context: self.persistenceController.viewContext) else { - return - } - self.router.selectedTab = .notifications - self.router.push(event.referencedNote() ?? event) - } - } else if let data = userInfo["data"] as? [AnyHashable: Any] { - if let followPubkeys = data["follows"] as? [String], - let firstPubkey = followPubkeys.first, - let publicKey = PublicKey.build(npubOrHex: firstPubkey) { - Task { @MainActor in - self.router.pushAuthor(id: publicKey.hex) + if let follower = try? Author.find(by: eventID, context: self.persistenceController.viewContext) { + self.router.selectedTab = .notifications + self.router.push(follower) + } else if let event = Event.find(by: eventID, context: self.persistenceController.viewContext) { + self.router.selectedTab = .notifications + self.router.push(event.referencedNote() ?? event) } } } } } - + func userNotificationCenter( - _ center: UNUserNotificationCenter, + _ center: UNUserNotificationCenter, willPresent notification: UNNotification ) async -> UNNotificationPresentationOptions { analytics.displayedNotification() return [.list, .banner, .badge, .sound] } - + // MARK: - NSFetchedResultsControllerDelegate - + nonisolated func controller( - _ controller: NSFetchedResultsController, - didChange anObject: Any, - at indexPath: IndexPath?, - for type: NSFetchedResultsChangeType, + _ controller: NSFetchedResultsController, + didChange anObject: Any, + at indexPath: IndexPath?, + for type: NSFetchedResultsChangeType, newIndexPath: IndexPath? ) { guard type == .insert else { return } - + guard let event = anObject as? Event, let eventID = event.identifier else { return } - + Task { await showNotificationIfNecessary(for: eventID) } } + + func application(_ application: UIApplication, didReceiveRemoteNotification userInfo: [AnyHashable: Any]) async { + if let data = userInfo["data"] as? [AnyHashable: Any], let followPubkeys = data["follows"] as? [String], + let firstPubkey = followPubkeys.first, + let followerKey = PublicKey.build(npubOrHex: firstPubkey)?.hex { + + // Create Event for the follow + await modelContext.perform { [weak self] in + guard let self else { return } + + do { + let event = Event(context: self.modelContext) + event.identifier = UUID().uuidString + event.author = try Author.findOrCreate(by: followerKey, context: self.modelContext) + event.kind = EventKind.contactList.rawValue + event.createdAt = .now + + try self.modelContext.save() + + // Show notification for the follow + Task { @MainActor in + guard let identifier = event.identifier else { return } + await self.showNotificationIfNecessary(for: identifier) + } + } catch { + Log.optional(error, "Error creating follow notification for \(followerKey)") + } + } + } + } } class MockPushNotificationService: PushNotificationService { diff --git a/Nos/Views/Notifications/FollowsNotificationCard.swift b/Nos/Views/Notifications/FollowsNotificationCard.swift new file mode 100644 index 000000000..143443e26 --- /dev/null +++ b/Nos/Views/Notifications/FollowsNotificationCard.swift @@ -0,0 +1,95 @@ +import SwiftUI + +/// This view displays the information we have for an author suitable for being used in a list. +struct FollowsNotificationCard: View { + @EnvironmentObject private var router: Router + @Environment(\.managedObjectContext) private var viewContext + var author: Author + + let viewModel: NotificationViewModel + + /// Whether the follow button should be displayed or not. + let showsFollowButton: Bool + + init(author: Author, viewModel: NotificationViewModel, showsFollowButton: Bool = true) { + self.author = author + self.viewModel = viewModel + self.showsFollowButton = showsFollowButton + } + + private func showFollowProfile() { + guard let follower = try? Author.find(by: viewModel.authorID ?? "", context: viewContext) else { + return + } + router.push(follower) + } + + var body: some View { + Button { + showFollowProfile() + } label: { + VStack(spacing: 12) { + HStack { + ZStack(alignment: .bottomTrailing) { + AvatarView(imageUrl: author.profilePhotoURL, size: 80) + .padding(.trailing, 12) + if showsFollowButton { + CircularFollowButton(author: author) + } + } + VStack(alignment: .leading, spacing: 12) { + HStack { + VStack(alignment: .leading, spacing: 4) { + Text(author.safeName) + .lineLimit(2) + .font(.title3) + .fontWeight(.bold) + .foregroundColor(Color.primaryTxt) + .multilineTextAlignment(.leading) + .frame(maxWidth: .infinity, alignment: .leading) + } + } + + Text(String(localized: "startedFollowingYou")) + .lineLimit(2) + .font(.callout) + } + + if let date = viewModel.date { + Text(date.distanceString()) + .lineLimit(1) + .font(.callout) + .foregroundColor(.secondaryTxt) + } + } + } + .padding(.top, 13) + .padding(.bottom, 12) + .padding(.leading, 12) + .padding(.trailing, 12) + .background(LinearGradient.cardBackground) + .cornerRadius(cornerRadius) + } + .buttonStyle(CardButtonStyle(style: .compact)) + } + + var cornerRadius: CGFloat { 15 } +} + +#Preview { + var previewData = PreviewData() + + var alice: Author { + previewData.alice + } + + return VStack { + Spacer() + FollowsNotificationCard( + author: alice, + viewModel: NotificationViewModel(follower: alice, date: Date()) + ) + Spacer() + } + .inject(previewData: previewData) +} diff --git a/Nos/Views/Notifications/NotificationCard.swift b/Nos/Views/Notifications/NotificationCard.swift index 82ffc2d49..d08c5b7b0 100644 --- a/Nos/Views/Notifications/NotificationCard.swift +++ b/Nos/Views/Notifications/NotificationCard.swift @@ -3,23 +3,28 @@ import Dependencies /// A view that details some interaction (reply, like, follow, etc.) with one of your notes. struct NotificationCard: View { - + @Environment(\.managedObjectContext) private var viewContext @EnvironmentObject private var router: Router @Environment(RelayService.self) private var relayService @Dependency(\.persistenceController) private var persistenceController - + let viewModel: NotificationViewModel @State private var relaySubscriptions = SubscriptionCancellables() @State private var content: AttributedString? - + + init(viewModel: NotificationViewModel) { + self.viewModel = viewModel + } + func showNote() { - guard let note = Event.find(by: viewModel.noteID, context: viewContext) else { - return + guard let noteID = viewModel.noteID, + let note = Event.find(by: noteID, context: viewContext) else { + return } router.push(note.referencedNote() ?? note) } - + var body: some View { Button { showNote() @@ -27,7 +32,7 @@ struct NotificationCard: View { HStack { AvatarView(imageUrl: viewModel.authorProfilePhotoURL, size: 40) .shadow(radius: 10, y: 4) - + VStack { HStack { Text(viewModel.actionText) @@ -42,7 +47,7 @@ struct NotificationCard: View { .foregroundColor(.primaryTxt) .tint(.accent) .handleURLsInRouter() - + if viewModel.content == nil { contentText.redacted(reason: .placeholder) } else { @@ -53,13 +58,15 @@ struct NotificationCard: View { } } .frame(maxWidth: .infinity) - + VStack { Spacer() - Text(viewModel.date.distanceString()) - .lineLimit(1) - .font(.clarity(.regular)) - .foregroundColor(.secondaryTxt) + if let date = viewModel.date { + Text(date.distanceString()) + .lineLimit(1) + .font(.callout) + .foregroundColor(.secondaryTxt) + } } .fixedSize() } @@ -93,17 +100,17 @@ struct NotificationCard: View { #Preview { var previewData = PreviewData() - + let previewContext = previewData.previewContext - + var alice: Author { previewData.alice } - + var bob: Author { previewData.bob } - + let note: Event = { let mentionNote = Event(context: previewContext) mentionNote.content = "Hello, bob!" @@ -117,10 +124,10 @@ struct NotificationCard: View { try? previewContext.save() return mentionNote }() - + return VStack { Spacer() - NotificationCard(viewModel: NotificationViewModel(note: note, user: bob)) + NotificationCard(viewModel: NotificationViewModel(note: note, user: bob, date: Date())) Spacer() } .inject(previewData: previewData) diff --git a/Nos/Views/Notifications/NotificationsView.swift b/Nos/Views/Notifications/NotificationsView.swift index b0fcb147c..d9d614158 100644 --- a/Nos/Views/Notifications/NotificationsView.swift +++ b/Nos/Views/Notifications/NotificationsView.swift @@ -4,39 +4,84 @@ import CoreData import Dependencies import Logger -/// Displays a list of cells that let the user know when other users interact with their notes. +/// Displays a list of cells that let the user know when other users interact with their notes / follow them. struct NotificationsView: View { - + @Environment(RelayService.self) private var relayService @EnvironmentObject private var router: Router + @Environment(CurrentUser.self) private var currentUser @Dependency(\.analytics) private var analytics @Dependency(\.pushNotificationService) private var pushNotificationService @Dependency(\.persistenceController) private var persistenceController - @FetchRequest private var events: FetchedResults + @FetchRequest private var outOfNetworkNotifications: FetchedResults + @FetchRequest private var inNetworkNotifications: FetchedResults + @FetchRequest private var followNotifications: FetchedResults + @State private var relaySubscriptions = SubscriptionCancellables() @State private var isVisible = false - - // Probably the logged in user should be in the @Environment eventually - private var user: Author? + @State private var selectedTab = 1 + private let maxNotificationsToShow = 100 - + init(user: Author?) { - self.user = user + let followsRequest = Self.createFollowsFetchRequest(for: user, limit: maxNotificationsToShow) + let networkRequests = Self.createNetworkFetchRequests(for: user, limit: maxNotificationsToShow) + + _followNotifications = FetchRequest(fetchRequest: followsRequest) + _outOfNetworkNotifications = FetchRequest(fetchRequest: networkRequests.outOfNetwork) + _inNetworkNotifications = FetchRequest(fetchRequest: networkRequests.inNetwork) + } + + /// Creates the follows notification fetch requests for all notifications and follows. + /// + /// This is implemented as a static function because it's used during initialization + /// and doesn't require access to instance properties. + /// + /// - Parameters: + /// - user: The user to fetch notifications for. If nil, returns empty requests. + /// - limit: The maximum number of notifications to fetch. + /// - Returns: A fetch request for follows notifications. + private static func createFollowsFetchRequest(for user: Author?, limit: Int) -> NSFetchRequest { if let user { - _events = FetchRequest(fetchRequest: Event.all(notifying: user, limit: maxNotificationsToShow)) + return NosNotification.followsRequest(for: user, limit: limit) } else { - _events = FetchRequest(fetchRequest: Event.emptyRequest()) + let emptyRequest = NosNotification.emptyRequest() + return emptyRequest } - } - - func subscribeToNewEvents() async { + } + + /// Creates the network-specific notification fetch requests. + /// + /// This is implemented as a static function because it's used during initialization + /// and doesn't require access to instance properties. + /// + /// - Parameters: + /// - user: The user to fetch notifications for. If nil, returns empty requests. + /// - limit: The maximum number of notifications to fetch. + /// - Returns: A tuple containing fetch requests for in-network and out-of-network notifications. + private static func createNetworkFetchRequests(for user: Author?, limit: Int) -> ( + outOfNetwork: NSFetchRequest, + inNetwork: NSFetchRequest + ) { + if let user { + return ( + outOfNetwork: NosNotification.outOfNetworkRequest(for: user, limit: limit), + inNetwork: NosNotification.inNetworkRequest(for: user, limit: limit) + ) + } else { + let emptyRequest = NosNotification.emptyRequest() + return (outOfNetwork: emptyRequest, inNetwork: emptyRequest) + } + } + + private func subscribeToNewEvents() async { await cancelSubscriptions() - - guard let currentUserKey = user?.hexadecimalPublicKey else { + + guard let currentUserKey = currentUser.author?.hexadecimalPublicKey else { return } - + let filter = Filter( kinds: [.text, .zapReceipt], pTags: [currentUserKey], @@ -46,13 +91,13 @@ struct NotificationsView: View { let subscriptions = await relayService.fetchEvents(matching: filter) relaySubscriptions.append(subscriptions) } - - func cancelSubscriptions() async { + + private func cancelSubscriptions() async { relaySubscriptions.removeAll() } - - func markAllNotificationsRead() async { - if user != nil { + + private func markAllNotificationsRead() async { + if currentUser.author != nil { do { let backgroundContext = persistenceController.backgroundViewContext try await NosNotification.markAllAsRead(in: backgroundContext) @@ -62,31 +107,10 @@ struct NotificationsView: View { } } } - + var body: some View { NosNavigationStack(path: $router.notificationsPath) { - ScrollView(.vertical) { - LazyVStack { - /// The fetch request for events has a `fetchLimit` set but it doesn't work, so we limit the - /// number of views displayed here and that appears to prevent @FetchRequest from loading all the - /// records into memory. - ForEach(0.. Void + + var body: some View { + Button(action: action) { + Text(title) + .font(.subheadline) + .fontWeight(.bold) + .padding(.vertical, 12) + .padding(.horizontal, 16) // increase touch area width + .foregroundColor(isSelected ? .primaryTxt : .secondaryTxt) + } + } +} + +/// A scrollable view that displays a list of notifications for a specific category +/// (follows, in-network, or out-of-network). +private struct NotificationTabView: View { + let notifications: FetchedResults + let currentUser: CurrentUser + let maxNotificationsToShow: Int + let tag: Int + var body: some View { + ScrollView(.vertical) { + LazyVStack { + /// The fetch request for events has a `fetchLimit` set but it doesn't work, so we limit the + /// number of views displayed here and that appears to prevent @FetchRequest from loading all the + /// records into memory. + ForEach(0.. alice -> bob + let currentUserFollowsAlice = Follow(context: testContext) + currentUserFollowsAlice.source = currentUser + currentUserFollowsAlice.destination = alice + + let aliceFollowsBob = Follow(context: testContext) + aliceFollowsBob.source = alice + aliceFollowsBob.destination = bob + + let event = Event(context: testContext) + event.identifier = "test_event" + event.author = bob + + let notification = NosNotification(context: testContext) + notification.event = event + + try testContext.save() + + // Act + let fetchRequest = NosNotification.outOfNetworkRequest(for: currentUser) + let results = try testContext.fetch(fetchRequest) + + // Assert + XCTAssertEqual(results.count, 0) + } + + // MARK: - In Network Request Tests + + @MainActor func test_inNetwork_includesDirectlyFollowedAuthor() throws { + // Arrange + let currentUser = try Author.findOrCreate(by: "current_user", context: testContext) + let alice = try Author.findOrCreate(by: "alice", context: testContext) + + // Create follow relationship + let currentUserFollowsAlice = Follow(context: testContext) + currentUserFollowsAlice.source = currentUser + currentUserFollowsAlice.destination = alice + + let event = Event(context: testContext) + event.identifier = "test_event" + event.author = alice + + let notification = NosNotification(context: testContext) + notification.event = event + + try testContext.save() + + // Act + let fetchRequest = NosNotification.inNetworkRequest(for: currentUser) + let results = try testContext.fetch(fetchRequest) + + // Assert + XCTAssertEqual(results.count, 1) + XCTAssertEqual(results.first?.event?.author?.hexadecimalPublicKey, alice.hexadecimalPublicKey) + } + + @MainActor func test_inNetwork_includesIndirectlyConnectedAuthor() throws { + // Arrange + let currentUser = try Author.findOrCreate(by: "current_user", context: testContext) + let alice = try Author.findOrCreate(by: "alice", context: testContext) + let bob = try Author.findOrCreate(by: "bob", context: testContext) + + // Create follow chain: currentUser -> alice -> bob + let currentUserFollowsAlice = Follow(context: testContext) + currentUserFollowsAlice.source = currentUser + currentUserFollowsAlice.destination = alice + + let aliceFollowsBob = Follow(context: testContext) + aliceFollowsBob.source = alice + aliceFollowsBob.destination = bob + + let event = Event(context: testContext) + event.identifier = "test_event" + event.author = bob + + let notification = NosNotification(context: testContext) + notification.event = event + + try testContext.save() + + // Act + let fetchRequest = NosNotification.inNetworkRequest(for: currentUser) + let results = try testContext.fetch(fetchRequest) + + // Assert + XCTAssertEqual(results.count, 1) + XCTAssertEqual(results.first?.event?.author?.hexadecimalPublicKey, bob.hexadecimalPublicKey) + } + + @MainActor func test_inNetwork_excludesAuthorWithNoConnection() throws { + // Arrange + let currentUser = try Author.findOrCreate(by: "current_user", context: testContext) + let unconnectedAuthor = try Author.findOrCreate(by: "unconnected", context: testContext) + + let event = Event(context: testContext) + event.identifier = "test_event" + event.author = unconnectedAuthor + + let notification = NosNotification(context: testContext) + notification.event = event + + try testContext.save() + + // Act + let fetchRequest = NosNotification.inNetworkRequest(for: currentUser) + let results = try testContext.fetch(fetchRequest) + + // Assert + XCTAssertEqual(results.count, 0) + } + + @MainActor func test_inNetwork_excludesFollowNotifications() throws { + // Arrange + let currentUser = try Author.findOrCreate(by: "current_user", context: testContext) + let follower = try Author.findOrCreate(by: "follower", context: testContext) + + // Create follow relationship to ensure the author would be "in network" + let follow = Follow(context: testContext) + follow.source = currentUser + follow.destination = follower + + // Create notification with both follower and event + let notification = NosNotification(context: testContext) + notification.follower = follower + + let event = Event(context: testContext) + event.identifier = "test_event" + event.author = follower // This would normally make it appear in inNetwork + notification.event = event + + try testContext.save() + + // Act + let fetchRequest = NosNotification.inNetworkRequest(for: currentUser) + let results = try testContext.fetch(fetchRequest) + + // Assert + XCTAssertEqual(results.count, 0, "Should exclude notification even though author is in network") + } + + // MARK: - Follows Request Tests + @MainActor func test_followsRequest_includesOnlyFollowNotifications() throws { + // Arrange + let user = try Author.findOrCreate(by: "user", context: testContext) + let follower = try Author.findOrCreate(by: "follower", context: testContext) + + // Create a follow notification + let followNotification = NosNotification(context: testContext) + followNotification.follower = follower + followNotification.user = user + followNotification.createdAt = Date() + + // Create a regular notification + let regularNotification = NosNotification(context: testContext) + regularNotification.event = Event(context: testContext) + regularNotification.user = user + regularNotification.createdAt = Date() + + try testContext.save() + + // Act + let fetchRequest = NosNotification.followsRequest(for: user) + let results = try testContext.fetch(fetchRequest) + + // Assert + XCTAssertEqual(results.count, 1) + XCTAssertEqual(results.first?.follower?.hexadecimalPublicKey, follower.hexadecimalPublicKey) + } + + // MARK: - All Notifications Tests + @MainActor func test_allRequest_includesAllNotificationsForUser() throws { + // Arrange + let user = try Author.findOrCreate(by: "user", context: testContext) + let follower = try Author.findOrCreate(by: "follower", context: testContext) + let eventAuthor = try Author.findOrCreate(by: "author", context: testContext) + + // Create a follow notification + let followNotification = NosNotification(context: testContext) + followNotification.follower = follower + followNotification.user = user + followNotification.createdAt = Date() + + // Create an event notification + let event = Event(context: testContext) + event.author = eventAuthor + event.identifier = "test_event" + + let eventNotification = NosNotification(context: testContext) + eventNotification.event = event + eventNotification.user = user + eventNotification.createdAt = Date() + + try testContext.save() + + // Act + let fetchRequest = NosNotification.allRequest(for: user) + let results = try testContext.fetch(fetchRequest) + + // Assert + XCTAssertEqual(results.count, 2) + } + + @MainActor func test_allRequest_sortsNotificationsByCreatedAtDescending() throws { + // Arrange + let user = try Author.findOrCreate(by: "user", context: testContext) + + let laterDate = Calendar.current.date(byAdding: .hour, value: -2, to: .now)! + let recentDate = Calendar.current.date(byAdding: .hour, value: -1, to: .now)! + + let laterNotification = NosNotification(context: testContext) + laterNotification.user = user + laterNotification.createdAt = laterDate + + let recentNotification = NosNotification(context: testContext) + recentNotification.user = user + recentNotification.createdAt = recentDate + + try testContext.save() + + // Act + let fetchRequest = NosNotification.allRequest(for: user) + let results = try testContext.fetch(fetchRequest) + + // Assert + XCTAssertEqual(results.count, 2) + XCTAssertEqual(results[0].createdAt, recentDate) + XCTAssertEqual(results[1].createdAt, laterDate) + } +} diff --git a/NosTests/Models/NotificationViewModelTests.swift b/NosTests/Models/NotificationViewModelTests.swift index 5af94d1ca..5aa007040 100644 --- a/NosTests/Models/NotificationViewModelTests.swift +++ b/NosTests/Models/NotificationViewModelTests.swift @@ -8,8 +8,8 @@ final class NotificationViewModelTests: CoreDataTestCase { func testZapProfileNotification() throws { let zapRequest = try zapRequestEvent(filename: "zap_request") let recipient = try XCTUnwrap(Author.findOrCreate(by: "alice", context: testContext)) - let viewModel = NotificationViewModel(note: zapRequest, user: recipient) - + let viewModel = NotificationViewModel(note: zapRequest, user: recipient, date: Date()) + let notification = viewModel.notificationCenterRequest XCTAssertEqual(notification.content.title, "npub1vnz0m... ⚡️ zapped you 3,500 sats!") XCTAssertEqual(notification.content.body, "Zapped you!") @@ -19,8 +19,8 @@ final class NotificationViewModelTests: CoreDataTestCase { func testZapProfileNotification_noAmount() throws { let zapRequest = try zapRequestEvent(filename: "zap_request_no_amount") let recipient = try XCTUnwrap(Author.findOrCreate(by: "alice", context: testContext)) - let viewModel = NotificationViewModel(note: zapRequest, user: recipient) - + let viewModel = NotificationViewModel(note: zapRequest, user: recipient, date: Date()) + let notification = viewModel.notificationCenterRequest XCTAssertEqual(notification.content.title, "npub1vnz0m... ⚡️ zapped you!") XCTAssertEqual(notification.content.body, "Zap!") @@ -30,7 +30,7 @@ final class NotificationViewModelTests: CoreDataTestCase { func testZapProfileNotification_oneSat() throws { let zapRequest = try zapRequestEvent(filename: "zap_request_one_sat") let recipient = try XCTUnwrap(Author.findOrCreate(by: "alice", context: testContext)) - let viewModel = NotificationViewModel(note: zapRequest, user: recipient) + let viewModel = NotificationViewModel(note: zapRequest, user: recipient, date: Date()) let notification = viewModel.notificationCenterRequest XCTAssertEqual(notification.content.title, "npub1vnz0m... ⚡️ zapped you 1 sat!") diff --git a/NosTests/Service/DatabaseCleanerTests.swift b/NosTests/Service/DatabaseCleanerTests.swift index e6378b59a..044882bc3 100644 --- a/NosTests/Service/DatabaseCleanerTests.swift +++ b/NosTests/Service/DatabaseCleanerTests.swift @@ -171,7 +171,41 @@ final class DatabaseCleanerTests: CoreDataTestCase { let events = try testContext.fetch(Event.allEventsRequest()) XCTAssertEqual(events, [newEvent]) } - + + @MainActor func test_cleanup_deletesOldNotifications() async throws { + // Arrange + let alice = try Author.findOrCreate(by: KeyFixture.alice.publicKeyHex, context: testContext) + + // Creates old notification (3 months) + let oldDate = Calendar.current.date(byAdding: .month, value: -3, to: .now) ?? .now + let oldNotification = NosNotification(context: testContext) + oldNotification.createdAt = oldDate + oldNotification.user = alice + + // Creates recent notification (1 day old) + let recentDate = Calendar.current.date(byAdding: .day, value: -1, to: .now)! + let recentNotification = NosNotification(context: testContext) + recentNotification.createdAt = recentDate + recentNotification.user = alice + + try testContext.save() + + // Verify initial notifications before cleanup. + let initialCount = try testContext.fetch(NosNotification.fetchRequest()).count + XCTAssertEqual(initialCount, 2) + + // Act + try await DatabaseCleaner.cleanupEntities( + for: KeyFixture.alice.publicKeyHex, + in: testContext + ) + + // Assert + let remainingNotifications = try testContext.fetch(NosNotification.fetchRequest()) + XCTAssertEqual(remainingNotifications.count, 1) + XCTAssertEqual(remainingNotifications.first?.createdAt, recentDate) + } + // MARK: - Authors @MainActor func test_cleanup_keepsInNetworkAuthors() async throws { @@ -372,4 +406,40 @@ final class DatabaseCleanerTests: CoreDataTestCase { relays = try testContext.fetch(allRelaysRequest) XCTAssertTrue(relays.isEmpty) } + + @MainActor func test_deleteNotificationsAndEvents() async throws { + // Arrange + let alice = try Author.findOrCreate(by: KeyFixture.alice.publicKeyHex, context: testContext) + let bob = try Author.findOrCreate(by: KeyFixture.bob.publicKeyHex, context: testContext) + + // Create events + let event1 = try EventFixture.build(in: testContext, publicKey: KeyFixture.alice.publicKeyHex) + let event2 = try EventFixture.build(in: testContext, publicKey: KeyFixture.bob.publicKeyHex) + + // Create notifications + let notification1 = NosNotification(context: testContext) + notification1.createdAt = Date() + notification1.user = alice + notification1.event = event1 + + let notification2 = NosNotification(context: testContext) + notification2.createdAt = Date() + notification2.user = bob + notification2.event = event2 + + try testContext.save() + + // Verify initial state + XCTAssertEqual(try testContext.count(for: Event.allEventsRequest()), 2) + XCTAssertEqual(try testContext.count(for: NosNotification.fetchRequest()), 2) + XCTAssertEqual(try testContext.count(for: Author.allAuthorsRequest()), 2) + + // Act + try await DatabaseCleaner.deleteNotificationsAndEvents(in: testContext) + + // Assert + XCTAssertEqual(try testContext.count(for: Event.allEventsRequest()), 0) + XCTAssertEqual(try testContext.count(for: NosNotification.fetchRequest()), 0) + XCTAssertEqual(try testContext.count(for: Author.allAuthorsRequest()), 2) + } }