From e75f95451d73aea3b00f39d93be95094f75d5ae2 Mon Sep 17 00:00:00 2001 From: Itunu Raimi Date: Wed, 8 Jan 2025 22:06:25 +0100 Subject: [PATCH 01/30] update appDelegate --- Nos/AppDelegate.swift | 3 +++ 1 file changed, 3 insertions(+) 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") From 3ecfbb7a0dbc6dde8ae2b7e7812c79fe21ba90a7 Mon Sep 17 00:00:00 2001 From: Itunu Raimi Date: Wed, 8 Jan 2025 22:08:26 +0100 Subject: [PATCH 02/30] update NotificationViewModel --- Nos/Models/NotificationViewModel.swift | 71 ++++++++++++++++++++------ 1 file changed, 54 insertions(+), 17 deletions(-) diff --git a/Nos/Models/NotificationViewModel.swift b/Nos/Models/NotificationViewModel.swift index 838bafb13..1b13faf2e 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.eventID, 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: "followed you")) + 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 } From 09b589526f2836b1c215c71eaecdb02d7cd12d2a Mon Sep 17 00:00:00 2001 From: Itunu Raimi Date: Wed, 8 Jan 2025 22:09:26 +0100 Subject: [PATCH 03/30] update coredata classes --- .../CoreData/Author+CoreDataClass.swift | 7 +++ .../NosNotification+CoreDataClass.swift | 54 ++++++++++++++++--- 2 files changed, 55 insertions(+), 6 deletions(-) diff --git a/Nos/Models/CoreData/Author+CoreDataClass.swift b/Nos/Models/CoreData/Author+CoreDataClass.swift index 2ef579c85..8be599c25 100644 --- a/Nos/Models/CoreData/Author+CoreDataClass.swift +++ b/Nos/Models/CoreData/Author+CoreDataClass.swift @@ -406,4 +406,11 @@ import Logger // Publish the modified list await currentUser.publishMuteList(keys: Array(Set(mutedList))) } + + /// Returns true if this event tagged the given author. + func this(author: Author) -> Bool { + followNotifications.contains(where: { element in + (element as? NosNotification)?.follower == author + }) + } } diff --git a/Nos/Models/CoreData/NosNotification+CoreDataClass.swift b/Nos/Models/CoreData/NosNotification+CoreDataClass.swift index 03475b1d5..97a73d1ad 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, @@ -26,8 +26,8 @@ public class NosNotification: NSManagedObject { 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 @@ -35,8 +35,8 @@ public class NosNotification: NSManagedObject { 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.fetchLimit = 1 @@ -75,4 +75,46 @@ 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: + /// - user: 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 events to fetch. + /// - Returns: A fetch request for the events described. + @nonobjc public class func all( + notifying user: 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 + } + + // A notification for the user + let forUserPredicate = NSPredicate(format: "user = %@", user) + // User is being followed + let isFollowPredicate = NSPredicate(format: "follower != nil") + + var predicates: [NSPredicate] = [ + NSCompoundPredicate(orPredicateWithSubpredicates: [forUserPredicate, isFollowPredicate]) + ] + + if let since { + predicates.append(NSPredicate(format: "createdAt >= %@", since as CVarArg)) + } + +// fetchRequest.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: predicates) + + return fetchRequest + } } From a05bdb7f311159a096295f07d1258e0452c3518b Mon Sep 17 00:00:00 2001 From: Itunu Raimi Date: Wed, 8 Jan 2025 22:15:09 +0100 Subject: [PATCH 04/30] update push notification service --- Nos/Service/PushNotificationService.swift | 175 ++++++++++++++++------ 1 file changed, 133 insertions(+), 42 deletions(-) diff --git a/Nos/Service/PushNotificationService.swift b/Nos/Service/PushNotificationService.swift index 63e65f375..351db246f 100644 --- a/Nos/Service/PushNotificationService.swift +++ b/Nos/Service/PushNotificationService.swift @@ -19,7 +19,7 @@ import Combine 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. @@ -50,7 +50,7 @@ import Combine 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() @@ -140,8 +140,7 @@ import Combine } // 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 { @@ -150,34 +149,33 @@ import Combine 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 // 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 { @@ -188,7 +186,76 @@ 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 { @@ -200,18 +267,12 @@ import Combine !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) } } } @@ -219,7 +280,7 @@ import Combine } func userNotificationCenter( - _ center: UNUserNotificationCenter, + _ center: UNUserNotificationCenter, willPresent notification: UNNotification ) async -> UNNotificationPresentationOptions { analytics.displayedNotification() @@ -229,10 +290,10 @@ import Combine // 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 { @@ -246,6 +307,36 @@ import Combine 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 { From c49006fab4bb8b1514616fb066a1cd5f12ca2d75 Mon Sep 17 00:00:00 2001 From: Itunu Raimi Date: Wed, 8 Jan 2025 22:20:28 +0100 Subject: [PATCH 05/30] update NotificationView --- .../Notifications/NotificationCard.swift | 37 ++++++-- .../Notifications/NotificationsView.swift | 95 +++++++++++++------ 2 files changed, 92 insertions(+), 40 deletions(-) diff --git a/Nos/Views/Notifications/NotificationCard.swift b/Nos/Views/Notifications/NotificationCard.swift index 82ffc2d49..3703d83d5 100644 --- a/Nos/Views/Notifications/NotificationCard.swift +++ b/Nos/Views/Notifications/NotificationCard.swift @@ -12,17 +12,32 @@ struct NotificationCard: View { 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 note = Event.find(by: viewModel.noteID ?? "", context: viewContext) else { + return } router.push(note.referencedNote() ?? note) } - + + private func showFollowProfile() { + guard let follower = try? Author.find(by: viewModel.authorID ?? "", context: viewContext) else { + return + } + router.push(follower) + } + var body: some View { Button { - showNote() + switch viewModel.notificationType { + case .event: showNote() + case .follow : showFollowProfile() + } + } label: { HStack { AvatarView(imageUrl: viewModel.authorProfilePhotoURL, size: 40) @@ -56,10 +71,12 @@ struct NotificationCard: View { VStack { Spacer() - Text(viewModel.date.distanceString()) - .lineLimit(1) - .font(.clarity(.regular)) - .foregroundColor(.secondaryTxt) + if let date = viewModel.date { + Text(date.distanceString()) + .lineLimit(1) + .font(.clarity(.regular)) + .foregroundColor(.secondaryTxt) + } } .fixedSize() } @@ -120,7 +137,7 @@ struct NotificationCard: View { 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 ce962c4a8..9aa571bcc 100644 --- a/Nos/Views/Notifications/NotificationsView.swift +++ b/Nos/Views/Notifications/NotificationsView.swift @@ -4,39 +4,41 @@ 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 @Dependency(\.analytics) private var analytics @Dependency(\.pushNotificationService) private var pushNotificationService @Dependency(\.persistenceController) private var persistenceController - @FetchRequest private var events: FetchedResults + @FetchRequest private var notifications: 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? + + @Environment(CurrentUser.self) private var currentUser private let maxNotificationsToShow = 100 - + init(user: Author?) { - self.user = user if let user { - _events = FetchRequest(fetchRequest: Event.all(notifying: user, limit: maxNotificationsToShow)) + let request = NosNotification.all( + notifying: user, + limit: maxNotificationsToShow + ) + _notifications = FetchRequest(fetchRequest: request) } else { - _events = FetchRequest(fetchRequest: Event.emptyRequest()) + _notifications = FetchRequest(fetchRequest: NosNotification.emptyRequest()) } - } - + } + 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 +48,13 @@ struct NotificationsView: View { let subscriptions = await relayService.fetchEvents(matching: filter) relaySubscriptions.append(subscriptions) } - + func cancelSubscriptions() async { relaySubscriptions.removeAll() } - + func markAllNotificationsRead() async { - if user != nil { + if currentUser.author != nil { do { let backgroundContext = persistenceController.backgroundViewContext try await NosNotification.markAllAsRead(in: backgroundContext) @@ -62,28 +64,24 @@ 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 + /// number of views displayed here and ?that appears to prevent @FetchRequest from loading all the /// records into memory. ForEach(0.. Date: Thu, 9 Jan 2025 19:21:24 +0100 Subject: [PATCH 06/30] clean up --- .../NosNotification+CoreDataClass.swift | 33 +++----- Nos/Service/PushNotificationService.swift | 78 +++++++++---------- 2 files changed, 51 insertions(+), 60 deletions(-) diff --git a/Nos/Models/CoreData/NosNotification+CoreDataClass.swift b/Nos/Models/CoreData/NosNotification+CoreDataClass.swift index 97a73d1ad..2d85f7d8e 100644 --- a/Nos/Models/CoreData/NosNotification+CoreDataClass.swift +++ b/Nos/Models/CoreData/NosNotification+CoreDataClass.swift @@ -21,8 +21,15 @@ public class NosNotification: NosManagedObject { } 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) { + if event.kind == EventKind.contactList.rawValue { + notification.follower = event.author + } + } + return notification } } @@ -32,7 +39,7 @@ public class NosNotification: NosManagedObject { if let notification = try context.fetch(fetchRequest).first { return notification } - + return nil } @@ -43,13 +50,13 @@ public class NosNotification: NosManagedObject { fetchRequest.sortDescriptors = [NSSortDescriptor(keyPath: \NosNotification.eventID, 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: NosManagedObject { for notification in unreadNotifications { notification.isRead = true } - + try? context.saveIfNeeded() } } @@ -99,22 +106,6 @@ public class NosNotification: NosManagedObject { if let limit { fetchRequest.fetchLimit = limit } - - // A notification for the user - let forUserPredicate = NSPredicate(format: "user = %@", user) - // User is being followed - let isFollowPredicate = NSPredicate(format: "follower != nil") - - var predicates: [NSPredicate] = [ - NSCompoundPredicate(orPredicateWithSubpredicates: [forUserPredicate, isFollowPredicate]) - ] - - if let since { - predicates.append(NSPredicate(format: "createdAt >= %@", since as CVarArg)) - } - -// fetchRequest.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: predicates) - return fetchRequest } } diff --git a/Nos/Service/PushNotificationService.swift b/Nos/Service/PushNotificationService.swift index 351db246f..d2c0fdb12 100644 --- a/Nos/Service/PushNotificationService.swift +++ b/Nos/Service/PushNotificationService.swift @@ -10,9 +10,9 @@ 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 @@ -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? @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 { + 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,11 +134,11 @@ 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 /// NosNotification record in the database. @@ -146,10 +146,10 @@ import Combine 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 else { + let eventCreated = event.createdAt else { return nil } @@ -170,14 +170,14 @@ import Combine 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. 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() @@ -244,7 +244,7 @@ import Combine // Don't alert for old notifications or muted authors guard eventCreated > self.showPushNotificationsAfter, - event.author?.muted == false else { + event.author?.muted == false else { coreDataNotification.isRead = true return nil } @@ -257,15 +257,15 @@ import Combine } // 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 if let follower = try? Author.find(by: eventID, context: self.persistenceController.viewContext) { self.router.selectedTab = .notifications @@ -278,7 +278,7 @@ import Combine } } } - + func userNotificationCenter( _ center: UNUserNotificationCenter, willPresent notification: UNNotification @@ -286,9 +286,9 @@ import Combine analytics.displayedNotification() return [.list, .banner, .badge, .sound] } - + // MARK: - NSFetchedResultsControllerDelegate - + nonisolated func controller( _ controller: NSFetchedResultsController, didChange anObject: Any, @@ -299,12 +299,12 @@ import Combine guard type == .insert else { return } - + guard let event = anObject as? Event, - let eventID = event.identifier else { + let eventID = event.identifier else { return } - + Task { await showNotificationIfNecessary(for: eventID) } } From 9846f80afe8e365c30634e1c4be501b043ab7991 Mon Sep 17 00:00:00 2001 From: Itunu Raimi Date: Thu, 9 Jan 2025 19:24:15 +0100 Subject: [PATCH 07/30] add changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0f89acdf6..6ea2e79ec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Internal Changes - Added function for creating a new list and a test verifying list editing. [#112](https://github.com/verse-pbc/issues/issues/112) - Localized strings on the feed filter drop-down view. +- Added functionality to get follows notifications in the Notifications tab. [#127](https://github.com/verse-pbc/issues/issues/127) ## [1.1] - 2025-01-03Z From 036126077a19b0967a207792a4b8b7045a78ed84 Mon Sep 17 00:00:00 2001 From: Itunu Raimi Date: Thu, 9 Jan 2025 19:27:46 +0100 Subject: [PATCH 08/30] fix swiftlint warnings --- .../Notifications/NotificationCard.swift | 23 +++++++++---------- 1 file changed, 11 insertions(+), 12 deletions(-) diff --git a/Nos/Views/Notifications/NotificationCard.swift b/Nos/Views/Notifications/NotificationCard.swift index 3703d83d5..8013477a6 100644 --- a/Nos/Views/Notifications/NotificationCard.swift +++ b/Nos/Views/Notifications/NotificationCard.swift @@ -3,12 +3,12 @@ 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? @@ -35,14 +35,13 @@ struct NotificationCard: View { Button { switch viewModel.notificationType { case .event: showNote() - case .follow : showFollowProfile() + case .follow: showFollowProfile() } - } label: { HStack { AvatarView(imageUrl: viewModel.authorProfilePhotoURL, size: 40) .shadow(radius: 10, y: 4) - + VStack { HStack { Text(viewModel.actionText) @@ -57,7 +56,7 @@ struct NotificationCard: View { .foregroundColor(.primaryTxt) .tint(.accent) .handleURLsInRouter() - + if viewModel.content == nil { contentText.redacted(reason: .placeholder) } else { @@ -68,7 +67,7 @@ struct NotificationCard: View { } } .frame(maxWidth: .infinity) - + VStack { Spacer() if let date = viewModel.date { @@ -110,17 +109,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!" @@ -134,7 +133,7 @@ struct NotificationCard: View { try? previewContext.save() return mentionNote }() - + return VStack { Spacer() NotificationCard(viewModel: NotificationViewModel(note: note, user: bob, date: Date())) From 98b792f3868cf83d4adc5f4b5118c9a4e5e10fa0 Mon Sep 17 00:00:00 2001 From: Itunu Raimi Date: Thu, 9 Jan 2025 19:41:37 +0100 Subject: [PATCH 09/30] fix swiftlint warning --- Nos/Service/PushNotificationService.swift | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/Nos/Service/PushNotificationService.swift b/Nos/Service/PushNotificationService.swift index d2c0fdb12..04fbdaa97 100644 --- a/Nos/Service/PushNotificationService.swift +++ b/Nos/Service/PushNotificationService.swift @@ -61,7 +61,7 @@ import Combine relaySubscription = nil guard let author = user.author, - let authorKey = author.hexadecimalPublicKey else { + let authorKey = author.hexadecimalPublicKey else { notificationWatcher = NSFetchedResultsController( fetchRequest: Event.emptyRequest(), managedObjectContext: modelContext, @@ -149,7 +149,7 @@ import Combine let viewModel: NotificationViewModel? = await modelContext.perform { () -> NotificationViewModel? in guard let event = Event.find(by: eventID, context: self.modelContext), - let eventCreated = event.createdAt else { + let eventCreated = event.createdAt else { return nil } @@ -244,7 +244,7 @@ import Combine // Don't alert for old notifications or muted authors guard eventCreated > self.showPushNotificationsAfter, - event.author?.muted == false else { + event.author?.muted == false else { coreDataNotification.isRead = true return nil } @@ -264,7 +264,7 @@ import Combine let userInfo = response.notification.request.content.userInfo if let eventID = userInfo["eventID"] as? String, - !eventID.isEmpty { + !eventID.isEmpty { Task { @MainActor in if let follower = try? Author.find(by: eventID, context: self.persistenceController.viewContext) { @@ -301,7 +301,7 @@ import Combine } guard let event = anObject as? Event, - let eventID = event.identifier else { + let eventID = event.identifier else { return } @@ -310,8 +310,8 @@ import Combine 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 { + let firstPubkey = followPubkeys.first, + let followerKey = PublicKey.build(npubOrHex: firstPubkey)?.hex { // Create Event for the follow await modelContext.perform { [weak self] in From b66dcab7d1744e037b22a7fe5d4b5d2f78d6bd7d Mon Sep 17 00:00:00 2001 From: Itunu Raimi Date: Mon, 13 Jan 2025 15:30:13 +0100 Subject: [PATCH 10/30] create new coredata model version --- .../Generated/Event+CoreDataProperties.swift | 1 + .../NosNotification+CoreDataProperties.swift | 2 +- .../Nos.xcdatamodeld/.xccurrentversion | 2 +- .../Nos 24.xcdatamodel/.xccurrentversion | 8 ++ .../Nos.xcdatamodel/contents | 56 +++++++++ .../Nos 24.xcdatamodel/contents | 112 ++++++++++++++++++ 6 files changed, 179 insertions(+), 2 deletions(-) create mode 100644 Nos/Models/CoreData/Nos.xcdatamodeld/Nos 24.xcdatamodel/.xccurrentversion create mode 100644 Nos/Models/CoreData/Nos.xcdatamodeld/Nos 24.xcdatamodel/Nos.xcdatamodel/contents create mode 100644 Nos/Models/CoreData/Nos.xcdatamodeld/Nos 24.xcdatamodel/contents 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 From 22fe772cd84f3d78f53ac6b70bbfe5e3ad0dfb7b Mon Sep 17 00:00:00 2001 From: Itunu Raimi Date: Mon, 13 Jan 2025 15:31:18 +0100 Subject: [PATCH 11/30] write notifications unit test --- .../CoreData/NosNotificationTests.swift | 315 ++++++++++++++++++ 1 file changed, 315 insertions(+) create mode 100644 NosTests/Models/CoreData/NosNotificationTests.swift diff --git a/NosTests/Models/CoreData/NosNotificationTests.swift b/NosTests/Models/CoreData/NosNotificationTests.swift new file mode 100644 index 000000000..ebb4f01ee --- /dev/null +++ b/NosTests/Models/CoreData/NosNotificationTests.swift @@ -0,0 +1,315 @@ +import CoreData +import XCTest + +final class NosNotificationTests: CoreDataTestCase { + + @MainActor func test_outOfNetwork_excludesFollowNotifications() throws { + // Arrange + let currentUser = try Author.findOrCreate(by: "current_user", context: testContext) + let follower = try Author.findOrCreate(by: "follower", context: testContext) + let unconnectedAuthor = try Author.findOrCreate(by: "unconnected", context: testContext) + + // Create notification with both follower and event from an unconnected author + let notification = NosNotification(context: testContext) + notification.follower = follower + + let event = Event(context: testContext) + event.identifier = "test_event" + event.author = unconnectedAuthor // This would normally make it appear in outOfNetwork + notification.event = event + + try testContext.save() + + // Act + let fetchRequest = NosNotification.outOfNetworkRequest(for: currentUser) + let results = try testContext.fetch(fetchRequest) + + // Assert + XCTAssertEqual(results.count, 0) + } + + @MainActor func test_outOfNetwork_includesAuthorWithNoFollowers() throws { + // Arrange + let currentUser = try Author.findOrCreate(by: "current_user", context: testContext) + let author = try Author.findOrCreate(by: "author", context: testContext) + + let event = Event(context: testContext) + event.identifier = "test_event" + event.author = author + + 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, 1) + XCTAssertEqual(results.first?.event?.author?.hexadecimalPublicKey, author.hexadecimalPublicKey) + } + + @MainActor func test_outOfNetwork_excludesDirectlyFollowedAuthor() throws { + // Arrange + let currentUser = try Author.findOrCreate(by: "current_user", context: testContext) + let bob = try Author.findOrCreate(by: "bob", context: testContext) + + // Current user follows bob + let follow = Follow(context: testContext) + follow.source = currentUser + follow.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) + } + + @MainActor func test_outOfNetwork_excludesIndirectlyConnectedAuthor() 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.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) + } +} From 48e1a77544676ec1d08d5aa494428e3fa36b2ab0 Mon Sep 17 00:00:00 2001 From: Itunu Raimi Date: Mon, 13 Jan 2025 15:31:43 +0100 Subject: [PATCH 12/30] update unit test --- NosTests/Models/NotificationViewModelTests.swift | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) 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!") From 33ed1911dbf34fc5e7ed64243f5caa1090298b83 Mon Sep 17 00:00:00 2001 From: Itunu Raimi Date: Mon, 13 Jan 2025 15:32:13 +0100 Subject: [PATCH 13/30] update notificationViewModel --- Nos/Models/NotificationViewModel.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Nos/Models/NotificationViewModel.swift b/Nos/Models/NotificationViewModel.swift index 1b13faf2e..08b72951c 100644 --- a/Nos/Models/NotificationViewModel.swift +++ b/Nos/Models/NotificationViewModel.swift @@ -48,7 +48,7 @@ class NotificationViewModel: ObservableObject, Identifiable { return nil } - if let eventID = coreDataModel.eventID, let note = Event.find(by: eventID, context: context) { + 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) From 980c2343b1a4edcecd1c574d1b34e3b53b422eb3 Mon Sep 17 00:00:00 2001 From: Itunu Raimi Date: Mon, 13 Jan 2025 15:32:37 +0100 Subject: [PATCH 14/30] create Follows notification card --- Nos.xcodeproj/project.pbxproj | 36 ++++--- .../FollowsNotificationCard.swift | 93 +++++++++++++++++++ 2 files changed, 116 insertions(+), 13 deletions(-) create mode 100644 Nos/Views/Notifications/FollowsNotificationCard.swift diff --git a/Nos.xcodeproj/project.pbxproj b/Nos.xcodeproj/project.pbxproj index bb16beb5c..b934fe402 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 */; }; @@ -733,6 +735,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 = ""; }; @@ -1370,6 +1375,7 @@ children = ( C94437E529B0DB83004D8C86 /* NotificationsView.swift */, C98B8B3F29FBF83B009789C8 /* NotificationCard.swift */, + 045027FF2D35484400DA9835 /* FollowsNotificationCard.swift */, ); path = Notifications; sourceTree = ""; @@ -1434,6 +1440,7 @@ 035729A12BE4167E005FEE85 /* AuthorTests.swift */, 035729A32BE4167E005FEE85 /* EventTests.swift */, 035729A42BE4167E005FEE85 /* FollowTests.swift */, + 045027482D318E1300DA9835 /* NosNotificationTests.swift */, ); path = CoreData; sourceTree = ""; @@ -2232,7 +2239,7 @@ C9B737702AB24D5F00398BE7 /* XCRemoteSwiftPackageReference "SwiftGenPlugin" */, C91565BF2B2368FA0068EECA /* XCRemoteSwiftPackageReference "ViewInspector" */, 3AD3185B2B294E6200026B07 /* XCRemoteSwiftPackageReference "xcstrings-tool-plugin" */, - C9FD34F42BCEC89C008F8D95 /* XCRemoteSwiftPackageReference "secp256k1.swift" */, + C9FD34F42BCEC89C008F8D95 /* XCRemoteSwiftPackageReference "secp256k1" */, C9FD35112BCED5A6008F8D95 /* XCRemoteSwiftPackageReference "nostr-sdk-ios" */, 03C49ABE2C938A9C00502321 /* XCRemoteSwiftPackageReference "SwiftSoup" */, 039389212CA4985C00698978 /* XCRemoteSwiftPackageReference "SDWebImageWebPCoder" */, @@ -2607,6 +2614,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 */, @@ -2771,6 +2779,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 */, @@ -2891,11 +2900,11 @@ /* Begin PBXTargetDependency section */ 3AD3185D2B294E9000026B07 /* PBXTargetDependency */ = { isa = PBXTargetDependency; - productRef = 3AD3185C2B294E9000026B07 /* plugin:XCStringsToolPlugin */; + productRef = 3AD3185C2B294E9000026B07 /* XCStringsToolPlugin */; }; 3AEABEF32B2BF806001BC933 /* PBXTargetDependency */ = { isa = PBXTargetDependency; - productRef = 3AEABEF22B2BF806001BC933 /* plugin:XCStringsToolPlugin */; + productRef = 3AEABEF22B2BF806001BC933 /* XCStringsToolPlugin */; }; C90862C229E9804B00C35A71 /* PBXTargetDependency */ = { isa = PBXTargetDependency; @@ -2904,11 +2913,11 @@ }; C9A6C7442AD83F7A001F9500 /* PBXTargetDependency */ = { isa = PBXTargetDependency; - productRef = C9A6C7432AD83F7A001F9500 /* plugin:SwiftGenPlugin */; + productRef = C9A6C7432AD83F7A001F9500 /* SwiftGenPlugin */; }; C9D573402AB24A3700E06BB4 /* PBXTargetDependency */ = { isa = PBXTargetDependency; - productRef = C9D5733F2AB24A3700E06BB4 /* plugin:SwiftGenPlugin */; + productRef = C9D5733F2AB24A3700E06BB4 /* SwiftGenPlugin */; }; C9DEBFE6298941020078B43A /* PBXTargetDependency */ = { isa = PBXTargetDependency; @@ -3782,7 +3791,7 @@ minimumVersion = 4.0.0; }; }; - C9FD34F42BCEC89C008F8D95 /* XCRemoteSwiftPackageReference "secp256k1.swift" */ = { + C9FD34F42BCEC89C008F8D95 /* XCRemoteSwiftPackageReference "secp256k1" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/GigaBitcoin/secp256k1.swift"; requirement = { @@ -3821,12 +3830,12 @@ package = 03C49ABE2C938A9C00502321 /* XCRemoteSwiftPackageReference "SwiftSoup" */; productName = SwiftSoup; }; - 3AD3185C2B294E9000026B07 /* plugin:XCStringsToolPlugin */ = { + 3AD3185C2B294E9000026B07 /* XCStringsToolPlugin */ = { isa = XCSwiftPackageProductDependency; package = 3AD3185B2B294E6200026B07 /* XCRemoteSwiftPackageReference "xcstrings-tool-plugin" */; productName = "plugin:XCStringsToolPlugin"; }; - 3AEABEF22B2BF806001BC933 /* plugin:XCStringsToolPlugin */ = { + 3AEABEF22B2BF806001BC933 /* XCStringsToolPlugin */ = { isa = XCSwiftPackageProductDependency; package = 3AD3185B2B294E6200026B07 /* XCRemoteSwiftPackageReference "xcstrings-tool-plugin" */; productName = "plugin:XCStringsToolPlugin"; @@ -3905,7 +3914,7 @@ package = C99DBF7C2A9E81CF00F7068F /* XCRemoteSwiftPackageReference "SDWebImageSwiftUI" */; productName = SDWebImageSwiftUI; }; - C9A6C7432AD83F7A001F9500 /* plugin:SwiftGenPlugin */ = { + C9A6C7432AD83F7A001F9500 /* SwiftGenPlugin */ = { isa = XCSwiftPackageProductDependency; package = C9B737702AB24D5F00398BE7 /* XCRemoteSwiftPackageReference "SwiftGenPlugin" */; productName = "plugin:SwiftGenPlugin"; @@ -3925,7 +3934,7 @@ package = C9B71DBC2A8E9BAD0031ED9F /* XCRemoteSwiftPackageReference "sentry-cocoa" */; productName = Sentry; }; - C9D5733F2AB24A3700E06BB4 /* plugin:SwiftGenPlugin */ = { + C9D5733F2AB24A3700E06BB4 /* SwiftGenPlugin */ = { isa = XCSwiftPackageProductDependency; package = C9C8450C2AB249DB00654BC1 /* XCRemoteSwiftPackageReference "SwiftGenPlugin" */; productName = "plugin:SwiftGenPlugin"; @@ -3937,12 +3946,12 @@ }; C9FD34F52BCEC89C008F8D95 /* secp256k1 */ = { isa = XCSwiftPackageProductDependency; - package = C9FD34F42BCEC89C008F8D95 /* XCRemoteSwiftPackageReference "secp256k1.swift" */; + package = C9FD34F42BCEC89C008F8D95 /* XCRemoteSwiftPackageReference "secp256k1" */; productName = secp256k1; }; C9FD34F72BCEC8B5008F8D95 /* secp256k1 */ = { isa = XCSwiftPackageProductDependency; - package = C9FD34F42BCEC89C008F8D95 /* XCRemoteSwiftPackageReference "secp256k1.swift" */; + package = C9FD34F42BCEC89C008F8D95 /* XCRemoteSwiftPackageReference "secp256k1" */; productName = secp256k1; }; C9FD35122BCED5A6008F8D95 /* NostrSDK */ = { @@ -3966,6 +3975,7 @@ C936B4572A4C7B7C00DF1EB9 /* Nos.xcdatamodeld */ = { isa = XCVersionGroup; children = ( + 045027622D31A08D00DA9835 /* Nos 24.xcdatamodel */, 5022F9472D2188650012FF4B /* Nos 23.xcdatamodel */, 503CAB7B2D1DA6DB00805EF8 /* Nos 22.xcdatamodel */, 0303B11E2D0257D400077929 /* Nos 21.xcdatamodel */, @@ -3982,7 +3992,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/Views/Notifications/FollowsNotificationCard.swift b/Nos/Views/Notifications/FollowsNotificationCard.swift new file mode 100644 index 000000000..f78e3ecf7 --- /dev/null +++ b/Nos/Views/Notifications/FollowsNotificationCard.swift @@ -0,0 +1,93 @@ +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(1) + .font(.title3) + .fontWeight(.bold) + .foregroundColor(Color.primaryTxt) + .multilineTextAlignment(.leading) + .frame(maxWidth: .infinity, alignment: .leading) + } + } + + Text("started following you") + .lineLimit(2) + } + + if let date = viewModel.date { + Text(date.distanceString()) + .lineLimit(1) + .font(.clarity(.regular)) + .foregroundColor(.secondaryTxt) + } + } + } + .padding(.top, 20) + .padding(.bottom, 12) + .padding(.horizontal, 15) + .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) +} From 9619a7b52260d8dd3011cdf924c1ff4604cac377 Mon Sep 17 00:00:00 2001 From: Itunu Raimi Date: Mon, 13 Jan 2025 15:33:49 +0100 Subject: [PATCH 15/30] update Notification view and coredataclass --- .../NosNotification+CoreDataClass.swift | 87 ++++- .../Notifications/NotificationsView.swift | 302 ++++++++++++++---- 2 files changed, 314 insertions(+), 75 deletions(-) diff --git a/Nos/Models/CoreData/NosNotification+CoreDataClass.swift b/Nos/Models/CoreData/NosNotification+CoreDataClass.swift index 2d85f7d8e..9827bd16a 100644 --- a/Nos/Models/CoreData/NosNotification+CoreDataClass.swift +++ b/Nos/Models/CoreData/NosNotification+CoreDataClass.swift @@ -20,11 +20,11 @@ public class NosNotification: NosManagedObject { return nil } else { let notification = NosNotification(context: context) - notification.eventID = eventID 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 } @@ -45,9 +45,9 @@ public class NosNotification: NosManagedObject { 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 } @@ -92,12 +92,12 @@ public class NosNotification: NosManagedObject { /// A request for all notifications that the given user should receive a notification for. /// - Parameters: - /// - user: the author you want to view notifications for. + /// - 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 events to fetch. - /// - Returns: A fetch request for the events described. - @nonobjc public class func all( - notifying user: Author, + /// - 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 { @@ -108,4 +108,75 @@ public class NosNotification: NosManagedObject { } return fetchRequest } + + /// A request for all ot-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/Views/Notifications/NotificationsView.swift b/Nos/Views/Notifications/NotificationsView.swift index 9aa571bcc..c6d08fd39 100644 --- a/Nos/Views/Notifications/NotificationsView.swift +++ b/Nos/Views/Notifications/NotificationsView.swift @@ -9,30 +9,81 @@ 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 notifications: 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 + @State private var selectedTab = 0 - @Environment(CurrentUser.self) private var currentUser private let maxNotificationsToShow = 100 init(user: Author?) { + let mainRequests = Self.createMainFetchRequests(for: user, limit: maxNotificationsToShow) + let networkRequests = Self.createNetworkFetchRequests(for: user, limit: maxNotificationsToShow) + + _notifications = FetchRequest(fetchRequest: mainRequests.all) + _followNotifications = FetchRequest(fetchRequest: mainRequests.follows) + _outOfNetworkNotifications = FetchRequest(fetchRequest: networkRequests.outOfNetwork) + _inNetworkNotifications = FetchRequest(fetchRequest: networkRequests.inNetwork) + } + + /// Creates the main 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 tuple containing fetch requests for all notifications and follow notifications. + private static func createMainFetchRequests(for user: Author?, limit: Int) -> ( + all: NSFetchRequest, + follows: NSFetchRequest + ) { + if let user { + return ( + all: NosNotification.allRequest(for: user, limit: limit), + follows: NosNotification.followsRequest(for: user, limit: limit) + ) + } else { + let emptyRequest = NosNotification.emptyRequest() + return (all: emptyRequest, follows: emptyRequest) + } + } + + /// 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 { - let request = NosNotification.all( - notifying: user, - limit: maxNotificationsToShow + return ( + outOfNetwork: NosNotification.outOfNetworkRequest(for: user, limit: limit), + inNetwork: NosNotification.inNetworkRequest(for: user, limit: limit) ) - _notifications = FetchRequest(fetchRequest: request) } else { - _notifications = FetchRequest(fetchRequest: NosNotification.emptyRequest()) + let emptyRequest = NosNotification.emptyRequest() + return (outOfNetwork: emptyRequest, inNetwork: emptyRequest) } } - func subscribeToNewEvents() async { + private func subscribeToNewEvents() async { await cancelSubscriptions() guard let currentUserKey = currentUser.author?.hexadecimalPublicKey else { @@ -49,11 +100,11 @@ struct NotificationsView: View { relaySubscriptions.append(subscriptions) } - func cancelSubscriptions() async { + private func cancelSubscriptions() async { relaySubscriptions.removeAll() } - func markAllNotificationsRead() async { + private func markAllNotificationsRead() async { if currentUser.author != nil { do { let backgroundContext = persistenceController.backgroundViewContext @@ -67,24 +118,7 @@ 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, 8) + .foregroundColor(isSelected ? .primaryTxt : .secondaryTxt) + .cornerRadius(8) + } + } +} + +/// 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.. Date: Mon, 13 Jan 2025 15:40:57 +0100 Subject: [PATCH 16/30] update databasecleaner --- Nos/Models/CoreData/Event+Fetching.swift | 2 ++ Nos/Service/DatabaseCleaner.swift | 2 +- NosTests/Service/DatabaseCleanerTests.swift | 36 ++++++++++++++++++++- 3 files changed, 38 insertions(+), 2 deletions(-) diff --git a/Nos/Models/CoreData/Event+Fetching.swift b/Nos/Models/CoreData/Event+Fetching.swift index 82d751400..4638ff33d 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/Service/DatabaseCleaner.swift b/Nos/Service/DatabaseCleaner.swift index d924bd252..885580436 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 ) diff --git a/NosTests/Service/DatabaseCleanerTests.swift b/NosTests/Service/DatabaseCleanerTests.swift index e6378b59a..0bed1c87e 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 (2 months + 1 day old) + let oldDate = Calendar.current.date(byAdding: .day, value: -61, to: .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 { From f4f51cf2aee8871dcce053690c77ffd526cbe580 Mon Sep 17 00:00:00 2001 From: Itunu Raimi Date: Mon, 13 Jan 2025 15:57:20 +0100 Subject: [PATCH 17/30] cleanup --- Nos/Views/Notifications/NotificationCard.swift | 15 +++------------ 1 file changed, 3 insertions(+), 12 deletions(-) diff --git a/Nos/Views/Notifications/NotificationCard.swift b/Nos/Views/Notifications/NotificationCard.swift index 8013477a6..14e756fc7 100644 --- a/Nos/Views/Notifications/NotificationCard.swift +++ b/Nos/Views/Notifications/NotificationCard.swift @@ -18,25 +18,16 @@ struct NotificationCard: View { } func showNote() { - guard let note = Event.find(by: viewModel.noteID ?? "", context: viewContext) else { + guard let noteID = viewModel.noteID, + let note = Event.find(by: noteID, context: viewContext) else { return } router.push(note.referencedNote() ?? note) } - private func showFollowProfile() { - guard let follower = try? Author.find(by: viewModel.authorID ?? "", context: viewContext) else { - return - } - router.push(follower) - } - var body: some View { Button { - switch viewModel.notificationType { - case .event: showNote() - case .follow: showFollowProfile() - } + showNote() } label: { HStack { AvatarView(imageUrl: viewModel.authorProfilePhotoURL, size: 40) From b23d1d932f2fc0a4fdfb2e26415a7b2928db817b Mon Sep 17 00:00:00 2001 From: Itunu Raimi Date: Mon, 13 Jan 2025 16:20:58 +0100 Subject: [PATCH 18/30] use localised strings --- Nos/Models/NotificationViewModel.swift | 2 +- .../FollowsNotificationCard.swift | 2 +- .../Notifications/NotificationCard.swift | 2 +- .../Notifications/NotificationsView.swift | 41 +++++++++++-------- 4 files changed, 28 insertions(+), 19 deletions(-) diff --git a/Nos/Models/NotificationViewModel.swift b/Nos/Models/NotificationViewModel.swift index 08b72951c..9da063444 100644 --- a/Nos/Models/NotificationViewModel.swift +++ b/Nos/Models/NotificationViewModel.swift @@ -70,7 +70,7 @@ class NotificationViewModel: ObservableObject, Identifiable { var range = Range(uncheckedBounds: (authorName.startIndex, authorName.endIndex)) authorName[range].font = .boldSystemFont(ofSize: 17) - actionText = authorName + AttributedString(String(localized: "followed you")) + actionText = authorName + AttributedString(String(localized: "startedFollowingYou")) range = Range(uncheckedBounds: (actionText.startIndex, actionText.endIndex)) actionText[range].foregroundColor = .primaryTxt self.actionText = actionText diff --git a/Nos/Views/Notifications/FollowsNotificationCard.swift b/Nos/Views/Notifications/FollowsNotificationCard.swift index f78e3ecf7..4f902c030 100644 --- a/Nos/Views/Notifications/FollowsNotificationCard.swift +++ b/Nos/Views/Notifications/FollowsNotificationCard.swift @@ -50,7 +50,7 @@ struct FollowsNotificationCard: View { } } - Text("started following you") + Text(viewModel.actionText) .lineLimit(2) } diff --git a/Nos/Views/Notifications/NotificationCard.swift b/Nos/Views/Notifications/NotificationCard.swift index 14e756fc7..8e9ca12f2 100644 --- a/Nos/Views/Notifications/NotificationCard.swift +++ b/Nos/Views/Notifications/NotificationCard.swift @@ -19,7 +19,7 @@ struct NotificationCard: View { func showNote() { guard let noteID = viewModel.noteID, - let note = Event.find(by: noteID, context: viewContext) else { + let note = Event.find(by: noteID, context: viewContext) else { return } router.push(note.referencedNote() ?? note) diff --git a/Nos/Views/Notifications/NotificationsView.swift b/Nos/Views/Notifications/NotificationsView.swift index c6d08fd39..08f39abd5 100644 --- a/Nos/Views/Notifications/NotificationsView.swift +++ b/Nos/Views/Notifications/NotificationsView.swift @@ -161,15 +161,24 @@ struct NotificationsView: View { .overlay(Color.cardDividerTop) .shadow(color: .cardDividerTopShadow, radius: 0, x: 0, y: 1) HStack { - TabButton(title: "Follows", isSelected: selectedTab == 0) { + TabButton( + title: String(localized: "follows"), + isSelected: selectedTab == 0 + ) { selectedTab = 0 } Spacer() - TabButton(title: "In Network", isSelected: selectedTab == 1) { + TabButton( + title: String(localized: "inNetwork"), + isSelected: selectedTab == 1 + ) { selectedTab = 1 } Spacer() - TabButton(title: "Out of Network", isSelected: selectedTab == 2) { + TabButton( + title: String(localized: "outOfNetwork"), + isSelected: selectedTab == 2 + ) { selectedTab = 2 } } @@ -355,19 +364,19 @@ struct NotificationsView_Previews: PreviewProvider { inNetworkNotification.event = inNetworkNote // Creates mention from Eve (out-of-network since Bob doesn't follow Eve) - let outNetworkNote = Event(context: context) - outNetworkNote.content = "Hey @bob!" - outNetworkNote.createdAt = .now - outNetworkNote.author = eve - let outNetworkAuthorRef = AuthorReference(context: context) - outNetworkAuthorRef.pubkey = bob.hexadecimalPublicKey - outNetworkNote.authorReferences = NSMutableOrderedSet(array: [outNetworkAuthorRef]) - try? outNetworkNote.sign(withKey: KeyFixture.eve) - - let outNetworkNotification = NosNotification(context: context) - outNetworkNotification.createdAt = .now - outNetworkNotification.user = bob - outNetworkNotification.event = outNetworkNote + let outOfNetworkNote = Event(context: context) + outOfNetworkNote.content = "Hey @bob!" + outOfNetworkNote.createdAt = .now + outOfNetworkNote.author = eve + let outOfNetworkAuthorRef = AuthorReference(context: context) + outOfNetworkAuthorRef.pubkey = bob.hexadecimalPublicKey + outOfNetworkNote.authorReferences = NSMutableOrderedSet(array: [outOfNetworkAuthorRef]) + try? outOfNetworkNote.sign(withKey: KeyFixture.eve) + + let outOfNetworkNotification = NosNotification(context: context) + outOfNetworkNotification.createdAt = .now + outOfNetworkNotification.user = bob + outOfNetworkNotification.event = outOfNetworkNote try? context.save() } From 27de0ce8b593c866d775b132a13515b7f993af70 Mon Sep 17 00:00:00 2001 From: Itunu Raimi Date: Mon, 13 Jan 2025 21:46:54 +0100 Subject: [PATCH 19/30] fix comment --- Nos/Models/CoreData/NosNotification+CoreDataClass.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Nos/Models/CoreData/NosNotification+CoreDataClass.swift b/Nos/Models/CoreData/NosNotification+CoreDataClass.swift index 9827bd16a..2d15f5645 100644 --- a/Nos/Models/CoreData/NosNotification+CoreDataClass.swift +++ b/Nos/Models/CoreData/NosNotification+CoreDataClass.swift @@ -109,7 +109,7 @@ public class NosNotification: NosManagedObject { return fetchRequest } - /// A request for all ot-Of-Network notifications that the given user should receive. + /// 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. From 2fa45553db91b971bc6bb4254048f855e9debb0f Mon Sep 17 00:00:00 2001 From: Itunu Raimi Date: Tue, 14 Jan 2025 12:55:21 +0100 Subject: [PATCH 20/30] fix UI and autoscroll --- .../FollowsNotificationCard.swift | 12 +++--- .../Notifications/NotificationCard.swift | 2 +- .../Notifications/NotificationsView.swift | 42 ++++++++----------- 3 files changed, 25 insertions(+), 31 deletions(-) diff --git a/Nos/Views/Notifications/FollowsNotificationCard.swift b/Nos/Views/Notifications/FollowsNotificationCard.swift index 4f902c030..143443e26 100644 --- a/Nos/Views/Notifications/FollowsNotificationCard.swift +++ b/Nos/Views/Notifications/FollowsNotificationCard.swift @@ -41,7 +41,7 @@ struct FollowsNotificationCard: View { HStack { VStack(alignment: .leading, spacing: 4) { Text(author.safeName) - .lineLimit(1) + .lineLimit(2) .font(.title3) .fontWeight(.bold) .foregroundColor(Color.primaryTxt) @@ -50,21 +50,23 @@ struct FollowsNotificationCard: View { } } - Text(viewModel.actionText) + Text(String(localized: "startedFollowingYou")) .lineLimit(2) + .font(.callout) } if let date = viewModel.date { Text(date.distanceString()) .lineLimit(1) - .font(.clarity(.regular)) + .font(.callout) .foregroundColor(.secondaryTxt) } } } - .padding(.top, 20) + .padding(.top, 13) .padding(.bottom, 12) - .padding(.horizontal, 15) + .padding(.leading, 12) + .padding(.trailing, 12) .background(LinearGradient.cardBackground) .cornerRadius(cornerRadius) } diff --git a/Nos/Views/Notifications/NotificationCard.swift b/Nos/Views/Notifications/NotificationCard.swift index 8e9ca12f2..d08c5b7b0 100644 --- a/Nos/Views/Notifications/NotificationCard.swift +++ b/Nos/Views/Notifications/NotificationCard.swift @@ -64,7 +64,7 @@ struct NotificationCard: View { if let date = viewModel.date { Text(date.distanceString()) .lineLimit(1) - .font(.clarity(.regular)) + .font(.callout) .foregroundColor(.secondaryTxt) } } diff --git a/Nos/Views/Notifications/NotificationsView.swift b/Nos/Views/Notifications/NotificationsView.swift index 08f39abd5..5a1daaa71 100644 --- a/Nos/Views/Notifications/NotificationsView.swift +++ b/Nos/Views/Notifications/NotificationsView.swift @@ -14,7 +14,6 @@ struct NotificationsView: View { @Dependency(\.pushNotificationService) private var pushNotificationService @Dependency(\.persistenceController) private var persistenceController - @FetchRequest private var notifications: FetchedResults @FetchRequest private var outOfNetworkNotifications: FetchedResults @FetchRequest private var inNetworkNotifications: FetchedResults @FetchRequest private var followNotifications: FetchedResults @@ -26,16 +25,15 @@ struct NotificationsView: View { private let maxNotificationsToShow = 100 init(user: Author?) { - let mainRequests = Self.createMainFetchRequests(for: user, limit: maxNotificationsToShow) + let followsRequest = Self.createFollowsFetchRequest(for: user, limit: maxNotificationsToShow) let networkRequests = Self.createNetworkFetchRequests(for: user, limit: maxNotificationsToShow) - _notifications = FetchRequest(fetchRequest: mainRequests.all) - _followNotifications = FetchRequest(fetchRequest: mainRequests.follows) + _followNotifications = FetchRequest(fetchRequest: followsRequest) _outOfNetworkNotifications = FetchRequest(fetchRequest: networkRequests.outOfNetwork) _inNetworkNotifications = FetchRequest(fetchRequest: networkRequests.inNetwork) } - /// Creates the main notification fetch requests for all notifications and follows. + /// 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. @@ -43,19 +41,13 @@ struct NotificationsView: View { /// - 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 all notifications and follow notifications. - private static func createMainFetchRequests(for user: Author?, limit: Int) -> ( - all: NSFetchRequest, - follows: NSFetchRequest - ) { + /// - Returns: A fetch request for follows notifications. + private static func createFollowsFetchRequest(for user: Author?, limit: Int) -> NSFetchRequest { if let user { - return ( - all: NosNotification.allRequest(for: user, limit: limit), - follows: NosNotification.followsRequest(for: user, limit: limit) - ) + return NosNotification.followsRequest(for: user, limit: limit) } else { let emptyRequest = NosNotification.emptyRequest() - return (all: emptyRequest, follows: emptyRequest) + return emptyRequest } } @@ -146,11 +138,6 @@ struct NotificationsView: View { Task { await cancelSubscriptions() } } } - .doubleTapToPop(tab: .notifications) { proxy in - if let firstNotification = notifications.first { - proxy.scrollTo(firstNotification.id) - } - } } } @@ -246,8 +233,7 @@ private struct NotificationCell: View { date: notification.createdAt ?? .distantPast ) ) - .readabilityPadding() - .id(notification.id) + .id(event.id) } else if let followerKey = notification.follower?.hexadecimalPublicKey, let follower = try? Author.find( by: followerKey, context: persistenceController.viewContext @@ -259,8 +245,7 @@ private struct NotificationCell: View { date: notification.createdAt ?? .distantPast ) ) - .readabilityPadding() - .id(notification.id) + .id(notification.event?.id) } } } @@ -302,16 +287,23 @@ private struct NotificationTabView: View { NotificationCell(notification: notification, user: user) .tag(tag) .padding(.horizontal, 11) - .padding(.top, 20) + .padding(.top, 16) + .readabilityPadding() } } } + .padding(.vertical, 16) } .overlay(Group { if notifications.isEmpty { Text("noNotifications") } }) + .doubleTapToPop(tab: .notifications) { proxy in + if let firstNotification = notifications.first { + proxy.scrollTo(firstNotification.id) + } + } } } From afc211d6969c6357d34196b0aaba91d9e99cb4b1 Mon Sep 17 00:00:00 2001 From: Itunu Raimi Date: Fri, 17 Jan 2025 14:26:16 +0100 Subject: [PATCH 21/30] fetch events and nosNotifications on notificationView --- Nos.xcodeproj/project.pbxproj | 6 + Nos/Models/CoreData/Event+CoreDataClass.swift | 8 + Nos/Models/CoreData/Event+Fetching.swift | 88 +++++- .../NosNotification+CoreDataClass.swift | 58 +--- Nos/Models/NotificationDisplayable.swift | 19 ++ Nos/Models/NotificationViewModel.swift | 11 +- .../Notifications/NotificationsView.swift | 40 +-- NosTests/Models/CoreData/EventTests.swift | 296 +++++++++++++++--- .../CoreData/NosNotificationTests.swift | 221 ------------- 9 files changed, 412 insertions(+), 335 deletions(-) create mode 100644 Nos/Models/NotificationDisplayable.swift diff --git a/Nos.xcodeproj/project.pbxproj b/Nos.xcodeproj/project.pbxproj index b934fe402..828afbae8 100644 --- a/Nos.xcodeproj/project.pbxproj +++ b/Nos.xcodeproj/project.pbxproj @@ -157,6 +157,8 @@ 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 */; }; + 04C923BD2D3A8BFC0088A97B /* NotificationDisplayable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04C923BC2D3A8BFC0088A97B /* NotificationDisplayable.swift */; }; + 04C923BE2D3A8BFC0088A97B /* NotificationDisplayable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04C923BC2D3A8BFC0088A97B /* NotificationDisplayable.swift */; }; 04C9D7272CBF09C200EAAD4D /* TextField+PlaceHolderStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04C9D7262CBF09C200EAAD4D /* TextField+PlaceHolderStyle.swift */; }; 04C9D7912CC29D5000EAAD4D /* FeaturedAuthor+Cohort1.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04C9D7902CC29D5000EAAD4D /* FeaturedAuthor+Cohort1.swift */; }; 04C9D7932CC29D8300EAAD4D /* FeaturedAuthor+Cohort2.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04C9D7922CC29D8300EAAD4D /* FeaturedAuthor+Cohort2.swift */; }; @@ -741,6 +743,7 @@ 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 = ""; }; + 04C923BC2D3A8BFC0088A97B /* NotificationDisplayable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationDisplayable.swift; sourceTree = ""; }; 04C9D7262CBF09C200EAAD4D /* TextField+PlaceHolderStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TextField+PlaceHolderStyle.swift"; sourceTree = ""; }; 04C9D7902CC29D5000EAAD4D /* FeaturedAuthor+Cohort1.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FeaturedAuthor+Cohort1.swift"; sourceTree = ""; }; 04C9D7922CC29D8300EAAD4D /* FeaturedAuthor+Cohort2.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FeaturedAuthor+Cohort2.swift"; sourceTree = ""; }; @@ -2004,6 +2007,7 @@ C92E7F652C4EFF2600B80638 /* WebSockets */, 5BCA95D12C8A5F0D00A52D1A /* PreviewEventRepository.swift */, 04368D2A2C99A2C400DEAA2E /* FlagOption.swift */, + 04C923BC2D3A8BFC0088A97B /* NotificationDisplayable.swift */, ); path = Models; sourceTree = ""; @@ -2701,6 +2705,7 @@ 0304D0B22C9B731F001D16C7 /* MockOpenGraphService.swift in Sources */, 030E570D2CC2A05B00A4A51E /* DisplayNameView.swift in Sources */, C94437E629B0DB83004D8C86 /* NotificationsView.swift in Sources */, + 04C923BD2D3A8BFC0088A97B /* NotificationDisplayable.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -2856,6 +2861,7 @@ 035729B92BE416A6005FEE85 /* GiftWrapperTests.swift in Sources */, 50E2EB7B2C8617C800D4B360 /* NSRegularExpression+Replacement.swift in Sources */, C9246C1C2C8A42A0005495CE /* RelaySubscriptionManagerTests.swift in Sources */, + 04C923BE2D3A8BFC0088A97B /* NotificationDisplayable.swift in Sources */, 032634702C10C40B00E489B5 /* NostrBuildAPIClientTests.swift in Sources */, 0315B5F02C7E451C0020E707 /* MockMediaService.swift in Sources */, C9646EAA29B7A506007239A4 /* Analytics.swift in Sources */, diff --git a/Nos/Models/CoreData/Event+CoreDataClass.swift b/Nos/Models/CoreData/Event+CoreDataClass.swift index 3878f57cc..a9c9684b0 100644 --- a/Nos/Models/CoreData/Event+CoreDataClass.swift +++ b/Nos/Models/CoreData/Event+CoreDataClass.swift @@ -607,4 +607,12 @@ public class Event: NosManagedObject, VerifiableEvent { shouldBePublishedTo = Set() } } + +extension Event: NotificationDisplayable { + /// Returns self since an Event is its own associated event. + var event: Event? { + self + } +} + // swiftlint:enable file_length diff --git a/Nos/Models/CoreData/Event+Fetching.swift b/Nos/Models/CoreData/Event+Fetching.swift index 4638ff33d..a305d1e4f 100644 --- a/Nos/Models/CoreData/Event+Fetching.swift +++ b/Nos/Models/CoreData/Event+Fetching.swift @@ -1,3 +1,4 @@ +// swiftlint:disable file_length import CoreData extension Event { @@ -96,7 +97,90 @@ extension Event { return fetchRequest } - + + /// A request for all out-Of-Network events that the given user should receive. + /// - Parameters: + /// - currentUser: the author you want to view notifications for. + /// - limit: a max number of events to fetch. + /// - Returns: A fetch request for outOfNetwork events. + @nonobjc public class func outOfNetworkRequest( + for currentUser: Author, + limit: Int? = nil + ) -> NSFetchRequest { + let fetchRequest = NSFetchRequest(entityName: String(describing: Event.self)) + fetchRequest.sortDescriptors = [NSSortDescriptor(keyPath: \Event.createdAt, ascending: false)] + if let limit { + fetchRequest.fetchLimit = limit + } + + let mentionsPredicate = allMentionsPredicate(for: currentUser) + let repliesPredicate = allRepliesPredicate(for: currentUser) + let zapsPredicate = allZapsPredicate(for: currentUser) + + let notificationsPredicate = NSCompoundPredicate( + orPredicateWithSubpredicates: [mentionsPredicate, repliesPredicate, zapsPredicate] + ) + + // Out of network: has no followers OR not in follows network + let outOfNetworkPredicate = NSPredicate( + format: "author.followers.@count == 0 OR " + + "NOT (ANY author.followers.source IN %@.follows.destination " + + "OR author IN %@.follows.destination)", + currentUser, + currentUser + ) + + fetchRequest.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: [ + notificationsPredicate, + outOfNetworkPredicate, + NSPredicate(format: "author != %@", currentUser), + NSPredicate(format: "author.muted = false") + ]) + + return fetchRequest + } + + /// A request for all in-Network events that the given user should receive. + /// - Parameters: + /// - currentUser: the author you want to view notifications for. + /// - limit: a max number of events to fetch. + /// - Returns: A fetch request for inNetwork events. + @nonobjc public class func inNetworkRequest( + for currentUser: Author, + limit: Int? = nil + ) -> NSFetchRequest { + let fetchRequest = NSFetchRequest(entityName: String(describing: Event.self)) + fetchRequest.sortDescriptors = [NSSortDescriptor(keyPath: \Event.createdAt, ascending: false)] + if let limit { + fetchRequest.fetchLimit = limit + } + + let mentionsPredicate = allMentionsPredicate(for: currentUser) + let repliesPredicate = allRepliesPredicate(for: currentUser) + let zapsPredicate = allZapsPredicate(for: currentUser) + + let notificationsPredicate = NSCompoundPredicate( + orPredicateWithSubpredicates: [mentionsPredicate, repliesPredicate, zapsPredicate] + ) + + // In network: in follows network + let inNetworkPredicate = NSPredicate( + format: "(ANY author.followers.source IN %@.follows.destination " + + "OR author IN %@.follows.destination)", + currentUser, + currentUser + ) + + fetchRequest.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: [ + notificationsPredicate, + inNetworkPredicate, + NSPredicate(format: "author != %@", currentUser), + NSPredicate(format: "author.muted = false") + ]) + + return fetchRequest + } + @nonobjc public class func lastReceived(for user: Author) -> NSFetchRequest { let fetchRequest = NSFetchRequest(entityName: "Event") fetchRequest.predicate = NSPredicate(format: "author != %@", user) @@ -462,3 +546,5 @@ extension Event { return request } } + +// swiftlint:enable file_length diff --git a/Nos/Models/CoreData/NosNotification+CoreDataClass.swift b/Nos/Models/CoreData/NosNotification+CoreDataClass.swift index 2d15f5645..0de634858 100644 --- a/Nos/Models/CoreData/NosNotification+CoreDataClass.swift +++ b/Nos/Models/CoreData/NosNotification+CoreDataClass.swift @@ -109,57 +109,6 @@ public class NosNotification: NosManagedObject { 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. @@ -180,3 +129,10 @@ public class NosNotification: NosManagedObject { return fetchRequest } } + +extension NosNotification: NotificationDisplayable { + /// Returns the follower as the author since they generated the follow notification. + var author: Author? { + follower + } +} diff --git a/Nos/Models/NotificationDisplayable.swift b/Nos/Models/NotificationDisplayable.swift new file mode 100644 index 000000000..42941faa8 --- /dev/null +++ b/Nos/Models/NotificationDisplayable.swift @@ -0,0 +1,19 @@ +import CoreData + +/// A protocol that defines the common interface for displaying notifications in the app. +/// Both `Event` and `NosNotification` types conform to this protocol to enable unified +/// handling in notification views. +/// +/// Conforming types must be `NSManagedObject`s and `Identifiable` to support CoreData +/// persistence and unique identification in SwiftUI lists. +protocol NotificationDisplayable: NSManagedObject, Identifiable { + var createdAt: Date? { get } + + /// The associated event, if any. For `Event` types, this is the event itself. + /// For `NosNotification` types, this is the associated event if one exists. + var event: Event? { get } + + /// The author associated with this notification. For `Event` types, this is the event author. + /// For `NosNotification` types, this is the follower who generated the notification. + var author: Author? { get } +} diff --git a/Nos/Models/NotificationViewModel.swift b/Nos/Models/NotificationViewModel.swift index 9da063444..404bfcff1 100644 --- a/Nos/Models/NotificationViewModel.swift +++ b/Nos/Models/NotificationViewModel.swift @@ -73,7 +73,16 @@ class NotificationViewModel: ObservableObject, Identifiable { actionText = authorName + AttributedString(String(localized: "startedFollowingYou")) range = Range(uncheckedBounds: (actionText.startIndex, actionText.endIndex)) actionText[range].foregroundColor = .primaryTxt - self.actionText = actionText + + /// For notification content, truncate the text only if more than + /// specified maximum length. + let maxLength = 100 + if actionText.characters.count > maxLength { + let truncated = String(actionText.characters.prefix(maxLength)) + "..." + self.actionText = AttributedString(truncated) + } else { + self.actionText = actionText + } self.content = nil } diff --git a/Nos/Views/Notifications/NotificationsView.swift b/Nos/Views/Notifications/NotificationsView.swift index 5a1daaa71..4bc893877 100644 --- a/Nos/Views/Notifications/NotificationsView.swift +++ b/Nos/Views/Notifications/NotificationsView.swift @@ -14,8 +14,8 @@ struct NotificationsView: View { @Dependency(\.pushNotificationService) private var pushNotificationService @Dependency(\.persistenceController) private var persistenceController - @FetchRequest private var outOfNetworkNotifications: FetchedResults - @FetchRequest private var inNetworkNotifications: FetchedResults + @FetchRequest private var outOfNetworkEvents: FetchedResults + @FetchRequest private var inNetworkEvents: FetchedResults @FetchRequest private var followNotifications: FetchedResults @State private var relaySubscriptions = SubscriptionCancellables() @@ -29,8 +29,8 @@ struct NotificationsView: View { let networkRequests = Self.createNetworkFetchRequests(for: user, limit: maxNotificationsToShow) _followNotifications = FetchRequest(fetchRequest: followsRequest) - _outOfNetworkNotifications = FetchRequest(fetchRequest: networkRequests.outOfNetwork) - _inNetworkNotifications = FetchRequest(fetchRequest: networkRequests.inNetwork) + _outOfNetworkEvents = FetchRequest(fetchRequest: networkRequests.outOfNetwork) + _inNetworkEvents = FetchRequest(fetchRequest: networkRequests.inNetwork) } /// Creates the follows notification fetch requests for all notifications and follows. @@ -51,26 +51,26 @@ struct NotificationsView: View { } } - /// Creates the network-specific notification fetch requests. + /// Creates the network-specific events 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. + /// - user: The user to fetch events for. If nil, returns empty requests. + /// - limit: The maximum number of events to fetch. + /// - Returns: A tuple containing fetch requests for in-network and out-of-network events. private static func createNetworkFetchRequests(for user: Author?, limit: Int) -> ( - outOfNetwork: NSFetchRequest, - inNetwork: NSFetchRequest + outOfNetwork: NSFetchRequest, + inNetwork: NSFetchRequest ) { if let user { return ( - outOfNetwork: NosNotification.outOfNetworkRequest(for: user, limit: limit), - inNetwork: NosNotification.inNetworkRequest(for: user, limit: limit) + outOfNetwork: Event.outOfNetworkRequest(for: user, limit: limit), + inNetwork: Event.inNetworkRequest(for: user, limit: limit) ) } else { - let emptyRequest = NosNotification.emptyRequest() + let emptyRequest = Event.emptyRequest() return (outOfNetwork: emptyRequest, inNetwork: emptyRequest) } } @@ -194,7 +194,7 @@ struct NotificationsView: View { .tag(0) NotificationTabView( - notifications: inNetworkNotifications, + notifications: inNetworkEvents, currentUser: currentUser, maxNotificationsToShow: maxNotificationsToShow, tag: 1 @@ -202,7 +202,7 @@ struct NotificationsView: View { .tag(1) NotificationTabView( - notifications: outOfNetworkNotifications, + notifications: outOfNetworkEvents, currentUser: currentUser, maxNotificationsToShow: maxNotificationsToShow, tag: 2 @@ -215,9 +215,9 @@ struct NotificationsView: View { } /// A single notification cell that contains a follow event or a other event types in the notifications list -private struct NotificationCell: View { +private struct NotificationCell: View { @Dependency(\.persistenceController) private var persistenceController - let notification: NosNotification + let notification: T let user: Author var body: some View { @@ -234,7 +234,7 @@ private struct NotificationCell: View { ) ) .id(event.id) - } else if let followerKey = notification.follower?.hexadecimalPublicKey, let follower = try? Author.find( + } else if let followerKey = notification.author?.hexadecimalPublicKey, let follower = try? Author.find( by: followerKey, context: persistenceController.viewContext ) { @@ -271,8 +271,8 @@ private struct TabButton: View { /// 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 +private struct NotificationTabView: View { + let notifications: FetchedResults let currentUser: CurrentUser let maxNotificationsToShow: Int let tag: Int diff --git a/NosTests/Models/CoreData/EventTests.swift b/NosTests/Models/CoreData/EventTests.swift index 85fb72a20..55726f201 100644 --- a/NosTests/Models/CoreData/EventTests.swift +++ b/NosTests/Models/CoreData/EventTests.swift @@ -17,11 +17,11 @@ final class EventTests: CoreDataTestCase { [0,"32730e9dfcab797caf8380d096e548d9ef98f3af3000542f9271a91a9e3b0001",1675264762,1,[["p","d0a1ffb8761b974cec4a3be8cbcb2e96a7090dcf465ffeac839aa4ca20c9a59e"]],"Testing nos #[0]"] """.trimmingCharacters(in: .whitespacesAndNewlines) // swiftlint:enable line_length - + // Act let serializedData = try JSONSerialization.data(withJSONObject: event.serializedEventForSigning) let actualString = String(decoding: serializedData, as: UTF8.self) - + // Assert XCTAssertEqual(actualString, expectedString) } @@ -33,20 +33,20 @@ final class EventTests: CoreDataTestCase { let tags = [["p", "d0a1ffb8761b974cec4a3be8cbcb2e96a7090dcf465ffeac839aa4ca20c9a59e"]] let content = "Testing nos #[0]" let event = try EventFixture.build(in: testContext, content: content, tags: tags) - + // Act XCTAssertEqual( try event.calculateIdentifier(), "931b425e55559541451ddb99bd228bd1e0190af6ed21603b6b98544b42ee3317" ) } - + @MainActor func testIdentifierCalculationWithEmptyAndNoTags() throws { // Arrange let content = "Testing nos #[0]" let nilTagsEvent = try EventFixture.build(in: testContext, content: content, tags: nil) let emptyTagsEvent = try EventFixture.build(in: testContext, content: content, tags: []) - + // Act XCTAssertEqual( try nilTagsEvent.calculateIdentifier(), @@ -57,44 +57,44 @@ final class EventTests: CoreDataTestCase { "bc45c3ac53de113e1400fca956048a816ad1c2e6ecceba6b1372ca597066fa9a" ) } - + // MARK: - Signatures and Verification - + /// Verifies that we can sign an event and verify it. /// Since Schnorr signatures are non-deterministic we can't assert on constants. That's why all this test really /// does is verify that we are internally consistent in our signature logic. @MainActor func testSigningAndVerification() throws { // Arrange let event = try EventFixture.build(in: testContext) - + // Act try event.sign(withKey: KeyFixture.keyPair) - + // Assert XCTAssert(try event.verifySignature(for: KeyFixture.keyPair.publicKey)) } - + @MainActor func testVerificationOnBadId() throws { // Arrange let event = try EventFixture.build(in: testContext) - + // Act try event.sign(withKey: KeyFixture.keyPair) event.identifier = "invalid" - + // Assert XCTAssertFalse(try event.verifySignature(for: KeyFixture.keyPair.publicKey)) } - + @MainActor func testVerificationOnBadSignature() throws { // Arrange let event = try EventFixture.build(in: testContext) event.identifier = try event.calculateIdentifier() - + // Act event.signature = "31c710803d3b77cb2c61697c8e2a980a53ec66e980990ca34cc24f9018bf85bfd2b0" + - "669c1404f364de776a9d9ed31a5d6d32f5662ac77f2dc6b89c7762132d63" - + "669c1404f364de776a9d9ed31a5d6d32f5662ac77f2dc6b89c7762132d63" + // Assert XCTAssertFalse(try event.verifySignature(for: KeyFixture.keyPair.publicKey)) } @@ -106,56 +106,56 @@ final class EventTests: CoreDataTestCase { try testContext.save() measure { for _ in 0..<1000 { - _ = Event.find(by: eventID, context: testContext) + _ = Event.find(by: eventID, context: testContext) } } } - + // MARK: - Replies - + @MainActor func testReferencedNoteGivenMentionMarker() throws { let testEvent = try EventFixture.build(in: testContext) - + let mention = try EventReference( - jsonTag: ["e", "646daa2f5d2d990dc98fb50a6ce8de65d77419cee689d7153c912175e85ca95d", "", "mention"], + jsonTag: ["e", "646daa2f5d2d990dc98fb50a6ce8de65d77419cee689d7153c912175e85ca95d", "", "mention"], context: testContext ) testEvent.addToEventReferences(mention) - + XCTAssertNil(testEvent.referencedNote()) } - + @MainActor func testRepostedNote() throws { let testEvent = try EventFixture.build(in: testContext) testEvent.kind = 6 - + let mention = try EventReference( - jsonTag: ["e", "646daa2f5d2d990dc98fb50a6ce8de65d77419cee689d7153c912175e85ca95d"], + jsonTag: ["e", "646daa2f5d2d990dc98fb50a6ce8de65d77419cee689d7153c912175e85ca95d"], context: testContext ) testEvent.addToEventReferences(mention) - + XCTAssertEqual( - testEvent.repostedNote()?.identifier, + testEvent.repostedNote()?.identifier, "646daa2f5d2d990dc98fb50a6ce8de65d77419cee689d7153c912175e85ca95d" ) } - + @MainActor func testRepostedNoteGivenNonRepost() throws { let testEvent = try EventFixture.build(in: testContext) testEvent.kind = 1 - + let mention = try EventReference( - jsonTag: ["e", "646daa2f5d2d990dc98fb50a6ce8de65d77419cee689d7153c912175e85ca95d"], + jsonTag: ["e", "646daa2f5d2d990dc98fb50a6ce8de65d77419cee689d7153c912175e85ca95d"], context: testContext ) testEvent.addToEventReferences(mention) - + XCTAssertEqual(testEvent.repostedNote()?.identifier, nil) } - + // MARK: - Fetch requests - + @MainActor func test_eventByIdentifierSeenOnRelay_givenAlreadySeen() throws { // Arrange let eventID = "foo" @@ -163,28 +163,28 @@ final class EventTests: CoreDataTestCase { let relay = try Relay.findOrCreate(by: "wss://relay.nos.social", context: testContext) event.addToSeenOnRelays(relay) try testContext.saveIfNeeded() - + // Act let events = try testContext.fetch(Event.event(by: eventID, seenOn: relay)) - + // Assert XCTAssertEqual(events.count, 1) XCTAssertEqual(events.first, event) } - + @MainActor func test_eventByIdentifierSeenOnRelay_givenNotSeen() throws { // Arrange let eventID = "foo" _ = try Event.findOrCreateStubBy(id: eventID, context: testContext) let relay = try Relay.findOrCreate(by: "wss://relay.nos.social", context: testContext) - + // Act let events = try testContext.fetch(Event.event(by: eventID, seenOn: relay)) - + // Assert XCTAssertEqual(events.count, 0) } - + @MainActor func test_eventByIdentifierSeenOnRelay_givenSeenOnAnother() throws { // Arrange let eventID = "foo" @@ -192,10 +192,10 @@ final class EventTests: CoreDataTestCase { let relayOne = try Relay.findOrCreate(by: "wss://relay.nos.social", context: testContext) event.addToSeenOnRelays(relayOne) let relayTwo = try Relay.findOrCreate(by: "wss://other.relay.com", context: testContext) - + // Act let events = try testContext.fetch(Event.event(by: eventID, seenOn: relayTwo)) - + // Assert XCTAssertEqual(events.count, 0) } @@ -221,4 +221,218 @@ final class EventTests: CoreDataTestCase { // Assert XCTAssertEqual(references, [alice, bob]) } + + @MainActor func test_outOfNetwork_excludesFollowNotifications() throws { + // Arrange + let currentUser = try Author.findOrCreate(by: "current_user", context: testContext) + let follower = try Author.findOrCreate(by: "follower", context: testContext) + let unconnectedAuthor = try Author.findOrCreate(by: "unconnected", context: testContext) + + let event = Event(context: testContext) + event.identifier = "test_event" + event.author = unconnectedAuthor // This would normally make it appear in outOfNetwork + + try testContext.save() + + // Act + let fetchRequest = Event.outOfNetworkRequest(for: currentUser) + let results = try testContext.fetch(fetchRequest) + + // Assert + XCTAssertEqual(results.count, 0) + } + + @MainActor func test_outOfNetwork_includesAuthorWithNoFollowers() throws { + // Arrange + let currentUser = try Author.findOrCreate(by: "current_user", context: testContext) + let author = try Author.findOrCreate(by: "author", context: testContext) + + let event = Event(context: testContext) + event.identifier = "test_event" + event.author = author + event.kind = EventKind.text.rawValue + + // Create an author reference to represent the mention + let mention = AuthorReference(context: testContext) + mention.pubkey = currentUser.hexadecimalPublicKey + mention.event = event + event.addToAuthorReferences(mention) + + try testContext.save() + + // Act + let fetchRequest = Event.outOfNetworkRequest(for: currentUser) + let results = try testContext.fetch(fetchRequest) + + // Assert + XCTAssertEqual(results.count, 1) + XCTAssertEqual(results.first?.author?.hexadecimalPublicKey, author.hexadecimalPublicKey) + } + + @MainActor func test_outOfNetwork_excludesDirectlyFollowedAuthor() throws { + // Arrange + let currentUser = try Author.findOrCreate(by: "current_user", context: testContext) + let bob = try Author.findOrCreate(by: "bob", context: testContext) + + // Current user follows bob + let follow = Follow(context: testContext) + follow.source = currentUser + follow.destination = bob + + let event = Event(context: testContext) + event.identifier = "test_event" + event.author = bob + + try testContext.save() + + // Act + let fetchRequest = Event.outOfNetworkRequest(for: currentUser) + let results = try testContext.fetch(fetchRequest) + + // Assert + XCTAssertEqual(results.count, 0) + } + + @MainActor func test_outOfNetwork_excludesIndirectlyConnectedAuthor() 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 + + try testContext.save() + + // Act + let fetchRequest = Event.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 + event.kind = 1 + + // Create an author reference to represent the mention + let mention = AuthorReference(context: testContext) + mention.pubkey = currentUser.hexadecimalPublicKey + mention.event = event + event.addToAuthorReferences(mention) + + try testContext.save() + + // Act + let fetchRequest = Event.inNetworkRequest(for: currentUser) + let results = try testContext.fetch(fetchRequest) + + // Assert + XCTAssertEqual(results.count, 1) + XCTAssertEqual(results.first?.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 + event.kind = 1 + + // Create an author reference to represent the mention + let mention = AuthorReference(context: testContext) + mention.pubkey = currentUser.hexadecimalPublicKey + mention.event = event + event.addToAuthorReferences(mention) + + try testContext.save() + + // Act + let fetchRequest = Event.inNetworkRequest(for: currentUser) + let results = try testContext.fetch(fetchRequest) + + // Assert + XCTAssertEqual(results.count, 1) + XCTAssertEqual(results.first?.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 + + try testContext.save() + + // Act + let fetchRequest = Event.inNetworkRequest(for: currentUser) + let results = try testContext.fetch(fetchRequest) + + // Assert + XCTAssertEqual(results.count, 0) + } + + @MainActor func test_inNetwork_excludesFollowEvents() 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 + + let event = Event(context: testContext) + event.identifier = "test_event" + event.author = follower // This would normally make it appear in inNetwork + + try testContext.save() + + // Act + let fetchRequest = Event.inNetworkRequest(for: currentUser) + let results = try testContext.fetch(fetchRequest) + + // Assert + XCTAssertEqual(results.count, 0) + } } diff --git a/NosTests/Models/CoreData/NosNotificationTests.swift b/NosTests/Models/CoreData/NosNotificationTests.swift index ebb4f01ee..cd420dfec 100644 --- a/NosTests/Models/CoreData/NosNotificationTests.swift +++ b/NosTests/Models/CoreData/NosNotificationTests.swift @@ -3,227 +3,6 @@ import XCTest final class NosNotificationTests: CoreDataTestCase { - @MainActor func test_outOfNetwork_excludesFollowNotifications() throws { - // Arrange - let currentUser = try Author.findOrCreate(by: "current_user", context: testContext) - let follower = try Author.findOrCreate(by: "follower", context: testContext) - let unconnectedAuthor = try Author.findOrCreate(by: "unconnected", context: testContext) - - // Create notification with both follower and event from an unconnected author - let notification = NosNotification(context: testContext) - notification.follower = follower - - let event = Event(context: testContext) - event.identifier = "test_event" - event.author = unconnectedAuthor // This would normally make it appear in outOfNetwork - notification.event = event - - try testContext.save() - - // Act - let fetchRequest = NosNotification.outOfNetworkRequest(for: currentUser) - let results = try testContext.fetch(fetchRequest) - - // Assert - XCTAssertEqual(results.count, 0) - } - - @MainActor func test_outOfNetwork_includesAuthorWithNoFollowers() throws { - // Arrange - let currentUser = try Author.findOrCreate(by: "current_user", context: testContext) - let author = try Author.findOrCreate(by: "author", context: testContext) - - let event = Event(context: testContext) - event.identifier = "test_event" - event.author = author - - 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, 1) - XCTAssertEqual(results.first?.event?.author?.hexadecimalPublicKey, author.hexadecimalPublicKey) - } - - @MainActor func test_outOfNetwork_excludesDirectlyFollowedAuthor() throws { - // Arrange - let currentUser = try Author.findOrCreate(by: "current_user", context: testContext) - let bob = try Author.findOrCreate(by: "bob", context: testContext) - - // Current user follows bob - let follow = Follow(context: testContext) - follow.source = currentUser - follow.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) - } - - @MainActor func test_outOfNetwork_excludesIndirectlyConnectedAuthor() 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.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 From b11162c2572af7d2824c52c6a69177ce5b146149 Mon Sep 17 00:00:00 2001 From: Itunu Raimi Date: Mon, 27 Jan 2025 22:23:29 +0100 Subject: [PATCH 22/30] Revert "fetch events and nosNotifications on notificationView" This reverts commit afc211d6969c6357d34196b0aaba91d9e99cb4b1. --- Nos.xcodeproj/project.pbxproj | 6 - Nos/Models/CoreData/Event+CoreDataClass.swift | 8 - Nos/Models/CoreData/Event+Fetching.swift | 88 +----- .../NosNotification+CoreDataClass.swift | 58 +++- Nos/Models/NotificationDisplayable.swift | 19 -- Nos/Models/NotificationViewModel.swift | 11 +- .../Notifications/NotificationsView.swift | 40 +-- NosTests/Models/CoreData/EventTests.swift | 296 +++--------------- .../CoreData/NosNotificationTests.swift | 221 +++++++++++++ 9 files changed, 335 insertions(+), 412 deletions(-) delete mode 100644 Nos/Models/NotificationDisplayable.swift diff --git a/Nos.xcodeproj/project.pbxproj b/Nos.xcodeproj/project.pbxproj index 828afbae8..b934fe402 100644 --- a/Nos.xcodeproj/project.pbxproj +++ b/Nos.xcodeproj/project.pbxproj @@ -157,8 +157,6 @@ 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 */; }; - 04C923BD2D3A8BFC0088A97B /* NotificationDisplayable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04C923BC2D3A8BFC0088A97B /* NotificationDisplayable.swift */; }; - 04C923BE2D3A8BFC0088A97B /* NotificationDisplayable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04C923BC2D3A8BFC0088A97B /* NotificationDisplayable.swift */; }; 04C9D7272CBF09C200EAAD4D /* TextField+PlaceHolderStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04C9D7262CBF09C200EAAD4D /* TextField+PlaceHolderStyle.swift */; }; 04C9D7912CC29D5000EAAD4D /* FeaturedAuthor+Cohort1.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04C9D7902CC29D5000EAAD4D /* FeaturedAuthor+Cohort1.swift */; }; 04C9D7932CC29D8300EAAD4D /* FeaturedAuthor+Cohort2.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04C9D7922CC29D8300EAAD4D /* FeaturedAuthor+Cohort2.swift */; }; @@ -743,7 +741,6 @@ 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 = ""; }; - 04C923BC2D3A8BFC0088A97B /* NotificationDisplayable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationDisplayable.swift; sourceTree = ""; }; 04C9D7262CBF09C200EAAD4D /* TextField+PlaceHolderStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TextField+PlaceHolderStyle.swift"; sourceTree = ""; }; 04C9D7902CC29D5000EAAD4D /* FeaturedAuthor+Cohort1.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FeaturedAuthor+Cohort1.swift"; sourceTree = ""; }; 04C9D7922CC29D8300EAAD4D /* FeaturedAuthor+Cohort2.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FeaturedAuthor+Cohort2.swift"; sourceTree = ""; }; @@ -2007,7 +2004,6 @@ C92E7F652C4EFF2600B80638 /* WebSockets */, 5BCA95D12C8A5F0D00A52D1A /* PreviewEventRepository.swift */, 04368D2A2C99A2C400DEAA2E /* FlagOption.swift */, - 04C923BC2D3A8BFC0088A97B /* NotificationDisplayable.swift */, ); path = Models; sourceTree = ""; @@ -2705,7 +2701,6 @@ 0304D0B22C9B731F001D16C7 /* MockOpenGraphService.swift in Sources */, 030E570D2CC2A05B00A4A51E /* DisplayNameView.swift in Sources */, C94437E629B0DB83004D8C86 /* NotificationsView.swift in Sources */, - 04C923BD2D3A8BFC0088A97B /* NotificationDisplayable.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -2861,7 +2856,6 @@ 035729B92BE416A6005FEE85 /* GiftWrapperTests.swift in Sources */, 50E2EB7B2C8617C800D4B360 /* NSRegularExpression+Replacement.swift in Sources */, C9246C1C2C8A42A0005495CE /* RelaySubscriptionManagerTests.swift in Sources */, - 04C923BE2D3A8BFC0088A97B /* NotificationDisplayable.swift in Sources */, 032634702C10C40B00E489B5 /* NostrBuildAPIClientTests.swift in Sources */, 0315B5F02C7E451C0020E707 /* MockMediaService.swift in Sources */, C9646EAA29B7A506007239A4 /* Analytics.swift in Sources */, diff --git a/Nos/Models/CoreData/Event+CoreDataClass.swift b/Nos/Models/CoreData/Event+CoreDataClass.swift index a9c9684b0..3878f57cc 100644 --- a/Nos/Models/CoreData/Event+CoreDataClass.swift +++ b/Nos/Models/CoreData/Event+CoreDataClass.swift @@ -607,12 +607,4 @@ public class Event: NosManagedObject, VerifiableEvent { shouldBePublishedTo = Set() } } - -extension Event: NotificationDisplayable { - /// Returns self since an Event is its own associated event. - var event: Event? { - self - } -} - // swiftlint:enable file_length diff --git a/Nos/Models/CoreData/Event+Fetching.swift b/Nos/Models/CoreData/Event+Fetching.swift index a305d1e4f..4638ff33d 100644 --- a/Nos/Models/CoreData/Event+Fetching.swift +++ b/Nos/Models/CoreData/Event+Fetching.swift @@ -1,4 +1,3 @@ -// swiftlint:disable file_length import CoreData extension Event { @@ -97,90 +96,7 @@ extension Event { return fetchRequest } - - /// A request for all out-Of-Network events that the given user should receive. - /// - Parameters: - /// - currentUser: the author you want to view notifications for. - /// - limit: a max number of events to fetch. - /// - Returns: A fetch request for outOfNetwork events. - @nonobjc public class func outOfNetworkRequest( - for currentUser: Author, - limit: Int? = nil - ) -> NSFetchRequest { - let fetchRequest = NSFetchRequest(entityName: String(describing: Event.self)) - fetchRequest.sortDescriptors = [NSSortDescriptor(keyPath: \Event.createdAt, ascending: false)] - if let limit { - fetchRequest.fetchLimit = limit - } - - let mentionsPredicate = allMentionsPredicate(for: currentUser) - let repliesPredicate = allRepliesPredicate(for: currentUser) - let zapsPredicate = allZapsPredicate(for: currentUser) - - let notificationsPredicate = NSCompoundPredicate( - orPredicateWithSubpredicates: [mentionsPredicate, repliesPredicate, zapsPredicate] - ) - - // Out of network: has no followers OR not in follows network - let outOfNetworkPredicate = NSPredicate( - format: "author.followers.@count == 0 OR " + - "NOT (ANY author.followers.source IN %@.follows.destination " + - "OR author IN %@.follows.destination)", - currentUser, - currentUser - ) - - fetchRequest.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: [ - notificationsPredicate, - outOfNetworkPredicate, - NSPredicate(format: "author != %@", currentUser), - NSPredicate(format: "author.muted = false") - ]) - - return fetchRequest - } - - /// A request for all in-Network events that the given user should receive. - /// - Parameters: - /// - currentUser: the author you want to view notifications for. - /// - limit: a max number of events to fetch. - /// - Returns: A fetch request for inNetwork events. - @nonobjc public class func inNetworkRequest( - for currentUser: Author, - limit: Int? = nil - ) -> NSFetchRequest { - let fetchRequest = NSFetchRequest(entityName: String(describing: Event.self)) - fetchRequest.sortDescriptors = [NSSortDescriptor(keyPath: \Event.createdAt, ascending: false)] - if let limit { - fetchRequest.fetchLimit = limit - } - - let mentionsPredicate = allMentionsPredicate(for: currentUser) - let repliesPredicate = allRepliesPredicate(for: currentUser) - let zapsPredicate = allZapsPredicate(for: currentUser) - - let notificationsPredicate = NSCompoundPredicate( - orPredicateWithSubpredicates: [mentionsPredicate, repliesPredicate, zapsPredicate] - ) - - // In network: in follows network - let inNetworkPredicate = NSPredicate( - format: "(ANY author.followers.source IN %@.follows.destination " + - "OR author IN %@.follows.destination)", - currentUser, - currentUser - ) - - fetchRequest.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: [ - notificationsPredicate, - inNetworkPredicate, - NSPredicate(format: "author != %@", currentUser), - NSPredicate(format: "author.muted = false") - ]) - - return fetchRequest - } - + @nonobjc public class func lastReceived(for user: Author) -> NSFetchRequest { let fetchRequest = NSFetchRequest(entityName: "Event") fetchRequest.predicate = NSPredicate(format: "author != %@", user) @@ -546,5 +462,3 @@ extension Event { return request } } - -// swiftlint:enable file_length diff --git a/Nos/Models/CoreData/NosNotification+CoreDataClass.swift b/Nos/Models/CoreData/NosNotification+CoreDataClass.swift index 0de634858..2d15f5645 100644 --- a/Nos/Models/CoreData/NosNotification+CoreDataClass.swift +++ b/Nos/Models/CoreData/NosNotification+CoreDataClass.swift @@ -109,6 +109,57 @@ public class NosNotification: NosManagedObject { 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. @@ -129,10 +180,3 @@ public class NosNotification: NosManagedObject { return fetchRequest } } - -extension NosNotification: NotificationDisplayable { - /// Returns the follower as the author since they generated the follow notification. - var author: Author? { - follower - } -} diff --git a/Nos/Models/NotificationDisplayable.swift b/Nos/Models/NotificationDisplayable.swift deleted file mode 100644 index 42941faa8..000000000 --- a/Nos/Models/NotificationDisplayable.swift +++ /dev/null @@ -1,19 +0,0 @@ -import CoreData - -/// A protocol that defines the common interface for displaying notifications in the app. -/// Both `Event` and `NosNotification` types conform to this protocol to enable unified -/// handling in notification views. -/// -/// Conforming types must be `NSManagedObject`s and `Identifiable` to support CoreData -/// persistence and unique identification in SwiftUI lists. -protocol NotificationDisplayable: NSManagedObject, Identifiable { - var createdAt: Date? { get } - - /// The associated event, if any. For `Event` types, this is the event itself. - /// For `NosNotification` types, this is the associated event if one exists. - var event: Event? { get } - - /// The author associated with this notification. For `Event` types, this is the event author. - /// For `NosNotification` types, this is the follower who generated the notification. - var author: Author? { get } -} diff --git a/Nos/Models/NotificationViewModel.swift b/Nos/Models/NotificationViewModel.swift index 404bfcff1..9da063444 100644 --- a/Nos/Models/NotificationViewModel.swift +++ b/Nos/Models/NotificationViewModel.swift @@ -73,16 +73,7 @@ class NotificationViewModel: ObservableObject, Identifiable { actionText = authorName + AttributedString(String(localized: "startedFollowingYou")) range = Range(uncheckedBounds: (actionText.startIndex, actionText.endIndex)) actionText[range].foregroundColor = .primaryTxt - - /// For notification content, truncate the text only if more than - /// specified maximum length. - let maxLength = 100 - if actionText.characters.count > maxLength { - let truncated = String(actionText.characters.prefix(maxLength)) + "..." - self.actionText = AttributedString(truncated) - } else { - self.actionText = actionText - } + self.actionText = actionText self.content = nil } diff --git a/Nos/Views/Notifications/NotificationsView.swift b/Nos/Views/Notifications/NotificationsView.swift index 4bc893877..5a1daaa71 100644 --- a/Nos/Views/Notifications/NotificationsView.swift +++ b/Nos/Views/Notifications/NotificationsView.swift @@ -14,8 +14,8 @@ struct NotificationsView: View { @Dependency(\.pushNotificationService) private var pushNotificationService @Dependency(\.persistenceController) private var persistenceController - @FetchRequest private var outOfNetworkEvents: FetchedResults - @FetchRequest private var inNetworkEvents: FetchedResults + @FetchRequest private var outOfNetworkNotifications: FetchedResults + @FetchRequest private var inNetworkNotifications: FetchedResults @FetchRequest private var followNotifications: FetchedResults @State private var relaySubscriptions = SubscriptionCancellables() @@ -29,8 +29,8 @@ struct NotificationsView: View { let networkRequests = Self.createNetworkFetchRequests(for: user, limit: maxNotificationsToShow) _followNotifications = FetchRequest(fetchRequest: followsRequest) - _outOfNetworkEvents = FetchRequest(fetchRequest: networkRequests.outOfNetwork) - _inNetworkEvents = FetchRequest(fetchRequest: networkRequests.inNetwork) + _outOfNetworkNotifications = FetchRequest(fetchRequest: networkRequests.outOfNetwork) + _inNetworkNotifications = FetchRequest(fetchRequest: networkRequests.inNetwork) } /// Creates the follows notification fetch requests for all notifications and follows. @@ -51,26 +51,26 @@ struct NotificationsView: View { } } - /// Creates the network-specific events fetch requests. + /// 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 events for. If nil, returns empty requests. - /// - limit: The maximum number of events to fetch. - /// - Returns: A tuple containing fetch requests for in-network and out-of-network events. + /// - 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 + outOfNetwork: NSFetchRequest, + inNetwork: NSFetchRequest ) { if let user { return ( - outOfNetwork: Event.outOfNetworkRequest(for: user, limit: limit), - inNetwork: Event.inNetworkRequest(for: user, limit: limit) + outOfNetwork: NosNotification.outOfNetworkRequest(for: user, limit: limit), + inNetwork: NosNotification.inNetworkRequest(for: user, limit: limit) ) } else { - let emptyRequest = Event.emptyRequest() + let emptyRequest = NosNotification.emptyRequest() return (outOfNetwork: emptyRequest, inNetwork: emptyRequest) } } @@ -194,7 +194,7 @@ struct NotificationsView: View { .tag(0) NotificationTabView( - notifications: inNetworkEvents, + notifications: inNetworkNotifications, currentUser: currentUser, maxNotificationsToShow: maxNotificationsToShow, tag: 1 @@ -202,7 +202,7 @@ struct NotificationsView: View { .tag(1) NotificationTabView( - notifications: outOfNetworkEvents, + notifications: outOfNetworkNotifications, currentUser: currentUser, maxNotificationsToShow: maxNotificationsToShow, tag: 2 @@ -215,9 +215,9 @@ struct NotificationsView: View { } /// A single notification cell that contains a follow event or a other event types in the notifications list -private struct NotificationCell: View { +private struct NotificationCell: View { @Dependency(\.persistenceController) private var persistenceController - let notification: T + let notification: NosNotification let user: Author var body: some View { @@ -234,7 +234,7 @@ private struct NotificationCell: View { ) ) .id(event.id) - } else if let followerKey = notification.author?.hexadecimalPublicKey, let follower = try? Author.find( + } else if let followerKey = notification.follower?.hexadecimalPublicKey, let follower = try? Author.find( by: followerKey, context: persistenceController.viewContext ) { @@ -271,8 +271,8 @@ private struct TabButton: View { /// 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 +private struct NotificationTabView: View { + let notifications: FetchedResults let currentUser: CurrentUser let maxNotificationsToShow: Int let tag: Int diff --git a/NosTests/Models/CoreData/EventTests.swift b/NosTests/Models/CoreData/EventTests.swift index 55726f201..85fb72a20 100644 --- a/NosTests/Models/CoreData/EventTests.swift +++ b/NosTests/Models/CoreData/EventTests.swift @@ -17,11 +17,11 @@ final class EventTests: CoreDataTestCase { [0,"32730e9dfcab797caf8380d096e548d9ef98f3af3000542f9271a91a9e3b0001",1675264762,1,[["p","d0a1ffb8761b974cec4a3be8cbcb2e96a7090dcf465ffeac839aa4ca20c9a59e"]],"Testing nos #[0]"] """.trimmingCharacters(in: .whitespacesAndNewlines) // swiftlint:enable line_length - + // Act let serializedData = try JSONSerialization.data(withJSONObject: event.serializedEventForSigning) let actualString = String(decoding: serializedData, as: UTF8.self) - + // Assert XCTAssertEqual(actualString, expectedString) } @@ -33,20 +33,20 @@ final class EventTests: CoreDataTestCase { let tags = [["p", "d0a1ffb8761b974cec4a3be8cbcb2e96a7090dcf465ffeac839aa4ca20c9a59e"]] let content = "Testing nos #[0]" let event = try EventFixture.build(in: testContext, content: content, tags: tags) - + // Act XCTAssertEqual( try event.calculateIdentifier(), "931b425e55559541451ddb99bd228bd1e0190af6ed21603b6b98544b42ee3317" ) } - + @MainActor func testIdentifierCalculationWithEmptyAndNoTags() throws { // Arrange let content = "Testing nos #[0]" let nilTagsEvent = try EventFixture.build(in: testContext, content: content, tags: nil) let emptyTagsEvent = try EventFixture.build(in: testContext, content: content, tags: []) - + // Act XCTAssertEqual( try nilTagsEvent.calculateIdentifier(), @@ -57,44 +57,44 @@ final class EventTests: CoreDataTestCase { "bc45c3ac53de113e1400fca956048a816ad1c2e6ecceba6b1372ca597066fa9a" ) } - + // MARK: - Signatures and Verification - + /// Verifies that we can sign an event and verify it. /// Since Schnorr signatures are non-deterministic we can't assert on constants. That's why all this test really /// does is verify that we are internally consistent in our signature logic. @MainActor func testSigningAndVerification() throws { // Arrange let event = try EventFixture.build(in: testContext) - + // Act try event.sign(withKey: KeyFixture.keyPair) - + // Assert XCTAssert(try event.verifySignature(for: KeyFixture.keyPair.publicKey)) } - + @MainActor func testVerificationOnBadId() throws { // Arrange let event = try EventFixture.build(in: testContext) - + // Act try event.sign(withKey: KeyFixture.keyPair) event.identifier = "invalid" - + // Assert XCTAssertFalse(try event.verifySignature(for: KeyFixture.keyPair.publicKey)) } - + @MainActor func testVerificationOnBadSignature() throws { // Arrange let event = try EventFixture.build(in: testContext) event.identifier = try event.calculateIdentifier() - + // Act event.signature = "31c710803d3b77cb2c61697c8e2a980a53ec66e980990ca34cc24f9018bf85bfd2b0" + - "669c1404f364de776a9d9ed31a5d6d32f5662ac77f2dc6b89c7762132d63" - + "669c1404f364de776a9d9ed31a5d6d32f5662ac77f2dc6b89c7762132d63" + // Assert XCTAssertFalse(try event.verifySignature(for: KeyFixture.keyPair.publicKey)) } @@ -106,56 +106,56 @@ final class EventTests: CoreDataTestCase { try testContext.save() measure { for _ in 0..<1000 { - _ = Event.find(by: eventID, context: testContext) + _ = Event.find(by: eventID, context: testContext) } } } - + // MARK: - Replies - + @MainActor func testReferencedNoteGivenMentionMarker() throws { let testEvent = try EventFixture.build(in: testContext) - + let mention = try EventReference( - jsonTag: ["e", "646daa2f5d2d990dc98fb50a6ce8de65d77419cee689d7153c912175e85ca95d", "", "mention"], + jsonTag: ["e", "646daa2f5d2d990dc98fb50a6ce8de65d77419cee689d7153c912175e85ca95d", "", "mention"], context: testContext ) testEvent.addToEventReferences(mention) - + XCTAssertNil(testEvent.referencedNote()) } - + @MainActor func testRepostedNote() throws { let testEvent = try EventFixture.build(in: testContext) testEvent.kind = 6 - + let mention = try EventReference( - jsonTag: ["e", "646daa2f5d2d990dc98fb50a6ce8de65d77419cee689d7153c912175e85ca95d"], + jsonTag: ["e", "646daa2f5d2d990dc98fb50a6ce8de65d77419cee689d7153c912175e85ca95d"], context: testContext ) testEvent.addToEventReferences(mention) - + XCTAssertEqual( - testEvent.repostedNote()?.identifier, + testEvent.repostedNote()?.identifier, "646daa2f5d2d990dc98fb50a6ce8de65d77419cee689d7153c912175e85ca95d" ) } - + @MainActor func testRepostedNoteGivenNonRepost() throws { let testEvent = try EventFixture.build(in: testContext) testEvent.kind = 1 - + let mention = try EventReference( - jsonTag: ["e", "646daa2f5d2d990dc98fb50a6ce8de65d77419cee689d7153c912175e85ca95d"], + jsonTag: ["e", "646daa2f5d2d990dc98fb50a6ce8de65d77419cee689d7153c912175e85ca95d"], context: testContext ) testEvent.addToEventReferences(mention) - + XCTAssertEqual(testEvent.repostedNote()?.identifier, nil) } - + // MARK: - Fetch requests - + @MainActor func test_eventByIdentifierSeenOnRelay_givenAlreadySeen() throws { // Arrange let eventID = "foo" @@ -163,28 +163,28 @@ final class EventTests: CoreDataTestCase { let relay = try Relay.findOrCreate(by: "wss://relay.nos.social", context: testContext) event.addToSeenOnRelays(relay) try testContext.saveIfNeeded() - + // Act let events = try testContext.fetch(Event.event(by: eventID, seenOn: relay)) - + // Assert XCTAssertEqual(events.count, 1) XCTAssertEqual(events.first, event) } - + @MainActor func test_eventByIdentifierSeenOnRelay_givenNotSeen() throws { // Arrange let eventID = "foo" _ = try Event.findOrCreateStubBy(id: eventID, context: testContext) let relay = try Relay.findOrCreate(by: "wss://relay.nos.social", context: testContext) - + // Act let events = try testContext.fetch(Event.event(by: eventID, seenOn: relay)) - + // Assert XCTAssertEqual(events.count, 0) } - + @MainActor func test_eventByIdentifierSeenOnRelay_givenSeenOnAnother() throws { // Arrange let eventID = "foo" @@ -192,10 +192,10 @@ final class EventTests: CoreDataTestCase { let relayOne = try Relay.findOrCreate(by: "wss://relay.nos.social", context: testContext) event.addToSeenOnRelays(relayOne) let relayTwo = try Relay.findOrCreate(by: "wss://other.relay.com", context: testContext) - + // Act let events = try testContext.fetch(Event.event(by: eventID, seenOn: relayTwo)) - + // Assert XCTAssertEqual(events.count, 0) } @@ -221,218 +221,4 @@ final class EventTests: CoreDataTestCase { // Assert XCTAssertEqual(references, [alice, bob]) } - - @MainActor func test_outOfNetwork_excludesFollowNotifications() throws { - // Arrange - let currentUser = try Author.findOrCreate(by: "current_user", context: testContext) - let follower = try Author.findOrCreate(by: "follower", context: testContext) - let unconnectedAuthor = try Author.findOrCreate(by: "unconnected", context: testContext) - - let event = Event(context: testContext) - event.identifier = "test_event" - event.author = unconnectedAuthor // This would normally make it appear in outOfNetwork - - try testContext.save() - - // Act - let fetchRequest = Event.outOfNetworkRequest(for: currentUser) - let results = try testContext.fetch(fetchRequest) - - // Assert - XCTAssertEqual(results.count, 0) - } - - @MainActor func test_outOfNetwork_includesAuthorWithNoFollowers() throws { - // Arrange - let currentUser = try Author.findOrCreate(by: "current_user", context: testContext) - let author = try Author.findOrCreate(by: "author", context: testContext) - - let event = Event(context: testContext) - event.identifier = "test_event" - event.author = author - event.kind = EventKind.text.rawValue - - // Create an author reference to represent the mention - let mention = AuthorReference(context: testContext) - mention.pubkey = currentUser.hexadecimalPublicKey - mention.event = event - event.addToAuthorReferences(mention) - - try testContext.save() - - // Act - let fetchRequest = Event.outOfNetworkRequest(for: currentUser) - let results = try testContext.fetch(fetchRequest) - - // Assert - XCTAssertEqual(results.count, 1) - XCTAssertEqual(results.first?.author?.hexadecimalPublicKey, author.hexadecimalPublicKey) - } - - @MainActor func test_outOfNetwork_excludesDirectlyFollowedAuthor() throws { - // Arrange - let currentUser = try Author.findOrCreate(by: "current_user", context: testContext) - let bob = try Author.findOrCreate(by: "bob", context: testContext) - - // Current user follows bob - let follow = Follow(context: testContext) - follow.source = currentUser - follow.destination = bob - - let event = Event(context: testContext) - event.identifier = "test_event" - event.author = bob - - try testContext.save() - - // Act - let fetchRequest = Event.outOfNetworkRequest(for: currentUser) - let results = try testContext.fetch(fetchRequest) - - // Assert - XCTAssertEqual(results.count, 0) - } - - @MainActor func test_outOfNetwork_excludesIndirectlyConnectedAuthor() 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 - - try testContext.save() - - // Act - let fetchRequest = Event.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 - event.kind = 1 - - // Create an author reference to represent the mention - let mention = AuthorReference(context: testContext) - mention.pubkey = currentUser.hexadecimalPublicKey - mention.event = event - event.addToAuthorReferences(mention) - - try testContext.save() - - // Act - let fetchRequest = Event.inNetworkRequest(for: currentUser) - let results = try testContext.fetch(fetchRequest) - - // Assert - XCTAssertEqual(results.count, 1) - XCTAssertEqual(results.first?.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 - event.kind = 1 - - // Create an author reference to represent the mention - let mention = AuthorReference(context: testContext) - mention.pubkey = currentUser.hexadecimalPublicKey - mention.event = event - event.addToAuthorReferences(mention) - - try testContext.save() - - // Act - let fetchRequest = Event.inNetworkRequest(for: currentUser) - let results = try testContext.fetch(fetchRequest) - - // Assert - XCTAssertEqual(results.count, 1) - XCTAssertEqual(results.first?.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 - - try testContext.save() - - // Act - let fetchRequest = Event.inNetworkRequest(for: currentUser) - let results = try testContext.fetch(fetchRequest) - - // Assert - XCTAssertEqual(results.count, 0) - } - - @MainActor func test_inNetwork_excludesFollowEvents() 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 - - let event = Event(context: testContext) - event.identifier = "test_event" - event.author = follower // This would normally make it appear in inNetwork - - try testContext.save() - - // Act - let fetchRequest = Event.inNetworkRequest(for: currentUser) - let results = try testContext.fetch(fetchRequest) - - // Assert - XCTAssertEqual(results.count, 0) - } } diff --git a/NosTests/Models/CoreData/NosNotificationTests.swift b/NosTests/Models/CoreData/NosNotificationTests.swift index cd420dfec..ebb4f01ee 100644 --- a/NosTests/Models/CoreData/NosNotificationTests.swift +++ b/NosTests/Models/CoreData/NosNotificationTests.swift @@ -3,6 +3,227 @@ import XCTest final class NosNotificationTests: CoreDataTestCase { + @MainActor func test_outOfNetwork_excludesFollowNotifications() throws { + // Arrange + let currentUser = try Author.findOrCreate(by: "current_user", context: testContext) + let follower = try Author.findOrCreate(by: "follower", context: testContext) + let unconnectedAuthor = try Author.findOrCreate(by: "unconnected", context: testContext) + + // Create notification with both follower and event from an unconnected author + let notification = NosNotification(context: testContext) + notification.follower = follower + + let event = Event(context: testContext) + event.identifier = "test_event" + event.author = unconnectedAuthor // This would normally make it appear in outOfNetwork + notification.event = event + + try testContext.save() + + // Act + let fetchRequest = NosNotification.outOfNetworkRequest(for: currentUser) + let results = try testContext.fetch(fetchRequest) + + // Assert + XCTAssertEqual(results.count, 0) + } + + @MainActor func test_outOfNetwork_includesAuthorWithNoFollowers() throws { + // Arrange + let currentUser = try Author.findOrCreate(by: "current_user", context: testContext) + let author = try Author.findOrCreate(by: "author", context: testContext) + + let event = Event(context: testContext) + event.identifier = "test_event" + event.author = author + + 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, 1) + XCTAssertEqual(results.first?.event?.author?.hexadecimalPublicKey, author.hexadecimalPublicKey) + } + + @MainActor func test_outOfNetwork_excludesDirectlyFollowedAuthor() throws { + // Arrange + let currentUser = try Author.findOrCreate(by: "current_user", context: testContext) + let bob = try Author.findOrCreate(by: "bob", context: testContext) + + // Current user follows bob + let follow = Follow(context: testContext) + follow.source = currentUser + follow.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) + } + + @MainActor func test_outOfNetwork_excludesIndirectlyConnectedAuthor() 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.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 From 63f34819071b8c328c42c642ac67ed9ac2886b8b Mon Sep 17 00:00:00 2001 From: Itunu Raimi Date: Tue, 28 Jan 2025 15:14:55 +0100 Subject: [PATCH 23/30] delete notifications and events on launch if needed --- Nos/Controller/PersistenceController.swift | 17 ++++++++++ Nos/Service/DatabaseCleaner.swift | 12 +++++++ NosTests/Service/DatabaseCleanerTests.swift | 36 +++++++++++++++++++++ 3 files changed, 65 insertions(+) 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/Service/DatabaseCleaner.swift b/Nos/Service/DatabaseCleaner.swift index 885580436..5cde1b71c 100644 --- a/Nos/Service/DatabaseCleaner.swift +++ b/Nos/Service/DatabaseCleaner.swift @@ -156,4 +156,16 @@ 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/NosTests/Service/DatabaseCleanerTests.swift b/NosTests/Service/DatabaseCleanerTests.swift index 0bed1c87e..b327c38c4 100644 --- a/NosTests/Service/DatabaseCleanerTests.swift +++ b/NosTests/Service/DatabaseCleanerTests.swift @@ -406,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) + } } From bcdeb3978eaa0d59d2aa78a6475dddddd42ac01b Mon Sep 17 00:00:00 2001 From: Itunu Raimi Date: Tue, 28 Jan 2025 15:19:26 +0100 Subject: [PATCH 24/30] fix swiftlint warning --- Nos/Service/DatabaseCleaner.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Nos/Service/DatabaseCleaner.swift b/Nos/Service/DatabaseCleaner.swift index 5cde1b71c..99a707318 100644 --- a/Nos/Service/DatabaseCleaner.swift +++ b/Nos/Service/DatabaseCleaner.swift @@ -159,7 +159,8 @@ enum DatabaseCleaner { /// 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`. + /// 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: [ From 2b7c6b68b381e6b26ff63775030d127913a59748 Mon Sep 17 00:00:00 2001 From: Itunu Raimi Date: Wed, 29 Jan 2025 14:01:10 +0100 Subject: [PATCH 25/30] update function name and documentation --- Nos/Models/CoreData/Author+CoreDataClass.swift | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/Nos/Models/CoreData/Author+CoreDataClass.swift b/Nos/Models/CoreData/Author+CoreDataClass.swift index 8be599c25..9ff069e08 100644 --- a/Nos/Models/CoreData/Author+CoreDataClass.swift +++ b/Nos/Models/CoreData/Author+CoreDataClass.swift @@ -407,8 +407,10 @@ import Logger await currentUser.publishMuteList(keys: Array(Set(mutedList))) } - /// Returns true if this event tagged the given author. - func this(author: Author) -> Bool { + // 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 }) From 3be7add4ff48b5817df19c446a488d046fe7a523 Mon Sep 17 00:00:00 2001 From: Itunu Raimi Date: Wed, 29 Jan 2025 14:39:17 +0100 Subject: [PATCH 26/30] update UI --- Nos/Views/Notifications/NotificationsView.swift | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/Nos/Views/Notifications/NotificationsView.swift b/Nos/Views/Notifications/NotificationsView.swift index 5a1daaa71..02fcf617e 100644 --- a/Nos/Views/Notifications/NotificationsView.swift +++ b/Nos/Views/Notifications/NotificationsView.swift @@ -20,7 +20,7 @@ struct NotificationsView: View { @State private var relaySubscriptions = SubscriptionCancellables() @State private var isVisible = false - @State private var selectedTab = 0 + @State private var selectedTab = 1 private let maxNotificationsToShow = 100 @@ -147,7 +147,7 @@ struct NotificationsView: View { Divider() .overlay(Color.cardDividerTop) .shadow(color: .cardDividerTopShadow, radius: 0, x: 0, y: 1) - HStack { + HStack(spacing: 0) { TabButton( title: String(localized: "follows"), isSelected: selectedTab == 0 @@ -169,7 +169,8 @@ struct NotificationsView: View { selectedTab = 2 } } - .padding(.vertical, 12) + // Constrains the width to prevent the content from becoming too wide + .frame(maxWidth: 600) .padding(.horizontal, 36) .background(LinearGradient.cardBackground) .background( @@ -262,9 +263,8 @@ private struct TabButton: View { Text(title) .font(.subheadline) .fontWeight(.bold) - .padding(.vertical, 8) + .padding(.vertical, 12) .foregroundColor(isSelected ? .primaryTxt : .secondaryTxt) - .cornerRadius(8) } } } From ddd9b4838f4a8b5f2c3b8712e8b6e54e012da186 Mon Sep 17 00:00:00 2001 From: Itunu Raimi Date: Wed, 29 Jan 2025 19:35:01 +0100 Subject: [PATCH 27/30] update UI --- .../Notifications/NotificationsView.swift | 26 +++++++++++-------- 1 file changed, 15 insertions(+), 11 deletions(-) diff --git a/Nos/Views/Notifications/NotificationsView.swift b/Nos/Views/Notifications/NotificationsView.swift index 02fcf617e..87d0f4356 100644 --- a/Nos/Views/Notifications/NotificationsView.swift +++ b/Nos/Views/Notifications/NotificationsView.swift @@ -170,19 +170,21 @@ struct NotificationsView: View { } } // Constrains the width to prevent the content from becoming too wide + .padding(.horizontal, 20) .frame(maxWidth: 600) - .padding(.horizontal, 36) .background(LinearGradient.cardBackground) - .background( - Color.card3d - .offset(y: 4.5) - .shadow( - color: Color.cardShadowBottom, - radius: 5, - x: 0, - y: 4 - ) - ) + + // Shadow rectangle below + Rectangle() + .fill(Color.card3d) + .frame(height: 4.5) + .frame(maxWidth: 600) + .shadow( + color: Color.cardShadowBottom, + radius: 5, + x: 0, + y: 4 + ) // Content based on selected tab TabView(selection: $selectedTab) { @@ -211,6 +213,7 @@ struct NotificationsView: View { .tag(2) } .tabViewStyle(PageTabViewStyle(indexDisplayMode: .never)) + .frame(maxWidth: 600) } } } @@ -264,6 +267,7 @@ private struct TabButton: View { .font(.subheadline) .fontWeight(.bold) .padding(.vertical, 12) + .padding(.horizontal, 16) // increase touch area width .foregroundColor(isSelected ? .primaryTxt : .secondaryTxt) } } From 1ed8ec7e745ac8ca15bab6cf84a179b060e40cf9 Mon Sep 17 00:00:00 2001 From: Itunu Raimi Date: Wed, 29 Jan 2025 23:37:37 +0100 Subject: [PATCH 28/30] space around --- Nos/Views/Notifications/NotificationsView.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Nos/Views/Notifications/NotificationsView.swift b/Nos/Views/Notifications/NotificationsView.swift index 87d0f4356..fc4822eaf 100644 --- a/Nos/Views/Notifications/NotificationsView.swift +++ b/Nos/Views/Notifications/NotificationsView.swift @@ -148,6 +148,7 @@ struct NotificationsView: View { .overlay(Color.cardDividerTop) .shadow(color: .cardDividerTopShadow, radius: 0, x: 0, y: 1) HStack(spacing: 0) { + Spacer() TabButton( title: String(localized: "follows"), isSelected: selectedTab == 0 @@ -168,9 +169,8 @@ struct NotificationsView: View { ) { selectedTab = 2 } + Spacer() } - // Constrains the width to prevent the content from becoming too wide - .padding(.horizontal, 20) .frame(maxWidth: 600) .background(LinearGradient.cardBackground) From b71e82c651e3e8de7c024f9580a8da58f23b8820 Mon Sep 17 00:00:00 2001 From: Itunu Raimi Date: Fri, 31 Jan 2025 21:10:45 +0100 Subject: [PATCH 29/30] update notifications UI --- .../Notifications/NotificationsView.swift | 47 ++++++++++--------- 1 file changed, 25 insertions(+), 22 deletions(-) diff --git a/Nos/Views/Notifications/NotificationsView.swift b/Nos/Views/Notifications/NotificationsView.swift index fc4822eaf..470a9db03 100644 --- a/Nos/Views/Notifications/NotificationsView.swift +++ b/Nos/Views/Notifications/NotificationsView.swift @@ -149,36 +149,39 @@ struct NotificationsView: View { .shadow(color: .cardDividerTopShadow, radius: 0, x: 0, y: 1) HStack(spacing: 0) { Spacer() - TabButton( - title: String(localized: "follows"), - isSelected: selectedTab == 0 - ) { - selectedTab = 0 - } - Spacer() - TabButton( - title: String(localized: "inNetwork"), - isSelected: selectedTab == 1 - ) { - selectedTab = 1 - } - Spacer() - TabButton( - title: String(localized: "outOfNetwork"), - isSelected: selectedTab == 2 - ) { - selectedTab = 2 + // Container to center tabs well on bigger screens. + HStack(spacing: 0) { + TabButton( + title: String(localized: "follows"), + isSelected: selectedTab == 0 + ) { + selectedTab = 0 + } + Spacer() + TabButton( + title: String(localized: "inNetwork"), + isSelected: selectedTab == 1 + ) { + selectedTab = 1 + } + Spacer() + TabButton( + title: String(localized: "outOfNetwork"), + isSelected: selectedTab == 2 + ) { + selectedTab = 2 + } } + .readabilityPadding() + Spacer() } - .frame(maxWidth: 600) .background(LinearGradient.cardBackground) // Shadow rectangle below Rectangle() .fill(Color.card3d) .frame(height: 4.5) - .frame(maxWidth: 600) .shadow( color: Color.cardShadowBottom, radius: 5, @@ -213,7 +216,6 @@ struct NotificationsView: View { .tag(2) } .tabViewStyle(PageTabViewStyle(indexDisplayMode: .never)) - .frame(maxWidth: 600) } } } @@ -297,6 +299,7 @@ private struct NotificationTabView: View { } } .padding(.vertical, 16) + .frame(maxWidth: .infinity) } .overlay(Group { if notifications.isEmpty { From 71804812c7a3aa0fef56245df026ac9c6fb98f67 Mon Sep 17 00:00:00 2001 From: Itunu Raimi Date: Fri, 31 Jan 2025 21:46:06 +0100 Subject: [PATCH 30/30] fix failing test --- NosTests/Service/DatabaseCleanerTests.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/NosTests/Service/DatabaseCleanerTests.swift b/NosTests/Service/DatabaseCleanerTests.swift index b327c38c4..044882bc3 100644 --- a/NosTests/Service/DatabaseCleanerTests.swift +++ b/NosTests/Service/DatabaseCleanerTests.swift @@ -176,8 +176,8 @@ final class DatabaseCleanerTests: CoreDataTestCase { // Arrange let alice = try Author.findOrCreate(by: KeyFixture.alice.publicKeyHex, context: testContext) - // Creates old notification (2 months + 1 day old) - let oldDate = Calendar.current.date(byAdding: .day, value: -61, to: .now)! + // 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