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