From 336a4c33a3a5d63e7a12b77e37b40f4de999760b Mon Sep 17 00:00:00 2001 From: Bryan Montz Date: Wed, 11 Sep 2024 07:53:20 -0500 Subject: [PATCH] minor refactor of Event+CoreDataClass #1443 --- CHANGELOG.md | 1 + Nos.xcodeproj/project.pbxproj | 12 + Nos/Models/CoreData/Event+CoreDataClass.swift | 675 +----------------- Nos/Models/CoreData/Event+Fetching.swift | 453 ++++++++++++ Nos/Models/CoreData/Event+Hydration.swift | 209 ++++++ Nos/Service/DatabaseCleaner.swift | 2 +- 6 files changed, 686 insertions(+), 666 deletions(-) create mode 100644 Nos/Models/CoreData/Event+Fetching.swift create mode 100644 Nos/Models/CoreData/Event+Hydration.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index a2e999f5d..b28eb0a99 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Use NIP-92 media metadata to display media in the proper orientation. Currently behind the “Enable new media display” feature flag. [#1172](https://github.com/planetary-social/nos/issues/1172) - Added more instructions to the changelog file. - Added some logging when a content warning is displayed. [cleanstr#53](https://github.com/planetary-social/cleanstr/issues/53) +- Minor refactor of Event+CoreDataClass. [#1443](https://github.com/planetary-social/nos/issues/1443) ## [0.1.26] - 2024-09-09Z diff --git a/Nos.xcodeproj/project.pbxproj b/Nos.xcodeproj/project.pbxproj index 95a0df9ff..0d7402a25 100644 --- a/Nos.xcodeproj/project.pbxproj +++ b/Nos.xcodeproj/project.pbxproj @@ -124,6 +124,10 @@ 3FFB1D9729A6BBEC002A755D /* Collection+SafeSubscript.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3FFB1D9529A6BBEC002A755D /* Collection+SafeSubscript.swift */; }; 3FFB1D9C29A7DF9D002A755D /* StackedAvatarsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3FFB1D9B29A7DF9D002A755D /* StackedAvatarsView.swift */; }; 3FFF3BD029A9645F00DD0B72 /* AuthorReference+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F43C47529A9625700E896A0 /* AuthorReference+CoreDataClass.swift */; }; + 5044546E2C90726A00251A7E /* Event+Fetching.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5044546D2C90726A00251A7E /* Event+Fetching.swift */; }; + 504454702C90728500251A7E /* Event+Hydration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5044546F2C90728500251A7E /* Event+Hydration.swift */; }; + 504454712C90728E00251A7E /* Event+Fetching.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5044546D2C90726A00251A7E /* Event+Fetching.swift */; }; + 504454722C90729100251A7E /* Event+Hydration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5044546F2C90728500251A7E /* Event+Hydration.swift */; }; 5045540D2C81E10C0044ECAE /* EditableAvatarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5045540C2C81E10C0044ECAE /* EditableAvatarView.swift */; }; 508133CB2C79F78500DFBF75 /* AttributedString+Quotation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 508133CA2C79F78500DFBF75 /* AttributedString+Quotation.swift */; }; 508133DB2C7A003600DFBF75 /* AttributedString+QuotationsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 508133DA2C7A003600DFBF75 /* AttributedString+QuotationsTests.swift */; }; @@ -635,6 +639,8 @@ 3FFB1D9229A6BBCE002A755D /* EventReference+CoreDataClass.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "EventReference+CoreDataClass.swift"; sourceTree = ""; }; 3FFB1D9529A6BBEC002A755D /* Collection+SafeSubscript.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Collection+SafeSubscript.swift"; sourceTree = ""; }; 3FFB1D9B29A7DF9D002A755D /* StackedAvatarsView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = StackedAvatarsView.swift; sourceTree = ""; }; + 5044546D2C90726A00251A7E /* Event+Fetching.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Event+Fetching.swift"; sourceTree = ""; }; + 5044546F2C90728500251A7E /* Event+Hydration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Event+Hydration.swift"; sourceTree = ""; }; 5045540C2C81E10C0044ECAE /* EditableAvatarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditableAvatarView.swift; sourceTree = ""; }; 508133CA2C79F78500DFBF75 /* AttributedString+Quotation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AttributedString+Quotation.swift"; sourceTree = ""; }; 508133DA2C7A003600DFBF75 /* AttributedString+QuotationsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AttributedString+QuotationsTests.swift"; sourceTree = ""; }; @@ -981,6 +987,8 @@ C9DEC04329894BED0078B43A /* Author+CoreDataClass.swift */, 3F43C47529A9625700E896A0 /* AuthorReference+CoreDataClass.swift */, C9DEC03F29894BED0078B43A /* Event+CoreDataClass.swift */, + 5044546D2C90726A00251A7E /* Event+Fetching.swift */, + 5044546F2C90728500251A7E /* Event+Hydration.swift */, 03FE3F7B2C87AC9900D25810 /* Event+InlineMetadata.swift */, 3FFB1D9229A6BBCE002A755D /* EventReference+CoreDataClass.swift */, A3B943D4299D514800A15A08 /* Follow+CoreDataClass.swift */, @@ -2195,10 +2203,12 @@ C9F0BB6B29A503D6000547FC /* PublicKey.swift in Sources */, C9EF84CF2C24D63000182B6F /* MockRelayService.swift in Sources */, 5B79F6192B98B24C002DA9BE /* DeleteUsernameWizard.swift in Sources */, + 5044546E2C90726A00251A7E /* Event+Fetching.swift in Sources */, C9EE3E602A0538B7008A7491 /* ExpirationTimeButton.swift in Sources */, 03FE3F7C2C87AC9900D25810 /* Event+InlineMetadata.swift in Sources */, A303AF8329A9153A005DC8FC /* FollowButton.swift in Sources */, 65D066992BD558690011C5CD /* DirectMessageWrapper.swift in Sources */, + 504454702C90728500251A7E /* Event+Hydration.swift in Sources */, 659B27242BD9CB4500BEA6CC /* VerifiableEvent.swift in Sources */, C9C2B77F29E0731600548B4A /* AsyncTimer.swift in Sources */, A32B6C7329A6BE9B00653FF5 /* FollowsView.swift in Sources */, @@ -2414,6 +2424,7 @@ C973AB642A323167002AED16 /* Relay+CoreDataProperties.swift in Sources */, 5BD813A32C8BA7CC00E65F4D /* PreviewEventRepository.swift in Sources */, C9EE3E642A053910008A7491 /* ExpirationTimeOption.swift in Sources */, + 504454712C90728E00251A7E /* Event+Fetching.swift in Sources */, 65D066AA2BD55E160011C5CD /* DirectMessageWrapper.swift in Sources */, C973AB5E2A323167002AED16 /* Event+CoreDataProperties.swift in Sources */, C9F64D8D29ED840700563F2B /* Zipper.swift in Sources */, @@ -2428,6 +2439,7 @@ 035729CA2BE4173E005FEE85 /* PreviewData.swift in Sources */, 037975D12C0E341500ADDF37 /* MockFeatureFlags.swift in Sources */, C92E7F682C4EFF3D00B80638 /* WebSocketErrorEvent.swift in Sources */, + 504454722C90729100251A7E /* Event+Hydration.swift in Sources */, 5BD08BB22A38E96F00BB926C /* JSONRelayMetadata.swift in Sources */, C936B45A2A4C7B7C00DF1EB9 /* Nos.xcdatamodeld in Sources */, 037975BD2C0E25E200ADDF37 /* FeatureFlags.swift in Sources */, diff --git a/Nos/Models/CoreData/Event+CoreDataClass.swift b/Nos/Models/CoreData/Event+CoreDataClass.swift index 494b9f6fe..baeb2bbe6 100644 --- a/Nos/Models/CoreData/Event+CoreDataClass.swift +++ b/Nos/Models/CoreData/Event+CoreDataClass.swift @@ -7,452 +7,12 @@ import Dependencies @objc(Event) @Observable public class Event: NosManagedObject, VerifiableEvent { - @Dependency(\.currentUser) @ObservationIgnored private var currentUser + @Dependency(\.currentUser) @ObservationIgnored var currentUser var pubKey: String { author?.hexadecimalPublicKey ?? "" } /// Event identifier for the note created by ``NoteComposer`` when displaying previews. static let previewIdentifier = "preview" - - @nonobjc public class func allEventsRequest() -> NSFetchRequest { - let fetchRequest = NSFetchRequest(entityName: "Event") - fetchRequest.sortDescriptors = [ - NSSortDescriptor(keyPath: \Event.createdAt, ascending: true), - NSSortDescriptor(keyPath: \Event.receivedAt, ascending: true) - ] - return fetchRequest - } - - // MARK: - Fetching - - @nonobjc public class func allPostsRequest(_ eventKind: EventKind = .text) -> NSFetchRequest { - let fetchRequest = NSFetchRequest(entityName: "Event") - fetchRequest.sortDescriptors = [NSSortDescriptor(keyPath: \Event.createdAt, ascending: false)] - fetchRequest.predicate = NSPredicate(format: "kind = %i", eventKind.rawValue) - return fetchRequest - } - - @nonobjc public class func allMentionsPredicate(for user: Author) -> NSPredicate { - guard let publicKey = user.hexadecimalPublicKey, !publicKey.isEmpty else { - return NSPredicate.false - } - - return NSPredicate( - format: "kind = %i AND ANY authorReferences.pubkey = %@ AND deletedOn.@count = 0", - EventKind.text.rawValue, - publicKey - ) - } - - @nonobjc public class func unpublishedEventsRequest(for user: Author) -> NSFetchRequest { - let fetchRequest = NSFetchRequest(entityName: "Event") - fetchRequest.sortDescriptors = [NSSortDescriptor(keyPath: \Event.createdAt, ascending: false)] - fetchRequest.predicate = NSPredicate( - format: "author.hexadecimalPublicKey = %@ AND " + - "SUBQUERY(shouldBePublishedTo, $relay, TRUEPREDICATE).@count > " + - "SUBQUERY(publishedTo, $relay, TRUEPREDICATE).@count AND " + - "deletedOn.@count = 0", - user.hexadecimalPublicKey ?? "" - ) - return fetchRequest - } - - @nonobjc public class func allRepliesPredicate(for user: Author) -> NSPredicate { - NSPredicate( - format: "kind = 1 AND ANY eventReferences.referencedEvent.author = %@ AND deletedOn.@count = 0", - user - ) - } - - @nonobjc public class func allZapsPredicate(for user: Author) -> NSPredicate { - guard let publicKey = user.hexadecimalPublicKey, !publicKey.isEmpty else { - return NSPredicate.false - } - - return NSPredicate( - format: "kind = %i AND ANY authorReferences.pubkey = %@ AND deletedOn.@count = 0", - EventKind.zapRequest.rawValue, - publicKey - ) - } - - /// A request for all events 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: "Event") - fetchRequest.sortDescriptors = [NSSortDescriptor(keyPath: \Event.createdAt, ascending: false)] - if let limit { - fetchRequest.fetchLimit = limit - } - - let mentionsPredicate = allMentionsPredicate(for: user) - let repliesPredicate = allRepliesPredicate(for: user) - let zapsPredicate = allZapsPredicate(for: user) - let notSelfPredicate = NSPredicate(format: "author != %@", user) - let notMuted = NSPredicate(format: "author.muted == 0", user) - let allNotificationsPredicate = NSCompoundPredicate( - orPredicateWithSubpredicates: [mentionsPredicate, repliesPredicate, zapsPredicate] - ) - var andPredicates = [allNotificationsPredicate, notSelfPredicate, notMuted] - if let since { - let sincePredicate = NSPredicate(format: "receivedAt >= %@", since as CVarArg) - andPredicates.append(sincePredicate) - } - fetchRequest.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: andPredicates) - - return fetchRequest - } - - @nonobjc public class func lastReceived(for user: Author) -> NSFetchRequest { - let fetchRequest = NSFetchRequest(entityName: "Event") - fetchRequest.predicate = NSPredicate(format: "author != %@", user) - fetchRequest.fetchLimit = 1 - fetchRequest.sortDescriptors = [NSSortDescriptor(keyPath: \Event.receivedAt, ascending: false)] - return fetchRequest - } - - @nonobjc public class func allReplies(to rootEvent: Event) -> NSFetchRequest { - allReplies(toNoteWith: rootEvent.identifier) - } - - @nonobjc public class func allReplies(toNoteWith noteID: String?) -> NSFetchRequest { - guard let noteID else { - return emptyRequest() - } - - let replyNoteReferences = "kind = 1 " + - "AND ANY eventReferences.referencedEvent.identifier == %@ " + - "AND author.muted = false" - - let fetchRequest = NSFetchRequest(entityName: "Event") - fetchRequest.sortDescriptors = [NSSortDescriptor(keyPath: \Event.receivedAt, ascending: false)] - fetchRequest.predicate = NSPredicate( - format: replyNoteReferences, - noteID - ) - return fetchRequest - } - - /// A request for all events that responded to a specific note. - /// - /// - Parameter noteIdentifier: ID of the note to retrieve replies for. - /// - /// Intented to be used primarily to compute the number of replies and for - /// building a set of author avatars. - @nonobjc public class func replies(to noteID: RawEventID) -> NSFetchRequest { - let format = """ - SUBQUERY( - eventReferences, - $e, - $e.referencedEvent.identifier = %@ AND - ($e.marker = 'reply' OR $e.marker = 'root') - ).@count > 0 - """ - - let predicate = NSCompoundPredicate( - andPredicateWithSubpredicates: [ - NSPredicate(format: "kind = 1"), - NSPredicate( - format: format, - noteID, - noteID - ), - NSPredicate(format: "deletedOn.@count = 0"), - NSPredicate(format: "author.muted = false") - ] - ) - - let fetchRequest = Event.fetchRequest() - fetchRequest.includesPendingChanges = false - fetchRequest.includesSubentities = false - fetchRequest.sortDescriptors = [ - NSSortDescriptor(keyPath: \Event.identifier, ascending: true) - ] - - fetchRequest.predicate = predicate - fetchRequest.relationshipKeyPathsForPrefetching = ["author"] - return fetchRequest - } - - /// A fetch request for all the events that should be cleared out of the database by - /// `DatabaseCleaner.cleanupEntities(...)`. - /// - /// It will save the events for the given `user`, as well as other important events matching various other - /// criteria. - /// - Parameter before: The date before which events will be considered for cleanup. - /// - Parameter user: The Author record for the currently logged in user. Special treatment is given to their data. - @nonobjc public class func cleanupRequest(before date: Date, for user: Author) -> NSFetchRequest { - let oldStoryCutoff = Calendar.current.date(byAdding: .day, value: -2, to: .now) ?? .now - - let request = NSFetchRequest(entityName: "Event") - request.sortDescriptors = [NSSortDescriptor(keyPath: \Event.receivedAt, ascending: true)] - let oldUnreferencedEventsClause = "(receivedAt < %@ OR receivedAt == nil) AND referencingEvents.@count = 0" - let notOwnEventClause = "(author != %@)" - let readStoryClause = "(isRead = 1 AND receivedAt > %@)" - let userReportClause = "(kind == \(EventKind.report.rawValue) AND " + - "authorReferences.@count > 0 AND eventReferences.@count == 0)" - let clauses = "\(oldUnreferencedEventsClause) AND" + - "\(notOwnEventClause) AND " + - "NOT \(readStoryClause) AND " + - "NOT \(userReportClause)" - request.predicate = NSPredicate( - format: clauses, - date as CVarArg, - user, - oldStoryCutoff as CVarArg - ) - - return request - } - - /// This constructs a predicate for events that should be protected from deletion when we are purging the database. - /// - Parameter user: The Author record for the currently logged in user. Special treatment is given to their data. - /// - Parameter asSubquery: If true then each attribute in the predicate will prefixed with "$event." so the - /// predicate can be used in a SUBQUERY. - @nonobjc public class func protectedFromCleanupPredicate( - for user: Author, - asSubquery: Bool = false - ) -> NSPredicate { - guard let userKey = user.hexadecimalPublicKey else { - return NSPredicate.false - } - - // The string we use to reference the current event if we are constructing this predicate to be used in a - // subquery - let eventReference = asSubquery ? "$event." : "" - - // protect all events authored by the current user - let userEventsPredicate = NSPredicate(format: "\(eventReference)author.hexadecimalPublicKey = '\(userKey)'") - - // protect stories that were read recently, so we don't redownload and show them as unread again - let oldStoryCutoffDate = Calendar.current.date(byAdding: .day, value: -2, to: .now) ?? .now - let recentlyReadStoriesPredicate = NSPredicate( - format: "(\(eventReference)isRead = 1 AND \(eventReference)receivedAt > %@)", - oldStoryCutoffDate as CVarArg - ) - - // keep author reports from people we follow - let userReportPredicate = NSPredicate( - format: "(\(eventReference)kind == \(EventKind.report.rawValue) AND " + - "SUBQUERY(\(eventReference)authorReferences, $references, TRUEPREDICATE).@count > 0 AND " + - "SUBQUERY(\(eventReference)eventReferences, $references, TRUEPREDICATE).@count == 0 AND " + - "ANY \(eventReference)author.followers.source.hexadecimalPublicKey == %@)", - userKey - ) - - return NSCompoundPredicate( - orPredicateWithSubpredicates: [ - userEventsPredicate, - recentlyReadStoriesPredicate, - userReportPredicate - ] - ) - } - - @nonobjc public class func expiredRequest() -> NSFetchRequest { - let fetchRequest = NSFetchRequest(entityName: "Event") - fetchRequest.predicate = NSPredicate(format: "expirationDate <= %@", Date.now as CVarArg) - return fetchRequest - } - - /// Builds a query that returns an Event with "preview" as its `identifier` if it exists. - /// - Returns: A Fetch Request with the necessary query inside. - @nonobjc public class func previewRequest() -> NSFetchRequest { - let fetchRequest = NSFetchRequest(entityName: "Event") - fetchRequest.predicate = NSPredicate( - format: "identifier = %@", - Event.previewIdentifier as CVarArg - ) - return fetchRequest - } - - @nonobjc public class func event(by identifier: RawEventID) -> NSFetchRequest { - let fetchRequest = NSFetchRequest(entityName: "Event") - fetchRequest.predicate = NSPredicate(format: "identifier = %@", identifier) - fetchRequest.sortDescriptors = [NSSortDescriptor(keyPath: \Event.identifier, ascending: true)] - fetchRequest.fetchLimit = 1 - return fetchRequest - } - - @nonobjc public class func event( - by replaceableID: RawReplaceableID, - author: Author, - kind: Int64 - ) -> NSFetchRequest { - let fetchRequest = NSFetchRequest(entityName: "Event") - fetchRequest.predicate = NSPredicate( - format: "replaceableIdentifier = %@ AND author = %@ AND kind = %i", - replaceableID, - author, - kind - ) - fetchRequest.sortDescriptors = [NSSortDescriptor(keyPath: \Event.replaceableIdentifier, ascending: true)] - fetchRequest.fetchLimit = 1 - return fetchRequest - } - - @nonobjc public class func hydratedEvent(by identifier: String) -> NSFetchRequest { - let fetchRequest = NSFetchRequest(entityName: "Event") - fetchRequest.predicate = NSPredicate( - format: "identifier = %@ AND createdAt != nil AND author != nil", identifier - ) - fetchRequest.fetchLimit = 1 - return fetchRequest - } - - @nonobjc public class func event(by identifier: String, seenOn relay: Relay) -> NSFetchRequest { - guard let relayAddress = relay.address else { - return Event.emptyRequest() - } - - let fetchRequest = NSFetchRequest(entityName: "Event") - fetchRequest.predicate = NSPredicate( - format: "identifier = %@ AND ANY seenOnRelays.address = %@", - identifier, - relayAddress - ) - fetchRequest.sortDescriptors = [NSSortDescriptor(keyPath: \Relay.createdAt, ascending: true)] - fetchRequest.fetchLimit = 1 - return fetchRequest - } - - /// Returns a predicate that can be used to fetch the given user's home feed. - /// - Parameters: - /// - user: The user whose home feed should appear. - /// - before: Only fetch events that were created before this date. Defaults to `nil`. - /// - after: Only fetch events that were created after this date. Defaults to `nil`. - /// - relay: Only fetch events on this relay. Defaults to `nil`, which uses all the user's relays. - /// - Returns: A predicate matching the given parameters that can be used to fetch the user's home feed. - @nonobjc private class func homeFeedPredicate( - for user: Author, - before: Date? = nil, - after: Date? = nil, - seenOn relay: Relay? = nil - ) -> NSPredicate { - let kind1Predicate = NSCompoundPredicate(andPredicateWithSubpredicates: [ - NSPredicate(format: "kind = 1"), - NSPredicate( - format: "SUBQUERY(" + - "eventReferences, $reference, $reference.marker = 'root'" + - " OR $reference.marker = 'reply'" + - " OR $reference.marker = nil" + - ").@count = 0" - ), - NSPredicate( - format: "identifier != %@", - Event.previewIdentifier as CVarArg - ) - ]) - let kind6Predicate = NSPredicate(format: "kind = 6") - let kind30023Predicate = NSPredicate(format: "kind = 30023") - - let kindsPredicate = NSCompoundPredicate(orPredicateWithSubpredicates: [ - kind1Predicate, - kind6Predicate, - kind30023Predicate - ]) - - let notMutedPredicate = NSPredicate(format: "author.muted = 0") - let notDeletedPredicate = NSPredicate(format: "deletedOn.@count = 0") - - var andPredicates = [kindsPredicate, notMutedPredicate, notDeletedPredicate] - - if let before { - andPredicates.append(NSPredicate(format: "createdAt <= %@", before as CVarArg)) - } - - if let after { - andPredicates.append(NSPredicate(format: "createdAt > %@", after as CVarArg)) - } - - if let relay { - andPredicates.append(NSPredicate(format: "ANY seenOnRelays = %@", relay as CVarArg)) - } else { - andPredicates.append( - NSPredicate(format: "(ANY author.followers.source = %@ OR author = %@)", user, user) - ) - } - - return NSCompoundPredicate(andPredicateWithSubpredicates: andPredicates) - } - - @nonobjc public class func homeFeed( - for user: Author, - before: Date, - seenOn relay: Relay? = nil - ) -> NSFetchRequest { - let fetchRequest = NSFetchRequest(entityName: "Event") - fetchRequest.sortDescriptors = [NSSortDescriptor(keyPath: \Event.createdAt, ascending: false)] - fetchRequest.predicate = homeFeedPredicate(for: user, before: before, seenOn: relay) - return fetchRequest - } - - @nonobjc public class func homeFeed( - for user: Author, - after: Date, - seenOn relay: Relay? = nil - ) -> NSFetchRequest { - let fetchRequest = NSFetchRequest(entityName: "Event") - fetchRequest.sortDescriptors = [NSSortDescriptor(keyPath: \Event.createdAt, ascending: false)] - fetchRequest.predicate = homeFeedPredicate(for: user, after: after, seenOn: relay) - return fetchRequest - } - - @nonobjc public class func likes(noteID: String) -> NSFetchRequest { - let fetchRequest = NSFetchRequest(entityName: "Event") - fetchRequest.sortDescriptors = [NSSortDescriptor(keyPath: \Event.createdAt, ascending: false)] - let noteIsLikedByUserPredicate = NSPredicate( - // swiftlint:disable line_length - format: "kind = \(String(EventKind.like.rawValue)) AND SUBQUERY(eventReferences, $reference, $reference.eventId = %@).@count > 0 AND deletedOn.@count = 0", - // swiftlint:enable line_length - noteID - ) - fetchRequest.predicate = noteIsLikedByUserPredicate - return fetchRequest - } - - @nonobjc public class func reposts(noteID: String) -> NSFetchRequest { - let fetchRequest = NSFetchRequest(entityName: "Event") - fetchRequest.sortDescriptors = [NSSortDescriptor(keyPath: \Event.createdAt, ascending: false)] - let noteIsLikedByUserPredicate = NSPredicate( - // swiftlint:disable line_length - format: "kind = \(String(EventKind.repost.rawValue)) AND SUBQUERY(eventReferences, $reference, $reference.eventId = %@).@count > 0 AND deletedOn.@count = 0", - // swiftlint:enable line_length - noteID - ) - fetchRequest.predicate = noteIsLikedByUserPredicate - return fetchRequest - } - - @nonobjc public class func emptyRequest() -> NSFetchRequest { - let fetchRequest = NSFetchRequest(entityName: "Event") - fetchRequest.sortDescriptors = [NSSortDescriptor(keyPath: \Event.createdAt, ascending: true)] - fetchRequest.predicate = NSPredicate.false - return fetchRequest - } - - @nonobjc public class func deleteAllEvents() -> NSBatchDeleteRequest { - let fetchRequest: NSFetchRequest = NSFetchRequest(entityName: "Event") - let deleteRequest = NSBatchDeleteRequest(fetchRequest: fetchRequest) - return deleteRequest - } - - @nonobjc public class func deleteAllPosts(by author: Author) -> NSBatchDeleteRequest { - let fetchRequest: NSFetchRequest = NSFetchRequest(entityName: "Event") - let kind = EventKind.text.rawValue - let key = author.hexadecimalPublicKey ?? "notakey" - fetchRequest.predicate = NSPredicate(format: "kind = %i AND author.hexadecimalPublicKey = %@", kind, key) - let deleteRequest = NSBatchDeleteRequest(fetchRequest: fetchRequest) - return deleteRequest - } class func all(context: NSManagedObjectContext) -> [Event] { let allRequest = Event.allPostsRequest() @@ -484,17 +44,6 @@ public class Event: NosManagedObject, VerifiableEvent { } return nil - } - - func reportsRequest() -> NSFetchRequest { - let request = NSFetchRequest(entityName: "Event") - request.predicate = NSPredicate( - format: "kind = %i AND ANY eventReferences.referencedEvent = %@ AND deletedOn.@count = 0", - EventKind.report.rawValue, - self - ) - request.sortDescriptors = [NSSortDescriptor(keyPath: \Event.identifier, ascending: true)] - return request } // MARK: - Creating @@ -580,216 +129,12 @@ public class Event: NosManagedObject, VerifiableEvent { } } - /// Populates an event stub (with only its ID set) using the data in the given JSON. - private func hydrate(from jsonEvent: JSONEvent, relay: Relay?, in context: NSManagedObjectContext) throws { - assert(isStub, "Tried to hydrate an event that isn't a stub. This is a programming error") - - // if this stub was created with a replaceableIdentifier and author, it won't have an identifier yet - identifier = jsonEvent.id - - // Meta data - createdAt = Date(timeIntervalSince1970: TimeInterval(jsonEvent.createdAt)) - if let createdAt, createdAt > .now { - self.createdAt = .now - } - content = jsonEvent.content - kind = jsonEvent.kind - signature = jsonEvent.signature - sendAttempts = 0 - - // Tags - allTags = jsonEvent.tags as NSObject - for tag in jsonEvent.tags { - if tag[safe: 0] == "expiration", - let expirationDateString = tag[safe: 1], - let expirationDateUnix = TimeInterval(expirationDateString), - expirationDateUnix != 0 { - let expirationDate = Date(timeIntervalSince1970: expirationDateUnix) - self.expirationDate = expirationDate - if isExpired { - throw EventError.expiredEvent - } - } else if tag[safe: 0] == "d", - let dTag = tag[safe: 1] { - replaceableIdentifier = dTag - } - } - - // Author - guard let newAuthor = try? Author.findOrCreate(by: jsonEvent.pubKey, context: context) else { - throw EventError.missingAuthor - } - - author = newAuthor - - // Relay - relay.unwrap { markSeen(on: $0) } - - guard let eventKind = EventKind(rawValue: kind) else { - throw EventError.unrecognizedKind - } - - switch eventKind { - case .contactList: - hydrateContactList(from: jsonEvent, author: newAuthor, context: context) - - case .metaData: - hydrateMetaData(from: jsonEvent, author: newAuthor, context: context) - - case .mute: - hydrateMuteList(from: jsonEvent, context: context) - case .repost: - - hydrateDefault(from: jsonEvent, context: context) - parseContent(from: jsonEvent, context: context) - - default: - hydrateDefault(from: jsonEvent, context: context) - } - } - - private func hydrateContactList( - from jsonEvent: JSONEvent, - author newAuthor: Author, - context: NSManagedObjectContext - ) { - guard createdAt! > newAuthor.lastUpdatedContactList ?? Date.distantPast else { - return - } - - newAuthor.lastUpdatedContactList = Date(timeIntervalSince1970: TimeInterval(jsonEvent.createdAt)) - - // Put existing follows into a dictionary so we can avoid doing a fetch request to look up each one. - var originalFollows = [RawAuthorID: Follow]() - for follow in newAuthor.follows { - if let pubKey = follow.destination?.hexadecimalPublicKey { - originalFollows[pubKey] = follow - } - } - - var newFollows = Set() - for jsonTag in jsonEvent.tags { - if let followedKey = jsonTag[safe: 1], - let existingFollow = originalFollows[followedKey] { - // We already have a Core Data Follow model for this user - newFollows.insert(existingFollow) - } else { - do { - newFollows.insert(try Follow.upsert(by: newAuthor, jsonTag: jsonTag, context: context)) - } catch { - Log.error("Error: could not parse Follow from: \(jsonEvent)") - } - } - } - - // Did we unfollow someone? If so, remove them from core data - let removedFollows = Set(originalFollows.values).subtracting(newFollows) - if !removedFollows.isEmpty { - Log.info("Removing \(removedFollows.count) follows") - Follow.deleteFollows(in: removedFollows, context: context) - } - - newAuthor.follows = newFollows - - // Get the user's active relays out of the content property - if let data = jsonEvent.content.data(using: .utf8, allowLossyConversion: false), - let relayEntries = try? JSONSerialization.jsonObject(with: data, options: .mutableContainers), - let relays = (relayEntries as? [String: Any])?.keys { - newAuthor.relays = Set() - - for address in relays { - if let relay = try? Relay.findOrCreate(by: address, context: context) { - newAuthor.add(relay: relay) - } - } - } - } - - private func hydrateDefault(from jsonEvent: JSONEvent, context: NSManagedObjectContext) { - let newEventReferences = NSMutableOrderedSet() - let newAuthorReferences = NSMutableOrderedSet() - for jsonTag in jsonEvent.tags { - if jsonTag.first == "e" { - // TODO: validate that the tag looks like an event ref - do { - let eTag = try EventReference(jsonTag: jsonTag, context: context) - newEventReferences.add(eTag) - } catch { - print("error parsing e tag: \(error.localizedDescription)") - } - } else if jsonTag.first == "p" { - // TODO: validate that the tag looks like a pubkey - let authorReference = AuthorReference(context: context) - authorReference.pubkey = jsonTag[safe: 1] - authorReference.recommendedRelayUrl = jsonTag[safe: 2] - newAuthorReferences.add(authorReference) - } - } - eventReferences = newEventReferences - authorReferences = newAuthorReferences - } - - private func hydrateMetaData(from jsonEvent: JSONEvent, author newAuthor: Author, context: NSManagedObjectContext) { - guard createdAt! > newAuthor.lastUpdatedMetadata ?? Date.distantPast else { - // This is old data - return - } - - if let contentData = jsonEvent.content.data(using: .utf8) { - newAuthor.lastUpdatedMetadata = Date(timeIntervalSince1970: TimeInterval(jsonEvent.createdAt)) - // There may be unsupported metadata. Store it to send back later in metadata publishes. - newAuthor.rawMetadata = contentData - - do { - let metadata = try JSONDecoder().decode(MetadataEventJSON.self, from: contentData) - - // Every event has an author created, so it just needs to be populated - newAuthor.name = metadata.name - newAuthor.displayName = metadata.displayName - newAuthor.about = metadata.about - newAuthor.profilePhotoURL = metadata.profilePhotoURL - newAuthor.website = metadata.website - newAuthor.nip05 = metadata.nip05 - newAuthor.uns = metadata.uns - } catch { - print("Failed to decode metaData event with ID \(String(describing: identifier))") - } - } - } - func markSeen(on relay: Relay) { seenOnRelays.insert(relay) } - - private func hydrateMuteList(from jsonEvent: JSONEvent, context: NSManagedObjectContext) { - let mutedKeys = jsonEvent.tags.map { $0[1] } - - let request = Author.allAuthorsRequest(muted: true) - - // Un-Mute anyone (locally only) who is muted but not in the mutedKeys - if let authors = try? context.fetch(request) { - for author in authors where !mutedKeys.contains(author.hexadecimalPublicKey!) { - author.muted = false - print("Parse-Un-Muted \(author.hexadecimalPublicKey ?? "")") - } - } - - // Mute anyone (locally only) in the mutedKeys - for key in mutedKeys { - if let author = try? Author.find(by: key, context: context) { - author.muted = true - print("Parse-Muted \(author.hexadecimalPublicKey ?? "")") - } - } - - // Force ensure current user never was muted - Task { @MainActor in - currentUser.author?.muted = false - } - } /// Tries to parse a new event out of the given jsonEvent's `content` field. - private func parseContent(from jsonEvent: JSONEvent, context: NSManagedObjectContext) { + func parseContent(from jsonEvent: JSONEvent, context: NSManagedObjectContext) { do { if let contentData = jsonEvent.content.data(using: .utf8) { let jsonEvent = try JSONDecoder().decode(JSONEvent.self, from: contentData) @@ -928,12 +273,6 @@ public class Event: NosManagedObject, VerifiableEvent { ] } - /// Returns true if this event doesn't have content. Usually this means we saw it referenced by another event - /// but we haven't actually downloaded it yet. - var isStub: Bool { - author == nil || createdAt == nil || identifier == nil - } - func calculateIdentifier() throws -> String { let serializedEventData = try JSONSerialization.data( withJSONObject: serializedEventForSigning, @@ -1222,8 +561,14 @@ public class Event: NosManagedObject, VerifiableEvent { } } - /// Converts an event back to a stubbed event by deleting all data except the `identifier`. - func stub() { + /// Returns true if this event doesn't have content. Usually this means we saw it referenced by another event + /// but we haven't actually downloaded it yet. + var isStub: Bool { + author == nil || createdAt == nil || identifier == nil + } + + /// Converts an event back to a stubbed event by resetting most properties and leaving the `identifier` in place. + func resetToStub() { allTags = nil content = nil createdAt = nil diff --git a/Nos/Models/CoreData/Event+Fetching.swift b/Nos/Models/CoreData/Event+Fetching.swift new file mode 100644 index 000000000..94a7d41a3 --- /dev/null +++ b/Nos/Models/CoreData/Event+Fetching.swift @@ -0,0 +1,453 @@ +import CoreData + +extension Event { + + @nonobjc public class func allEventsRequest() -> NSFetchRequest { + let fetchRequest = NSFetchRequest(entityName: "Event") + fetchRequest.sortDescriptors = [ + NSSortDescriptor(keyPath: \Event.createdAt, ascending: true), + NSSortDescriptor(keyPath: \Event.receivedAt, ascending: true) + ] + return fetchRequest + } + + @nonobjc public class func allPostsRequest(_ eventKind: EventKind = .text) -> NSFetchRequest { + let fetchRequest = NSFetchRequest(entityName: "Event") + fetchRequest.sortDescriptors = [NSSortDescriptor(keyPath: \Event.createdAt, ascending: false)] + fetchRequest.predicate = NSPredicate(format: "kind = %i", eventKind.rawValue) + return fetchRequest + } + + @nonobjc public class func allMentionsPredicate(for user: Author) -> NSPredicate { + guard let publicKey = user.hexadecimalPublicKey, !publicKey.isEmpty else { + return NSPredicate.false + } + + return NSPredicate( + format: "kind = %i AND ANY authorReferences.pubkey = %@ AND deletedOn.@count = 0", + EventKind.text.rawValue, + publicKey + ) + } + + @nonobjc public class func unpublishedEventsRequest(for user: Author) -> NSFetchRequest { + let fetchRequest = NSFetchRequest(entityName: "Event") + fetchRequest.sortDescriptors = [NSSortDescriptor(keyPath: \Event.createdAt, ascending: false)] + fetchRequest.predicate = NSPredicate( + format: "author.hexadecimalPublicKey = %@ AND " + + "SUBQUERY(shouldBePublishedTo, $relay, TRUEPREDICATE).@count > " + + "SUBQUERY(publishedTo, $relay, TRUEPREDICATE).@count AND " + + "deletedOn.@count = 0", + user.hexadecimalPublicKey ?? "" + ) + return fetchRequest + } + + @nonobjc public class func allRepliesPredicate(for user: Author) -> NSPredicate { + NSPredicate( + format: "kind = 1 AND ANY eventReferences.referencedEvent.author = %@ AND deletedOn.@count = 0", + user + ) + } + + @nonobjc public class func allZapsPredicate(for user: Author) -> NSPredicate { + guard let publicKey = user.hexadecimalPublicKey, !publicKey.isEmpty else { + return NSPredicate.false + } + + return NSPredicate( + format: "kind = %i AND ANY authorReferences.pubkey = %@ AND deletedOn.@count = 0", + EventKind.zapRequest.rawValue, + publicKey + ) + } + + /// A request for all events 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: "Event") + fetchRequest.sortDescriptors = [NSSortDescriptor(keyPath: \Event.createdAt, ascending: false)] + if let limit { + fetchRequest.fetchLimit = limit + } + + let mentionsPredicate = allMentionsPredicate(for: user) + let repliesPredicate = allRepliesPredicate(for: user) + let zapsPredicate = allZapsPredicate(for: user) + let notSelfPredicate = NSPredicate(format: "author != %@", user) + let notMuted = NSPredicate(format: "author.muted == 0", user) + let allNotificationsPredicate = NSCompoundPredicate( + orPredicateWithSubpredicates: [mentionsPredicate, repliesPredicate, zapsPredicate] + ) + var andPredicates = [allNotificationsPredicate, notSelfPredicate, notMuted] + if let since { + let sincePredicate = NSPredicate(format: "receivedAt >= %@", since as CVarArg) + andPredicates.append(sincePredicate) + } + fetchRequest.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: andPredicates) + + return fetchRequest + } + + @nonobjc public class func lastReceived(for user: Author) -> NSFetchRequest { + let fetchRequest = NSFetchRequest(entityName: "Event") + fetchRequest.predicate = NSPredicate(format: "author != %@", user) + fetchRequest.fetchLimit = 1 + fetchRequest.sortDescriptors = [NSSortDescriptor(keyPath: \Event.receivedAt, ascending: false)] + return fetchRequest + } + + @nonobjc public class func allReplies(to rootEvent: Event) -> NSFetchRequest { + allReplies(toNoteWith: rootEvent.identifier) + } + + @nonobjc public class func allReplies(toNoteWith noteID: String?) -> NSFetchRequest { + guard let noteID else { + return emptyRequest() + } + + let replyNoteReferences = "kind = 1 " + + "AND ANY eventReferences.referencedEvent.identifier == %@ " + + "AND author.muted = false" + + let fetchRequest = NSFetchRequest(entityName: "Event") + fetchRequest.sortDescriptors = [NSSortDescriptor(keyPath: \Event.receivedAt, ascending: false)] + fetchRequest.predicate = NSPredicate( + format: replyNoteReferences, + noteID + ) + return fetchRequest + } + + /// A request for all events that responded to a specific note. + /// + /// - Parameter noteIdentifier: ID of the note to retrieve replies for. + /// + /// Intented to be used primarily to compute the number of replies and for + /// building a set of author avatars. + @nonobjc public class func replies(to noteID: RawEventID) -> NSFetchRequest { + let format = """ + SUBQUERY( + eventReferences, + $e, + $e.referencedEvent.identifier = %@ AND + ($e.marker = 'reply' OR $e.marker = 'root') + ).@count > 0 + """ + + let predicate = NSCompoundPredicate( + andPredicateWithSubpredicates: [ + NSPredicate(format: "kind = 1"), + NSPredicate( + format: format, + noteID, + noteID + ), + NSPredicate(format: "deletedOn.@count = 0"), + NSPredicate(format: "author.muted = false") + ] + ) + + let fetchRequest = Event.fetchRequest() + fetchRequest.includesPendingChanges = false + fetchRequest.includesSubentities = false + fetchRequest.sortDescriptors = [ + NSSortDescriptor(keyPath: \Event.identifier, ascending: true) + ] + + fetchRequest.predicate = predicate + fetchRequest.relationshipKeyPathsForPrefetching = ["author"] + return fetchRequest + } + + /// A fetch request for all the events that should be cleared out of the database by + /// `DatabaseCleaner.cleanupEntities(...)`. + /// + /// It will save the events for the given `user`, as well as other important events matching various other + /// criteria. + /// - Parameter before: The date before which events will be considered for cleanup. + /// - Parameter user: The Author record for the currently logged in user. Special treatment is given to their data. + @nonobjc public class func cleanupRequest(before date: Date, for user: Author) -> NSFetchRequest { + let oldStoryCutoff = Calendar.current.date(byAdding: .day, value: -2, to: .now) ?? .now + + let request = NSFetchRequest(entityName: "Event") + request.sortDescriptors = [NSSortDescriptor(keyPath: \Event.receivedAt, ascending: true)] + let oldUnreferencedEventsClause = "(receivedAt < %@ OR receivedAt == nil) AND referencingEvents.@count = 0" + let notOwnEventClause = "(author != %@)" + let readStoryClause = "(isRead = 1 AND receivedAt > %@)" + let userReportClause = "(kind == \(EventKind.report.rawValue) AND " + + "authorReferences.@count > 0 AND eventReferences.@count == 0)" + let clauses = "\(oldUnreferencedEventsClause) AND" + + "\(notOwnEventClause) AND " + + "NOT \(readStoryClause) AND " + + "NOT \(userReportClause)" + request.predicate = NSPredicate( + format: clauses, + date as CVarArg, + user, + oldStoryCutoff as CVarArg + ) + + return request + } + + /// This constructs a predicate for events that should be protected from deletion when we are purging the database. + /// - Parameter user: The Author record for the currently logged in user. Special treatment is given to their data. + /// - Parameter asSubquery: If true then each attribute in the predicate will prefixed with "$event." so the + /// predicate can be used in a SUBQUERY. + @nonobjc public class func protectedFromCleanupPredicate( + for user: Author, + asSubquery: Bool = false + ) -> NSPredicate { + guard let userKey = user.hexadecimalPublicKey else { + return NSPredicate.false + } + + // The string we use to reference the current event if we are constructing this predicate to be used in a + // subquery + let eventReference = asSubquery ? "$event." : "" + + // protect all events authored by the current user + let userEventsPredicate = NSPredicate(format: "\(eventReference)author.hexadecimalPublicKey = '\(userKey)'") + + // protect stories that were read recently, so we don't redownload and show them as unread again + let oldStoryCutoffDate = Calendar.current.date(byAdding: .day, value: -2, to: .now) ?? .now + let recentlyReadStoriesPredicate = NSPredicate( + format: "(\(eventReference)isRead = 1 AND \(eventReference)receivedAt > %@)", + oldStoryCutoffDate as CVarArg + ) + + // keep author reports from people we follow + let userReportPredicate = NSPredicate( + format: "(\(eventReference)kind == \(EventKind.report.rawValue) AND " + + "SUBQUERY(\(eventReference)authorReferences, $references, TRUEPREDICATE).@count > 0 AND " + + "SUBQUERY(\(eventReference)eventReferences, $references, TRUEPREDICATE).@count == 0 AND " + + "ANY \(eventReference)author.followers.source.hexadecimalPublicKey == %@)", + userKey + ) + + return NSCompoundPredicate( + orPredicateWithSubpredicates: [ + userEventsPredicate, + recentlyReadStoriesPredicate, + userReportPredicate + ] + ) + } + + @nonobjc public class func expiredRequest() -> NSFetchRequest { + let fetchRequest = NSFetchRequest(entityName: "Event") + fetchRequest.predicate = NSPredicate(format: "expirationDate <= %@", Date.now as CVarArg) + return fetchRequest + } + + /// Builds a query that returns an Event with "preview" as its `identifier` if it exists. + /// - Returns: A Fetch Request with the necessary query inside. + @nonobjc public class func previewRequest() -> NSFetchRequest { + let fetchRequest = NSFetchRequest(entityName: "Event") + fetchRequest.predicate = NSPredicate( + format: "identifier = %@", + Event.previewIdentifier as CVarArg + ) + return fetchRequest + } + + @nonobjc public class func event(by identifier: RawEventID) -> NSFetchRequest { + let fetchRequest = NSFetchRequest(entityName: "Event") + fetchRequest.predicate = NSPredicate(format: "identifier = %@", identifier) + fetchRequest.sortDescriptors = [NSSortDescriptor(keyPath: \Event.identifier, ascending: true)] + fetchRequest.fetchLimit = 1 + return fetchRequest + } + + @nonobjc public class func event( + by replaceableID: RawReplaceableID, + author: Author, + kind: Int64 + ) -> NSFetchRequest { + let fetchRequest = NSFetchRequest(entityName: "Event") + fetchRequest.predicate = NSPredicate( + format: "replaceableIdentifier = %@ AND author = %@ AND kind = %i", + replaceableID, + author, + kind + ) + fetchRequest.sortDescriptors = [NSSortDescriptor(keyPath: \Event.replaceableIdentifier, ascending: true)] + fetchRequest.fetchLimit = 1 + return fetchRequest + } + + @nonobjc public class func hydratedEvent(by identifier: String) -> NSFetchRequest { + let fetchRequest = NSFetchRequest(entityName: "Event") + fetchRequest.predicate = NSPredicate( + format: "identifier = %@ AND createdAt != nil AND author != nil", identifier + ) + fetchRequest.fetchLimit = 1 + return fetchRequest + } + + @nonobjc public class func event(by identifier: String, seenOn relay: Relay) -> NSFetchRequest { + guard let relayAddress = relay.address else { + return Event.emptyRequest() + } + + let fetchRequest = NSFetchRequest(entityName: "Event") + fetchRequest.predicate = NSPredicate( + format: "identifier = %@ AND ANY seenOnRelays.address = %@", + identifier, + relayAddress + ) + fetchRequest.sortDescriptors = [NSSortDescriptor(keyPath: \Relay.createdAt, ascending: true)] + fetchRequest.fetchLimit = 1 + return fetchRequest + } + + /// Returns a predicate that can be used to fetch the given user's home feed. + /// - Parameters: + /// - user: The user whose home feed should appear. + /// - before: Only fetch events that were created before this date. Defaults to `nil`. + /// - after: Only fetch events that were created after this date. Defaults to `nil`. + /// - relay: Only fetch events on this relay. Defaults to `nil`, which uses all the user's relays. + /// - Returns: A predicate matching the given parameters that can be used to fetch the user's home feed. + @nonobjc private class func homeFeedPredicate( + for user: Author, + before: Date? = nil, + after: Date? = nil, + seenOn relay: Relay? = nil + ) -> NSPredicate { + let kind1Predicate = NSCompoundPredicate(andPredicateWithSubpredicates: [ + NSPredicate(format: "kind = 1"), + NSPredicate( + format: "SUBQUERY(" + + "eventReferences, $reference, $reference.marker = 'root'" + + " OR $reference.marker = 'reply'" + + " OR $reference.marker = nil" + + ").@count = 0" + ), + NSPredicate( + format: "identifier != %@", + Event.previewIdentifier as CVarArg + ) + ]) + let kind6Predicate = NSPredicate(format: "kind = 6") + let kind30023Predicate = NSPredicate(format: "kind = 30023") + + let kindsPredicate = NSCompoundPredicate(orPredicateWithSubpredicates: [ + kind1Predicate, + kind6Predicate, + kind30023Predicate + ]) + + let notMutedPredicate = NSPredicate(format: "author.muted = 0") + let notDeletedPredicate = NSPredicate(format: "deletedOn.@count = 0") + + var andPredicates = [kindsPredicate, notMutedPredicate, notDeletedPredicate] + + if let before { + andPredicates.append(NSPredicate(format: "createdAt <= %@", before as CVarArg)) + } + + if let after { + andPredicates.append(NSPredicate(format: "createdAt > %@", after as CVarArg)) + } + + if let relay { + andPredicates.append(NSPredicate(format: "ANY seenOnRelays = %@", relay as CVarArg)) + } else { + andPredicates.append( + NSPredicate(format: "(ANY author.followers.source = %@ OR author = %@)", user, user) + ) + } + + return NSCompoundPredicate(andPredicateWithSubpredicates: andPredicates) + } + + @nonobjc public class func homeFeed( + for user: Author, + before: Date, + seenOn relay: Relay? = nil + ) -> NSFetchRequest { + let fetchRequest = NSFetchRequest(entityName: "Event") + fetchRequest.sortDescriptors = [NSSortDescriptor(keyPath: \Event.createdAt, ascending: false)] + fetchRequest.predicate = homeFeedPredicate(for: user, before: before, seenOn: relay) + return fetchRequest + } + + @nonobjc public class func homeFeed( + for user: Author, + after: Date, + seenOn relay: Relay? = nil + ) -> NSFetchRequest { + let fetchRequest = NSFetchRequest(entityName: "Event") + fetchRequest.sortDescriptors = [NSSortDescriptor(keyPath: \Event.createdAt, ascending: false)] + fetchRequest.predicate = homeFeedPredicate(for: user, after: after, seenOn: relay) + return fetchRequest + } + + @nonobjc public class func likes(noteID: String) -> NSFetchRequest { + let fetchRequest = NSFetchRequest(entityName: "Event") + fetchRequest.sortDescriptors = [NSSortDescriptor(keyPath: \Event.createdAt, ascending: false)] + let noteIsLikedByUserPredicate = NSPredicate( + // swiftlint:disable line_length + format: "kind = \(String(EventKind.like.rawValue)) AND SUBQUERY(eventReferences, $reference, $reference.eventId = %@).@count > 0 AND deletedOn.@count = 0", + // swiftlint:enable line_length + noteID + ) + fetchRequest.predicate = noteIsLikedByUserPredicate + return fetchRequest + } + + @nonobjc public class func reposts(noteID: String) -> NSFetchRequest { + let fetchRequest = NSFetchRequest(entityName: "Event") + fetchRequest.sortDescriptors = [NSSortDescriptor(keyPath: \Event.createdAt, ascending: false)] + let noteIsLikedByUserPredicate = NSPredicate( + // swiftlint:disable line_length + format: "kind = \(String(EventKind.repost.rawValue)) AND SUBQUERY(eventReferences, $reference, $reference.eventId = %@).@count > 0 AND deletedOn.@count = 0", + // swiftlint:enable line_length + noteID + ) + fetchRequest.predicate = noteIsLikedByUserPredicate + return fetchRequest + } + + @nonobjc public class func emptyRequest() -> NSFetchRequest { + let fetchRequest = NSFetchRequest(entityName: "Event") + fetchRequest.sortDescriptors = [NSSortDescriptor(keyPath: \Event.createdAt, ascending: true)] + fetchRequest.predicate = NSPredicate.false + return fetchRequest + } + + @nonobjc public class func deleteAllEvents() -> NSBatchDeleteRequest { + let fetchRequest: NSFetchRequest = NSFetchRequest(entityName: "Event") + let deleteRequest = NSBatchDeleteRequest(fetchRequest: fetchRequest) + return deleteRequest + } + + @nonobjc public class func deleteAllPosts(by author: Author) -> NSBatchDeleteRequest { + let fetchRequest: NSFetchRequest = NSFetchRequest(entityName: "Event") + let kind = EventKind.text.rawValue + let key = author.hexadecimalPublicKey ?? "notakey" + fetchRequest.predicate = NSPredicate(format: "kind = %i AND author.hexadecimalPublicKey = %@", kind, key) + let deleteRequest = NSBatchDeleteRequest(fetchRequest: fetchRequest) + return deleteRequest + } + + func reportsRequest() -> NSFetchRequest { + let request = NSFetchRequest(entityName: "Event") + request.predicate = NSPredicate( + format: "kind = %i AND ANY eventReferences.referencedEvent = %@ AND deletedOn.@count = 0", + EventKind.report.rawValue, + self + ) + request.sortDescriptors = [NSSortDescriptor(keyPath: \Event.identifier, ascending: true)] + return request + } +} diff --git a/Nos/Models/CoreData/Event+Hydration.swift b/Nos/Models/CoreData/Event+Hydration.swift new file mode 100644 index 000000000..d167cad25 --- /dev/null +++ b/Nos/Models/CoreData/Event+Hydration.swift @@ -0,0 +1,209 @@ +import CoreData +import Logger + +extension Event { + + /// Populates an event stub (with only its ID set) using the data in the given JSON. + func hydrate(from jsonEvent: JSONEvent, relay: Relay?, in context: NSManagedObjectContext) throws { + assert(isStub, "Tried to hydrate an event that isn't a stub. This is a programming error") + + // if this stub was created with a replaceableIdentifier and author, it won't have an identifier yet + identifier = jsonEvent.id + + // Meta data + createdAt = Date(timeIntervalSince1970: TimeInterval(jsonEvent.createdAt)) + if let createdAt, createdAt > .now { + self.createdAt = .now + } + content = jsonEvent.content + kind = jsonEvent.kind + signature = jsonEvent.signature + sendAttempts = 0 + + // Tags + allTags = jsonEvent.tags as NSObject + for tag in jsonEvent.tags { + if tag[safe: 0] == "expiration", + let expirationDateString = tag[safe: 1], + let expirationDateUnix = TimeInterval(expirationDateString), + expirationDateUnix != 0 { + let expirationDate = Date(timeIntervalSince1970: expirationDateUnix) + self.expirationDate = expirationDate + if isExpired { + throw EventError.expiredEvent + } + } else if tag[safe: 0] == "d", + let dTag = tag[safe: 1] { + replaceableIdentifier = dTag + } + } + + // Author + guard let newAuthor = try? Author.findOrCreate(by: jsonEvent.pubKey, context: context) else { + throw EventError.missingAuthor + } + + author = newAuthor + + // Relay + relay.unwrap { markSeen(on: $0) } + + guard let eventKind = EventKind(rawValue: kind) else { + throw EventError.unrecognizedKind + } + + switch eventKind { + case .contactList: + hydrateContactList(from: jsonEvent, author: newAuthor, context: context) + + case .metaData: + hydrateMetaData(from: jsonEvent, author: newAuthor, context: context) + + case .mute: + hydrateMuteList(from: jsonEvent, context: context) + case .repost: + + hydrateDefault(from: jsonEvent, context: context) + parseContent(from: jsonEvent, context: context) + + default: + hydrateDefault(from: jsonEvent, context: context) + } + } + + private func hydrateContactList( + from jsonEvent: JSONEvent, + author newAuthor: Author, + context: NSManagedObjectContext + ) { + guard createdAt! > newAuthor.lastUpdatedContactList ?? Date.distantPast else { + return + } + + newAuthor.lastUpdatedContactList = Date(timeIntervalSince1970: TimeInterval(jsonEvent.createdAt)) + + // Put existing follows into a dictionary so we can avoid doing a fetch request to look up each one. + var originalFollows = [RawAuthorID: Follow]() + for follow in newAuthor.follows { + if let pubKey = follow.destination?.hexadecimalPublicKey { + originalFollows[pubKey] = follow + } + } + + var newFollows = Set() + for jsonTag in jsonEvent.tags { + if let followedKey = jsonTag[safe: 1], + let existingFollow = originalFollows[followedKey] { + // We already have a Core Data Follow model for this user + newFollows.insert(existingFollow) + } else { + do { + newFollows.insert(try Follow.upsert(by: newAuthor, jsonTag: jsonTag, context: context)) + } catch { + Log.error("Error: could not parse Follow from: \(jsonEvent)") + } + } + } + + // Did we unfollow someone? If so, remove them from core data + let removedFollows = Set(originalFollows.values).subtracting(newFollows) + if !removedFollows.isEmpty { + Log.info("Removing \(removedFollows.count) follows") + Follow.deleteFollows(in: removedFollows, context: context) + } + + newAuthor.follows = newFollows + + // Get the user's active relays out of the content property + if let data = jsonEvent.content.data(using: .utf8, allowLossyConversion: false), + let relayEntries = try? JSONSerialization.jsonObject(with: data, options: .mutableContainers), + let relays = (relayEntries as? [String: Any])?.keys { + newAuthor.relays = Set() + + for address in relays { + if let relay = try? Relay.findOrCreate(by: address, context: context) { + newAuthor.add(relay: relay) + } + } + } + } + + private func hydrateDefault(from jsonEvent: JSONEvent, context: NSManagedObjectContext) { + let newEventReferences = NSMutableOrderedSet() + let newAuthorReferences = NSMutableOrderedSet() + for jsonTag in jsonEvent.tags { + if jsonTag.first == "e" { + // TODO: validate that the tag looks like an event ref + do { + let eTag = try EventReference(jsonTag: jsonTag, context: context) + newEventReferences.add(eTag) + } catch { + print("error parsing e tag: \(error.localizedDescription)") + } + } else if jsonTag.first == "p" { + // TODO: validate that the tag looks like a pubkey + let authorReference = AuthorReference(context: context) + authorReference.pubkey = jsonTag[safe: 1] + authorReference.recommendedRelayUrl = jsonTag[safe: 2] + newAuthorReferences.add(authorReference) + } + } + eventReferences = newEventReferences + authorReferences = newAuthorReferences + } + + private func hydrateMetaData(from jsonEvent: JSONEvent, author newAuthor: Author, context: NSManagedObjectContext) { + guard createdAt! > newAuthor.lastUpdatedMetadata ?? Date.distantPast else { + // This is old data + return + } + + if let contentData = jsonEvent.content.data(using: .utf8) { + newAuthor.lastUpdatedMetadata = Date(timeIntervalSince1970: TimeInterval(jsonEvent.createdAt)) + // There may be unsupported metadata. Store it to send back later in metadata publishes. + newAuthor.rawMetadata = contentData + + do { + let metadata = try JSONDecoder().decode(MetadataEventJSON.self, from: contentData) + + // Every event has an author created, so it just needs to be populated + newAuthor.name = metadata.name + newAuthor.displayName = metadata.displayName + newAuthor.about = metadata.about + newAuthor.profilePhotoURL = metadata.profilePhotoURL + newAuthor.website = metadata.website + newAuthor.nip05 = metadata.nip05 + newAuthor.uns = metadata.uns + } catch { + print("Failed to decode metaData event with ID \(String(describing: identifier))") + } + } + } + + private func hydrateMuteList(from jsonEvent: JSONEvent, context: NSManagedObjectContext) { + let mutedKeys = jsonEvent.tags.map { $0[1] } + + let request = Author.allAuthorsRequest(muted: true) + + // Un-Mute anyone (locally only) who is muted but not in the mutedKeys + if let authors = try? context.fetch(request) { + for author in authors where !mutedKeys.contains(author.hexadecimalPublicKey!) { + author.muted = false + print("Parse-Un-Muted \(author.hexadecimalPublicKey ?? "")") + } + } + + // Mute anyone (locally only) in the mutedKeys + for key in mutedKeys { + if let author = try? Author.find(by: key, context: context) { + author.muted = true + print("Parse-Muted \(author.hexadecimalPublicKey ?? "")") + } + } + + // Force ensure current user never was muted + Task { @MainActor in + currentUser.author?.muted = false + } + } +} diff --git a/Nos/Service/DatabaseCleaner.swift b/Nos/Service/DatabaseCleaner.swift index dfcaf26af..a695cb683 100644 --- a/Nos/Service/DatabaseCleaner.swift +++ b/Nos/Service/DatabaseCleaner.swift @@ -104,7 +104,7 @@ enum DatabaseCleaner { let events = try context.fetch(request) Log.info("Stubbing \(events.count) old Events that are still referenced by newer events") for event in events { - event.stub() + event.resetToStub() } }