diff --git a/CHANGELOG.md b/CHANGELOG.md index a1f7ccf58..fd1b7cc1f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,11 +9,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] - Discover screen can now search notes by id. -- +- Added pagination to Profile screens. + ## [0.1 (128)] - 2023-12-21Z - Fixed a crash when opening the note composer. -- Fix localization of warning message when a ntoe has been reported. (thanks @L!) +- Fix localization of warning message when a note has been reported. (thanks @L!) - Fixed contact list hydration bug where unfollows are not removed when follow counts do not change. ## [0.1 (101)] - 2023-12-15Z diff --git a/Nos.xcodeproj/project.pbxproj b/Nos.xcodeproj/project.pbxproj index c9bf956f2..32fa9d5c8 100644 --- a/Nos.xcodeproj/project.pbxproj +++ b/Nos.xcodeproj/project.pbxproj @@ -109,6 +109,8 @@ C942566929B66A2800C4202C /* Date+Elapsed.swift in Sources */ = {isa = PBXBuildFile; fileRef = C942566829B66A2800C4202C /* Date+Elapsed.swift */; }; C942566A29B66A2800C4202C /* Date+Elapsed.swift in Sources */ = {isa = PBXBuildFile; fileRef = C942566829B66A2800C4202C /* Date+Elapsed.swift */; }; C94437E629B0DB83004D8C86 /* NotificationsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C94437E529B0DB83004D8C86 /* NotificationsView.swift */; }; + C94824482B32364100005B36 /* WalletConnectModal in Frameworks */ = {isa = PBXBuildFile; productRef = C94824472B32364100005B36 /* WalletConnectModal */; }; + C948244A2B32364900005B36 /* Web3 in Frameworks */ = {isa = PBXBuildFile; productRef = C94824492B32364900005B36 /* Web3 */; }; C94A5E152A716A6D00B6EC5D /* EditableNoteText.swift in Sources */ = {isa = PBXBuildFile; fileRef = C94A5E142A716A6D00B6EC5D /* EditableNoteText.swift */; }; C94A5E162A716A6D00B6EC5D /* EditableNoteText.swift in Sources */ = {isa = PBXBuildFile; fileRef = C94A5E142A716A6D00B6EC5D /* EditableNoteText.swift */; }; C94A5E182A72C84200B6EC5D /* ReportCategory.swift in Sources */ = {isa = PBXBuildFile; fileRef = C94A5E172A72C84200B6EC5D /* ReportCategory.swift */; }; @@ -182,6 +184,7 @@ C981E2DD2AC610D600FBF4F6 /* UNSStepImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = C981E2DC2AC610D600FBF4F6 /* UNSStepImage.swift */; }; C98298332ADD7F9A0096C5B5 /* DeepLinkService.swift in Sources */ = {isa = PBXBuildFile; fileRef = C98298322ADD7F9A0096C5B5 /* DeepLinkService.swift */; }; C98298342ADD7F9A0096C5B5 /* DeepLinkService.swift in Sources */ = {isa = PBXBuildFile; fileRef = C98298322ADD7F9A0096C5B5 /* DeepLinkService.swift */; }; + C98651102B0BD49200597B68 /* PagedNoteListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C986510F2B0BD49200597B68 /* PagedNoteListView.swift */; }; C987F81729BA4C6A00B44E7A /* BigActionButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = C987F81629BA4C6900B44E7A /* BigActionButton.swift */; }; C987F81A29BA4D0E00B44E7A /* ActionButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = C987F81929BA4D0E00B44E7A /* ActionButton.swift */; }; C987F81D29BA6D9A00B44E7A /* ProfileTab.swift in Sources */ = {isa = PBXBuildFile; fileRef = C987F81C29BA6D9A00B44E7A /* ProfileTab.swift */; }; @@ -227,7 +230,12 @@ C98A32272A05795E00E3FA13 /* Task+Timeout.swift in Sources */ = {isa = PBXBuildFile; fileRef = C98A32262A05795E00E3FA13 /* Task+Timeout.swift */; }; C98A32282A05795E00E3FA13 /* Task+Timeout.swift in Sources */ = {isa = PBXBuildFile; fileRef = C98A32262A05795E00E3FA13 /* Task+Timeout.swift */; }; C98B8B4029FBF83B009789C8 /* NotificationCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = C98B8B3F29FBF83B009789C8 /* NotificationCard.swift */; }; + C98CA9042B14FA3D00929141 /* PagedRelaySubscription.swift in Sources */ = {isa = PBXBuildFile; fileRef = C98CA9032B14FA3D00929141 /* PagedRelaySubscription.swift */; }; + C98CA9072B14FBBF00929141 /* PagedNoteDataSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = C98CA9062B14FBBF00929141 /* PagedNoteDataSource.swift */; }; + C98CA9082B14FD8600929141 /* PagedRelaySubscription.swift in Sources */ = {isa = PBXBuildFile; fileRef = C98CA9032B14FA3D00929141 /* PagedRelaySubscription.swift */; }; C98DC9BB2A795CAD004E5F0F /* ActionBanner.swift in Sources */ = {isa = PBXBuildFile; fileRef = C98DC9BA2A795CAD004E5F0F /* ActionBanner.swift */; }; + C992B32A2B3613CC00704A9C /* SubscriptionCancellable.swift in Sources */ = {isa = PBXBuildFile; fileRef = C992B3292B3613CC00704A9C /* SubscriptionCancellable.swift */; }; + C992B32B2B3613CC00704A9C /* SubscriptionCancellable.swift in Sources */ = {isa = PBXBuildFile; fileRef = C992B3292B3613CC00704A9C /* SubscriptionCancellable.swift */; }; C99721CB2AEBED26004EBEAB /* String+Empty.swift in Sources */ = {isa = PBXBuildFile; fileRef = C99721CA2AEBED26004EBEAB /* String+Empty.swift */; }; C99721CC2AEBED26004EBEAB /* String+Empty.swift in Sources */ = {isa = PBXBuildFile; fileRef = C99721CA2AEBED26004EBEAB /* String+Empty.swift */; }; C99D053C2AE6E5F50053D472 /* WalletConnectSendView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C99D053B2AE6E5F50053D472 /* WalletConnectSendView.swift */; }; @@ -584,6 +592,7 @@ C981E2DC2AC610D600FBF4F6 /* UNSStepImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UNSStepImage.swift; sourceTree = ""; }; C98298312ADD7EDB0096C5B5 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; C98298322ADD7F9A0096C5B5 /* DeepLinkService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeepLinkService.swift; sourceTree = ""; }; + C986510F2B0BD49200597B68 /* PagedNoteListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PagedNoteListView.swift; sourceTree = ""; }; C987F81629BA4C6900B44E7A /* BigActionButton.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BigActionButton.swift; sourceTree = ""; }; C987F81929BA4D0E00B44E7A /* ActionButton.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ActionButton.swift; sourceTree = ""; }; C987F81C29BA6D9A00B44E7A /* ProfileTab.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileTab.swift; sourceTree = ""; }; @@ -610,7 +619,10 @@ C987F86029BABAF800B44E7A /* String+Markdown.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+Markdown.swift"; sourceTree = ""; }; C98A32262A05795E00E3FA13 /* Task+Timeout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Task+Timeout.swift"; sourceTree = ""; }; C98B8B3F29FBF83B009789C8 /* NotificationCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationCard.swift; sourceTree = ""; }; + C98CA9032B14FA3D00929141 /* PagedRelaySubscription.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PagedRelaySubscription.swift; sourceTree = ""; }; + C98CA9062B14FBBF00929141 /* PagedNoteDataSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PagedNoteDataSource.swift; sourceTree = ""; }; C98DC9BA2A795CAD004E5F0F /* ActionBanner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActionBanner.swift; sourceTree = ""; }; + C992B3292B3613CC00704A9C /* SubscriptionCancellable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SubscriptionCancellable.swift; sourceTree = ""; }; C99507332AB9EE40005B1096 /* Nos 12.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "Nos 12.xcdatamodel"; sourceTree = ""; }; C99721CA2AEBED26004EBEAB /* String+Empty.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+Empty.swift"; sourceTree = ""; }; C99D053B2AE6E5F50053D472 /* WalletConnectSendView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WalletConnectSendView.swift; sourceTree = ""; }; @@ -781,7 +793,9 @@ C905B0752A619367009B8A78 /* DequeModule in Frameworks */, C91565C12B2368FA0068EECA /* ViewInspector in Frameworks */, C9646E9C29B79E4D007239A4 /* Logger in Frameworks */, + C948244A2B32364900005B36 /* Web3 in Frameworks */, CDDA1F7D29A527650047ACD8 /* SwiftUINavigation in Frameworks */, + C94824482B32364100005B36 /* WalletConnectModal in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -882,12 +896,9 @@ C98298322ADD7F9A0096C5B5 /* DeepLinkService.swift */, C9B678DA29EEBF3B00303F33 /* DependencyInjection.swift */, C9BAB09A2996FBA10003A84E /* EventProcessor.swift */, - A336DD3B299FD78000A0CBA0 /* Filter.swift */, A3B943CE299AE00100A15A08 /* KeyChain.swift */, C9F64D8B29ED840700563F2B /* LogHelper.swift */, C936B4612A4CB01C00DF1EB9 /* PushNotificationService.swift */, - C97797B8298AA19A0046BD25 /* RelayService.swift */, - C9C2B78129E0735400548B4A /* RelaySubscriptionManager.swift */, 5B8805192A21027C00E21F06 /* SHA256Key.swift */, C9B678E029EEC41000303F33 /* SocialGraphCache.swift */, 5B8B77182A1FDA3C004FC675 /* TLV.swift */, @@ -895,6 +906,7 @@ C9AA1BB02ABA383F00E8BD6D /* USBCWalletConnectService.swift */, C9F204682ADEDC4E0029A858 /* WalletConnectCryptoProvider.swift */, C9F2045E2ADED8F70029A858 /* WalletConnectSessionManager.swift */, + C98CA9052B14FA8500929141 /* Relay */, ); path = Service; sourceTree = ""; @@ -931,6 +943,17 @@ path = Font; sourceTree = ""; }; + C98CA9052B14FA8500929141 /* Relay */ = { + isa = PBXGroup; + children = ( + A336DD3B299FD78000A0CBA0 /* Filter.swift */, + C98CA9032B14FA3D00929141 /* PagedRelaySubscription.swift */, + C97797B8298AA19A0046BD25 /* RelayService.swift */, + C9C2B78129E0735400548B4A /* RelaySubscriptionManager.swift */, + ); + path = Relay; + sourceTree = ""; + }; C9C9443A29F6E420002F2C7A /* Test Helpers */ = { isa = PBXGroup; children = ( @@ -1120,6 +1143,7 @@ C9C2B78429E073E300548B4A /* RelaySubscription.swift */, C9E37E142A1E8143003D4B0A /* Report.swift */, C94A5E172A72C84200B6EC5D /* ReportCategory.swift */, + C992B3292B3613CC00704A9C /* SubscriptionCancellable.swift */, C97141ED2AE95F07001A5DD0 /* USBCAddress.swift */, C9F204772ADEDE120029A858 /* WalletConnectChain.swift */, C9F204602ADEDB720029A858 /* Wei.swift */, @@ -1165,6 +1189,7 @@ C9A6C7402AD837AD001F9500 /* UNSWizardController.swift */, C9A2FCA32AE701510020A5C6 /* SendUSBCController.swift */, C913DA092AEAF52B003BDD6D /* NoteWarningController.swift */, + C98CA9062B14FBBF00929141 /* PagedNoteDataSource.swift */, ); path = Controller; sourceTree = ""; @@ -1250,6 +1275,7 @@ 5B834F682A83FC7F000C1432 /* ProfileSocialStatsView.swift */, C987F81C29BA6D9A00B44E7A /* ProfileTab.swift */, C95D68AC299E721700429F86 /* ProfileView.swift */, + C986510F2B0BD49200597B68 /* PagedNoteListView.swift */, 5BFF66B32A58853D00AA79DD /* PublishedEventsView.swift */, C97A1C8729E45B3C009D9E8D /* RawEventView.swift */, C9A25B3C29F174D200B39534 /* ReadabilityPadding.swift */, @@ -1380,6 +1406,8 @@ C9B71DC42A9008300031ED9F /* Sentry */, C99DBF7F2A9E8BCF00F7068F /* SDWebImageSwiftUI */, C91565C02B2368FA0068EECA /* ViewInspector */, + C94824472B32364100005B36 /* WalletConnectModal */, + C94824492B32364900005B36 /* Web3 */, ); productName = NosTests; productReference = C9DEBFE4298941020078B43A /* NosTests.xctest */; @@ -1634,6 +1662,7 @@ CD09A74429A50F1D0063464F /* SideMenu.swift in Sources */, C9F2046B2ADEDCB80029A858 /* WalletConnectPairingView.swift in Sources */, C9C547512A4F1CC3006B0741 /* SearchController.swift in Sources */, + C992B32A2B3613CC00704A9C /* SubscriptionCancellable.swift in Sources */, C9DEC06E2989668E0078B43A /* Relay+CoreDataClass.swift in Sources */, C9F64D8C29ED840700563F2B /* LogHelper.swift in Sources */, C9C2B78529E073E300548B4A /* RelaySubscription.swift in Sources */, @@ -1743,6 +1772,7 @@ C9680AD42ACDF57D006C8C93 /* UNSWizardNeedsPaymentView.swift in Sources */, C94C4CF32AD993CA00F801CA /* UNSErrorView.swift in Sources */, C9A2FCA42AE701510020A5C6 /* SendUSBCController.swift in Sources */, + C98CA9042B14FA3D00929141 /* PagedRelaySubscription.swift in Sources */, 5B0D99032A94090A0039F0C5 /* DoubleTapToPopModifier.swift in Sources */, 5BFF66B42A58853D00AA79DD /* PublishedEventsView.swift in Sources */, C987F85B29BA9ED800B44E7A /* Font.swift in Sources */, @@ -1813,6 +1843,7 @@ 5BE281C72AE2CCD800880466 /* ReplyButton.swift in Sources */, C936B45C2A4C7D6B00DF1EB9 /* UNSWizard.swift in Sources */, C9DFA966299BEB96006929C1 /* NoteCard.swift in Sources */, + C98651102B0BD49200597B68 /* PagedNoteListView.swift in Sources */, C9E37E152A1E8143003D4B0A /* Report.swift in Sources */, C9DEBFD4298941000078B43A /* Persistence.swift in Sources */, CD76864C29B12F7E00085358 /* ExpandingTextFieldAndSubmitButton.swift in Sources */, @@ -1842,6 +1873,7 @@ CD2CF38E299E67F900332116 /* CardButtonStyle.swift in Sources */, A336DD3C299FD78000A0CBA0 /* Filter.swift in Sources */, DC2E54C82A700F1400C2CAAB /* UIDevice+Simulator.swift in Sources */, + C98CA9072B14FBBF00929141 /* PagedNoteDataSource.swift in Sources */, C9A0DAEA29C6A34200466635 /* ActivityView.swift in Sources */, CD2CF390299E68BE00332116 /* NoteButton.swift in Sources */, C93F48932AC5C9CE00900CEC /* UNSWizardIntroView.swift in Sources */, @@ -1887,6 +1919,7 @@ C94A5E162A716A6D00B6EC5D /* EditableNoteText.swift in Sources */, 5BD08BB22A38E96F00BB926C /* JSONRelayMetadata.swift in Sources */, C936B45A2A4C7B7C00DF1EB9 /* Nos.xcdatamodeld in Sources */, + C98CA9082B14FD8600929141 /* PagedRelaySubscription.swift in Sources */, C9F204812AE02D8C0029A858 /* AppDestination.swift in Sources */, C9E37E162A1E8143003D4B0A /* Report.swift in Sources */, C987F86229BABAF800B44E7A /* String+Markdown.swift in Sources */, @@ -1945,6 +1978,7 @@ C9ADB14229951CB10075E7F8 /* NSManagedObject+Nos.swift in Sources */, C92DF80629C25DE900400561 /* URL+Extensions.swift in Sources */, C9BAB09C2996FBA10003A84E /* EventProcessor.swift in Sources */, + C992B32B2B3613CC00704A9C /* SubscriptionCancellable.swift in Sources */, C9DFA97B299C31EE006929C1 /* Localized.swift in Sources */, C973AB5C2A323167002AED16 /* Follow+CoreDataProperties.swift in Sources */, C93005602A6AF8320098CA9E /* LoadingContent.swift in Sources */, @@ -2579,6 +2613,16 @@ isa = XCSwiftPackageProductDependency; productName = StarscreamOld; }; + C94824472B32364100005B36 /* WalletConnectModal */ = { + isa = XCSwiftPackageProductDependency; + package = C95F0AC82ABA379700A0D9CE /* XCRemoteSwiftPackageReference "WalletConnectSwiftV2" */; + productName = WalletConnectModal; + }; + C94824492B32364900005B36 /* Web3 */ = { + isa = XCSwiftPackageProductDependency; + package = C9AA1BB82ABB62EB00E8BD6D /* XCRemoteSwiftPackageReference "Web3" */; + productName = Web3; + }; C94D855E29914D2300749478 /* SwiftUINavigation */ = { isa = XCSwiftPackageProductDependency; package = C94D855D29914D2300749478 /* XCRemoteSwiftPackageReference "swiftui-navigation" */; diff --git a/Nos/Controller/PagedNoteDataSource.swift b/Nos/Controller/PagedNoteDataSource.swift new file mode 100644 index 000000000..a5e1d89fb --- /dev/null +++ b/Nos/Controller/PagedNoteDataSource.swift @@ -0,0 +1,223 @@ +// +// PagedNoteDataSource.swift +// Nos +// +// Created by Matthew Lorentz on 11/27/23. +// + +import SwiftUI +import CoreData +import Dependencies +import Logger + +/// Works with PagesNoteListView to paginate a reverse-chronological events from CoreData and relays simultaneously. +class PagedNoteDataSource: NSObject, UICollectionViewDataSource, + NSFetchedResultsControllerDelegate, UICollectionViewDataSourcePrefetching { + + var fetchedResultsController: NSFetchedResultsController + var collectionView: UICollectionView + + @Dependency(\.relayService) private var relayService: RelayService + private var relayFilter: Filter + private var pager: PagedRelaySubscription? + private var context: NSManagedObjectContext + private var header: () -> Header + private var emptyPlaceholder: () -> EmptyPlaceholder + let pageSize = 10 + + private var cellReuseID = "Cell" + private var headerReuseID = "Header" + private var footerReuseID = "Footer" + + init( + databaseFilter: NSFetchRequest, + relayFilter: Filter, + collectionView: UICollectionView, + context: NSManagedObjectContext, + @ViewBuilder header: @escaping () -> Header, + @ViewBuilder emptyPlaceholder: @escaping () -> EmptyPlaceholder + ) { + self.fetchedResultsController = NSFetchedResultsController( + fetchRequest: databaseFilter, + managedObjectContext: context, + sectionNameKeyPath: nil, + cacheName: nil + ) + self.collectionView = collectionView + self.context = context + self.relayFilter = relayFilter + self.header = header + self.emptyPlaceholder = emptyPlaceholder + + collectionView.register(UICollectionViewCell.self, forCellWithReuseIdentifier: cellReuseID) + collectionView.register( + UICollectionViewCell.self, + forSupplementaryViewOfKind: UICollectionView.elementKindSectionHeader, + withReuseIdentifier: headerReuseID + ) + collectionView.register( + UICollectionViewCell.self, + forSupplementaryViewOfKind: UICollectionView.elementKindSectionFooter, + withReuseIdentifier: footerReuseID + ) + + super.init() + + self.fetchedResultsController.delegate = self + + do { + try self.fetchedResultsController.performFetch() + } catch { + @Dependency(\.crashReporting) var crashReporter + crashReporter.report(error) + Log.error(error) + } + + Task { + var limitedFilter = relayFilter + limitedFilter.limit = pageSize + self.pager = await relayService.subscribeToPagedEvents(matching: limitedFilter) + } + } + + func updateFetchRequest(_ fetchRequest: NSFetchRequest) { + self.fetchedResultsController = NSFetchedResultsController( + fetchRequest: fetchRequest, + managedObjectContext: context, + sectionNameKeyPath: nil, + cacheName: nil + ) + self.fetchedResultsController.delegate = self + try? self.fetchedResultsController.performFetch() + } + + // MARK: - UICollectionViewDataSource + + func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { + fetchedResultsController.fetchedObjects?.count ?? 0 + } + + func collectionView( + _ collectionView: UICollectionView, + cellForItemAt indexPath: IndexPath + ) -> UICollectionViewCell { + if indexPath.row.isMultiple(of: pageSize) { + pager?.loadMore() + } + + let note = fetchedResultsController.object(at: indexPath) + let cell = collectionView.dequeueReusableCell(withReuseIdentifier: cellReuseID, for: indexPath) + cell.contentConfiguration = UIHostingConfiguration { + NoteButton(note: note, hideOutOfNetwork: false, displayRootMessage: true) + } + .margins(.horizontal, 0) + + return cell + } + + func collectionView(_ collectionView: UICollectionView, prefetchItemsAt indexPaths: [IndexPath]) { + for indexPath in indexPaths { + let note = fetchedResultsController.object(at: indexPath) + Task { await note.loadViewData() } + } + } + + func collectionView( + _ collectionView: UICollectionView, + viewForSupplementaryElementOfKind kind: String, + at indexPath: IndexPath + ) -> UICollectionReusableView { + switch kind { + case UICollectionView.elementKindSectionHeader: + guard let header = collectionView.dequeueReusableSupplementaryView( + ofKind: kind, + withReuseIdentifier: headerReuseID, + for: indexPath + ) as? UICollectionViewCell else { + return UICollectionViewCell() + } + + header.contentConfiguration = UIHostingConfiguration { + self.header() + } + .margins(.horizontal, 0) + .margins(.top, 0) + + return header + + case UICollectionView.elementKindSectionFooter: + guard let footer = collectionView.dequeueReusableSupplementaryView( + ofKind: kind, + withReuseIdentifier: footerReuseID, + for: indexPath + ) as? UICollectionViewCell else { + return UICollectionViewCell() + } + + footer.contentConfiguration = UIHostingConfiguration { + if self.fetchedResultsController.fetchedObjects?.isEmpty == true { + self.emptyPlaceholder() + } + } + .margins(.horizontal, 0) + .margins(.top, 0) + return footer + default: + return UICollectionViewCell() + } + } + + // MARK: - NSFetchedResultsControllerDelegate + + private var insertedIndexes = [IndexPath]() + private var deletedIndexes = [IndexPath]() + private var movedIndexes = [(IndexPath, IndexPath)]() + + func controllerWillChangeContent(_ controller: NSFetchedResultsController) { + insertedIndexes = [IndexPath]() + deletedIndexes = [IndexPath]() + movedIndexes = [(IndexPath, IndexPath)]() + } + + func controller( + _ controller: NSFetchedResultsController, + didChange anObject: Any, + at indexPath: IndexPath?, + for type: NSFetchedResultsChangeType, + newIndexPath: IndexPath? + ) { + // Note: I tried using UICollectionViewDiffableDatasource but it didn't seem to work well with SwiftUI views + // as it kept reloading cells with animations when nothing was visually changing. + switch type { + case .insert: + if let newIndexPath = newIndexPath { + insertedIndexes.append(newIndexPath) + } + case .delete: + if let indexPath = indexPath { + deletedIndexes.append(indexPath) + } + case .update: + // The SwiftUI cells are observing their source Core Data objects already so we don't need to notify + // them of updates through the collectionView. + return + case .move: + if let oldIndexPath = indexPath, let newIndexPath = newIndexPath { + movedIndexes.append((oldIndexPath, newIndexPath)) + } + @unknown default: + fatalError("Unexpected NSFetchedResultsChangeType: \(type)") + } + } + + func controllerDidChangeContent(_ controller: NSFetchedResultsController) { + collectionView.performBatchUpdates { + collectionView.deleteItems(at: deletedIndexes) + collectionView.insertItems(at: insertedIndexes) + movedIndexes.forEach { indexPair in + let (oldIndex, newIndex) = indexPair + collectionView.moveItem(at: oldIndex, to: newIndex) + } + } + } +} diff --git a/Nos/Controller/SearchController.swift b/Nos/Controller/SearchController.swift index 14c8684c2..a852927b5 100644 --- a/Nos/Controller/SearchController.swift +++ b/Nos/Controller/SearchController.swift @@ -25,7 +25,7 @@ class SearchController: ObservableObject { @Dependency(\.persistenceController) private var persistenceController @Dependency(\.unsAPI) var unsAPI private var cancellables = [AnyCancellable]() - private var searchSubscriptions = [RelaySubscription.ID]() + private var searchSubscriptions = SubscriptionCancellables() private lazy var context: NSManagedObjectContext = { persistenceController.viewContext }() @@ -47,8 +47,7 @@ class SearchController: ObservableObject { // These functions search other systems for the given query and add relevant authors to the database. // The database then generates a notification which is listened to above and resulst are reloaded. Task { - await self.relayService.decrementSubscriptionCount(for: self.searchSubscriptions) - self.searchSubscriptions = [] + self.searchSubscriptions.removeAll() self.searchRelays(for: query) self.searchUNS(for: query) } @@ -71,7 +70,7 @@ class SearchController: ObservableObject { func searchRelays(for query: String) { Task { let searchFilter = Filter(kinds: [.metaData], search: query, limit: 100) - self.searchSubscriptions.append(await self.relayService.openSubscription(with: searchFilter)) + self.searchSubscriptions.append(await self.relayService.subscribeToEvents(matching: searchFilter)) } } @@ -89,9 +88,7 @@ class SearchController: ObservableObject { try self.context.saveIfNeeded() for pubKey in pubKeys { try Task.checkCancellation() - if let subscriptionID = await relayService.requestMetadata(for: pubKey, since: nil) { - searchSubscriptions.append(subscriptionID) - } + searchSubscriptions.append(await relayService.requestMetadata(for: pubKey, since: nil)) } } catch { Log.optional(error) diff --git a/Nos/Extensions/WebSocket+Nos.swift b/Nos/Extensions/WebSocket+Nos.swift index 6640b3745..91851638c 100644 --- a/Nos/Extensions/WebSocket+Nos.swift +++ b/Nos/Extensions/WebSocket+Nos.swift @@ -5,16 +5,25 @@ // Created by Matthew Lorentz on 4/7/23. // +import Foundation import Starscream extension WebSocket { var host: String { self.request.url?.host ?? "unknown relay" } + + var url: URL? { + self.request.url + } } extension WebSocketClient { var host: String { (self as? WebSocket)?.host ?? "unkown relay" } + + var url: URL? { + (self as? WebSocket)?.url + } } diff --git a/Nos/Models/Author+CoreDataClass.swift b/Nos/Models/Author+CoreDataClass.swift index 437391258..0894068e2 100644 --- a/Nos/Models/Author+CoreDataClass.swift +++ b/Nos/Models/Author+CoreDataClass.swift @@ -151,15 +151,17 @@ enum AuthorError: Error { return fetchRequest } - @nonobjc func allPostsRequest() -> NSFetchRequest { + @nonobjc func allPostsRequest(before: Date = .now) -> NSFetchRequest { let fetchRequest = NSFetchRequest(entityName: "Event") fetchRequest.sortDescriptors = [NSSortDescriptor(keyPath: \Event.createdAt, ascending: false)] fetchRequest.predicate = NSPredicate( - format: "(kind = %i OR kind = %i OR kind = %i) AND author = %@", + format: "(kind = %i OR kind = %i OR kind = %i) AND author = %@ AND author.muted = 0 AND " + + "deletedOn.@count = 0 AND createdAt <= %@", EventKind.text.rawValue, EventKind.repost.rawValue, EventKind.longFormContent.rawValue, - self + self, + before as CVarArg ) return fetchRequest } diff --git a/Nos/Models/Event+CoreDataClass.swift b/Nos/Models/Event+CoreDataClass.swift index 27845973c..a11a05e91 100644 --- a/Nos/Models/Event+CoreDataClass.swift +++ b/Nos/Models/Event+CoreDataClass.swift @@ -73,10 +73,11 @@ extension FetchedResults where Element == Event { // swiftlint:disable type_body_length @objc(Event) +@Observable public class Event: NosManagedObject { - @Dependency(\.currentUser) private var currentUser - @Dependency(\.persistenceController) private var persistenceController + @Dependency(\.currentUser) @ObservationIgnored private var currentUser + @Dependency(\.persistenceController) @ObservationIgnored private var persistenceController static var replyNoteReferences = "kind = 1 AND ANY eventReferences.referencedEvent.identifier == %@ " + "AND author.muted = false" @@ -788,7 +789,7 @@ public class Event: NosManagedObject { currentUser.author?.muted = false } } - + /// Tries to parse a new event out of the given jsonEvent's `content` field. @discardableResult func parseContent(from jsonEvent: JSONEvent, context: NSManagedObjectContext) -> Event? { @@ -805,6 +806,77 @@ public class Event: NosManagedObject { return nil } + // MARK: - Preloading and Caching + // Probably should refactor this stuff into a view model + + @MainActor var loadingViewData = false + @MainActor var attributedContent = LoadingContent.loading + @MainActor var contentLinks = [URL]() + @MainActor var relaySubscriptions = SubscriptionCancellables() + + /// Instructs this event to load supplementary data like author name and photo, reference events, and produce + /// formatted `content` and cache it on this object. Idempotent. + @MainActor func loadViewData() async { + guard !loadingViewData else { + return + } + loadingViewData = true + Log.debug("\(identifier ?? "null") loading view data") + + if isStub { + await loadContent() + loadingViewData = false + } else { + Task { await loadReferencedNote() } + Task { await loadAuthorMetadata() } + Task { await loadAttributedContent() } + } + } + + /// Tries to download this event from relays. + @MainActor private func loadContent() async { + @Dependency(\.relayService) var relayService + relaySubscriptions.append(await relayService.requestEvent(with: identifier)) + } + + /// Requests any missing metadata for authors referenced by this note from relays. + @MainActor private func loadAuthorMetadata() async { + @Dependency(\.relayService) var relayService + @Dependency(\.persistenceController) var persistenceController + let backgroundContext = persistenceController.backgroundViewContext + relaySubscriptions.append(await Event.requestAuthorsMetadataIfNeeded( + noteID: identifier, + using: relayService, + in: backgroundContext + )) + } + + /// Tries to load the note this note is reposting or replying to from relays. + @MainActor private func loadReferencedNote() async { + if let referencedNote = referencedNote() { + await referencedNote.loadViewData() + } else { + await rootNote()?.loadViewData() + } + } + + /// Processes the note `content` to populate mentions and extract links. The results are saved in + /// `attributedContent` and `contentLinks`. + @MainActor func loadAttributedContent() async { + @Dependency(\.persistenceController) var persistenceController + let backgroundContext = persistenceController.backgroundViewContext + if let parsedAttributedContent = await Event.attributedContentAndURLs( + note: self, + context: backgroundContext + ) { + let (attributedString, contentLinks) = parsedAttributedContent + self.attributedContent = .loaded(attributedString) + self.contentLinks = contentLinks + } else { + self.attributedContent = .loaded(AttributedString(content ?? "")) + } + } + // MARK: - Helpers var serializedEventForSigning: [Any?] { @@ -1088,9 +1160,9 @@ public class Event: NosManagedObject { noteID: String?, using relayService: RelayService, in context: NSManagedObjectContext - ) async -> [RelaySubscription.ID] { + ) async -> SubscriptionCancellable { guard let noteID else { - return [] + return SubscriptionCancellable(subscriptionIDs: [], relayService: relayService) } let requestData: [(HexadecimalString?, Date?)] = await context.perform { @@ -1123,13 +1195,14 @@ public class Event: NosManagedObject { return requestData } - var subscriptionIDs = [RelaySubscription.ID]() + var cancellables = [SubscriptionCancellable]() for requestDatum in requestData { let authorKey = requestDatum.0 let sinceDate = requestDatum.1 - await relayService.requestMetadata(for: authorKey, since: sinceDate).unwrap { subscriptionIDs.append($0) } + cancellables.append(await relayService.requestMetadata(for: authorKey, since: sinceDate)) } - return subscriptionIDs + + return SubscriptionCancellable(cancellables: cancellables, relayService: relayService) } var webLink: String { diff --git a/Nos/Models/JSONEvent.swift b/Nos/Models/JSONEvent.swift index 3c7d5ed62..d03b6900c 100644 --- a/Nos/Models/JSONEvent.swift +++ b/Nos/Models/JSONEvent.swift @@ -100,6 +100,10 @@ struct JSONEvent: Codable, Hashable { ] } + var createdDate: Date { + Date(timeIntervalSince1970: TimeInterval(createdAt)) + } + /// Formats this event as a string that can be sent to a relay over a websocket to publish this event. func buildPublishRequest() throws -> String { let request: [Any] = ["EVENT", dictionary] diff --git a/Nos/Models/LoadingContent.swift b/Nos/Models/LoadingContent.swift index d158f9e24..e3c2b93fb 100644 --- a/Nos/Models/LoadingContent.swift +++ b/Nos/Models/LoadingContent.swift @@ -8,6 +8,6 @@ import Foundation -enum LoadingContent { +enum LoadingContent: Equatable { case loading, loaded(Content) } diff --git a/Nos/Models/ParseQueue.swift b/Nos/Models/ParseQueue.swift index bd42fe769..04f2b5e97 100644 --- a/Nos/Models/ParseQueue.swift +++ b/Nos/Models/ParseQueue.swift @@ -22,4 +22,8 @@ actor ParseQueue { events.removeFirst(min(events.count, count)) return poppedEvents } + + var count: Int { + events.count + } } diff --git a/Nos/Models/RelaySubscription.swift b/Nos/Models/RelaySubscription.swift index 5c15d29fb..2106a6a4d 100644 --- a/Nos/Models/RelaySubscription.swift +++ b/Nos/Models/RelaySubscription.swift @@ -6,28 +6,31 @@ // import Foundation +import CryptoSwift /// Models a request to a relay for Nostr Events. struct RelaySubscription: Identifiable { + var id: String + let filter: Filter + /// The relay this Filter should be sent to. + let relayAddress: URL + /// The date this Filter was opened as a subscription on relays. Used to close stale subscriptions var subscriptionStartDate: Date? + /// The oldest creation date on an event processed by this filter. Used for pagination. + var oldestEventCreationDate: Date? + + /// The number of events that have been returned for this subscription + var receivedEventCount = 0 + /// The number of objects using this filter. This is incremented and decremented by the RelayService to determine /// when a filter can be closed. var referenceCount: Int = 0 - var id: String { - subscriptionID - } - - // For closing requests; not part of hash - var subscriptionID: String { - filter.id - } - var isActive: Bool { subscriptionStartDate != nil } @@ -36,4 +39,19 @@ struct RelaySubscription: Identifiable { var isOneTime: Bool { filter.limit == 1 } + + internal init( + filter: Filter, + relayAddress: URL, + subscriptionStartDate: Date? = nil, + oldestEventCreationDate: Date? = nil, + referenceCount: Int = 0 + ) { + self.filter = filter + self.relayAddress = relayAddress + self.id = (filter.id + "-" + relayAddress.absoluteString).sha256() + self.subscriptionStartDate = subscriptionStartDate + self.oldestEventCreationDate = oldestEventCreationDate + self.referenceCount = referenceCount + } } diff --git a/Nos/Models/SubscriptionCancellable.swift b/Nos/Models/SubscriptionCancellable.swift new file mode 100644 index 000000000..096ea259b --- /dev/null +++ b/Nos/Models/SubscriptionCancellable.swift @@ -0,0 +1,43 @@ +// +// SubscriptionCancellable.swift +// Nos +// +// Created by Matthew Lorentz on 12/22/23. +// + +import Foundation + +/// A handle that holds references to one or more `RelaySubscription`s and provides the ability to cancel these +/// subscriptions. Will auto-cancel them when it is deallocated. Modeled after Combine's `Cancellable`. +class SubscriptionCancellable { + private var subscriptionIDs: [RelaySubscription.ID] + private weak var relayService: RelayService? + + init(subscriptionIDs: [RelaySubscription.ID], relayService: RelayService) { + self.subscriptionIDs = subscriptionIDs + self.relayService = relayService + } + + init(cancellables: [SubscriptionCancellable], relayService: RelayService) { + self.subscriptionIDs = cancellables.flatMap { $0.subscriptionIDs } + self.relayService = relayService + } + + private init() { + self.subscriptionIDs = [] + } + + deinit { + cancel() + } + + static func empty() -> SubscriptionCancellable { + SubscriptionCancellable() + } + + func cancel() { + relayService?.decrementSubscriptionCount(for: subscriptionIDs) + } +} + +typealias SubscriptionCancellables = [SubscriptionCancellable] diff --git a/Nos/Service/CurrentUser.swift b/Nos/Service/CurrentUser.swift index 796fa3d17..5fc57581e 100644 --- a/Nos/Service/CurrentUser.swift +++ b/Nos/Service/CurrentUser.swift @@ -87,7 +87,7 @@ enum CurrentUserError: Error { var socialGraph: SocialGraphCache! // swiftlint:enable implicitly_unwrapped_optional - var subscriptions: [String] = [] + var subscriptions = SubscriptionCancellables() var editing = false @@ -122,7 +122,6 @@ enum CurrentUserError: Error { // Reset CurrentUser state @MainActor func reset() { onboardingRelays = [] - Task { await relayService.decrementSubscriptionCount(for: subscriptions) } subscriptions = [] setUp() } @@ -187,15 +186,12 @@ enum CurrentUserError: Error { } // Close out stale requests - if !subscriptions.isEmpty { - await relayService.decrementSubscriptionCount(for: subscriptions) - subscriptions.removeAll() - } + subscriptions.removeAll() // Subscribe to our own events of all kinds. let latestRecievedEvent = try? viewContext.fetch(Event.lastReceived(for: author)).first let allEventsFilter = Filter(authorKeys: [key], since: latestRecievedEvent?.receivedAt) - subscriptions.append(await relayService.openSubscription(with: allEventsFilter)) + subscriptions.append(await relayService.subscribeToEvents(matching: allEventsFilter)) // Always make a one time request for the latest contact list let contactFilter = Filter( @@ -204,7 +200,7 @@ enum CurrentUserError: Error { limit: 2, // small hack to make sure this filter doesn't get closed for being stale since: author.lastUpdatedContactList ) - subscriptions.append(await relayService.openSubscription(with: contactFilter)) + subscriptions.append(await relayService.subscribeToEvents(matching: contactFilter)) // Listen for notifications await pushNotificationService.listen(for: self) @@ -250,7 +246,7 @@ enum CurrentUserError: Error { limit: 1, since: lastUpdatedMetadata ) - _ = await self?.relayService.openSubscription(with: metaFilter) + _ = await self?.relayService.subscribeToEvents(matching: metaFilter) let contactFilter = Filter( authorKeys: [followedKey], @@ -258,7 +254,7 @@ enum CurrentUserError: Error { limit: 1, since: lastUpdatedContactList ) - _ = await self?.relayService.openSubscription(with: contactFilter) + _ = await self?.relayService.subscribeToEvents(matching: contactFilter) // Do this slowly so we don't get rate limited try await Task.sleep(for: .seconds(5)) diff --git a/Nos/Service/EventProcessor.swift b/Nos/Service/EventProcessor.swift index 9a9885f19..2e26142ac 100644 --- a/Nos/Service/EventProcessor.swift +++ b/Nos/Service/EventProcessor.swift @@ -43,13 +43,12 @@ enum EventProcessor { if skipVerification == false { guard try publicKey.verifySignature(on: event) else { parseContext.delete(event) - Log.info("Invalid signature on event: \(jsonEvent)") + Log.info("Invalid signature on event: \(jsonEvent) from \(relay?.address ?? "error")") throw EventError.invalidSignature(event) } event.isVerified = true } - Log.debug("EventProcessor: parsed a new event") return event // Verify that this event has been marked seen on the given relay. @@ -58,11 +57,9 @@ enum EventProcessor { let event = Event.find(by: jsonEvent.id, context: parseContext) { event.markSeen(on: relay) try event.trackDelete(on: relay, context: parseContext) - Log.debug("EventProcessor: marked an existing event seen") - return event + return nil } - Log.debug("EventProcessor: skipping a duplicate event") return nil } diff --git a/Nos/Service/PushNotificationService.swift b/Nos/Service/PushNotificationService.swift index 9f6483ff1..126f08449 100644 --- a/Nos/Service/PushNotificationService.swift +++ b/Nos/Service/PushNotificationService.swift @@ -62,7 +62,7 @@ import Combine #endif private var notificationWatcher: NSFetchedResultsController? - private var relaySubscription: RelaySubscription.ID? + private var relaySubscription: SubscriptionCancellable? private var currentAuthor: Author? private lazy var modelContext: NSManagedObjectContext = { persistenceController.newBackgroundContext() @@ -71,9 +71,7 @@ import Combine // MARK: - Setup func listen(for user: CurrentUser) async { - if let relaySubscription { - await relayService.decrementSubscriptionCount(for: relaySubscription) - } + relaySubscription = nil guard let author = user.author, let authorKey = author.hexadecimalPublicKey else { @@ -101,7 +99,7 @@ import Combine pTags: [authorKey], limit: 50 ) - relaySubscription = await relayService.openSubscription(with: userMentionsFilter) + relaySubscription = await relayService.subscribeToEvents(matching: userMentionsFilter) await updateBadgeCount() } @@ -169,7 +167,7 @@ import Combine guard let publicKeyHex = currentUser.publicKeyHex else { throw PushNotificationError.unexpected } - let relays: [RegistrationRelayAddress] = await relayService.relays(for: user).map { + let relays: [RegistrationRelayAddress] = await relayService.relayAddresses(for: user).map { RegistrationRelayAddress(address: $0.absoluteString) } let content = Registration( diff --git a/Nos/Service/Filter.swift b/Nos/Service/Relay/Filter.swift similarity index 84% rename from Nos/Service/Filter.swift rename to Nos/Service/Relay/Filter.swift index 43fc34ea4..4d2bd2a60 100644 --- a/Nos/Service/Filter.swift +++ b/Nos/Service/Relay/Filter.swift @@ -7,7 +7,8 @@ import Foundation -/// Describes a set of Nostr Events, usually so we can ask relay servers for them. +/// Describes a set of Nostr Events, usually so we can ask relay servers for them. +/// See [NIP-01](https://github.com/nostr-protocol/nips/blob/master/01.md#communication-between-clients-and-relays). struct Filter: Hashable, Identifiable { let authorKeys: [HexadecimalString] @@ -17,8 +18,9 @@ struct Filter: Hashable, Identifiable { let pTags: [HexadecimalString] let search: String? let inNetwork: Bool - let limit: Int? - let since: Date? + var limit: Int? + var since: Date? + var until: Date? init( authorKeys: [HexadecimalString] = [], @@ -29,7 +31,8 @@ struct Filter: Hashable, Identifiable { search: String? = nil, inNetwork: Bool = false, limit: Int? = nil, - since: Date? = nil + since: Date? = nil, + until: Date? = nil ) { self.authorKeys = authorKeys.sorted(by: { $0 > $1 }) self.eventIDs = eventIDs @@ -40,6 +43,7 @@ struct Filter: Hashable, Identifiable { self.inNetwork = inNetwork self.limit = limit self.since = since + self.until = until } var dictionary: [String: Any] { @@ -76,6 +80,10 @@ struct Filter: Hashable, Identifiable { if let since { filterDict["since"] = Int(since.timeIntervalSince1970) } + + if let until { + filterDict["until"] = Int(until.timeIntervalSince1970) + } return filterDict } @@ -91,7 +99,9 @@ struct Filter: Hashable, Identifiable { hasher.combine(limit) hasher.combine(eTags) hasher.combine(pTags) + hasher.combine(search) hasher.combine(since) + hasher.combine(until) hasher.combine(inNetwork) } @@ -103,7 +113,9 @@ struct Filter: Hashable, Identifiable { limit?.description ?? "nil", eTags.joined(separator: ","), pTags.joined(separator: ","), + search ?? "nil", since?.timeIntervalSince1970.description ?? "nil", + until?.timeIntervalSince1970.description ?? "nil", inNetwork.description, ] diff --git a/Nos/Service/Relay/PagedRelaySubscription.swift b/Nos/Service/Relay/PagedRelaySubscription.swift new file mode 100644 index 000000000..3d6ebdbb8 --- /dev/null +++ b/Nos/Service/Relay/PagedRelaySubscription.swift @@ -0,0 +1,89 @@ +// +// PagedRelaySubscription.swift +// Nos +// +// Created by Matthew Lorentz on 11/27/23. +// + +import Foundation +import Logger + +/// This class manages a Filter and fetches events in reverse-chronological order as `loadMore()` is called. This +/// can be used to paginate a list of events. The underlying relay subscriptions will be deallocated when this object +/// goes out of scope. +class PagedRelaySubscription { + let startDate: Date + let filter: Filter + + private var relayService: RelayService + private var subscriptionManager: RelaySubscriptionManager + + /// A set of subscriptions fetching older events. + private var pagedSubscriptionIDs = [RelaySubscription.ID]() + + /// A set of subscriptions always listening for new events published after the `startDate`. + private var newEventsSubscriptionIDs = [RelaySubscription.ID]() + + init( + startDate: Date, + filter: Filter, + relayService: RelayService, + subscriptionManager: RelaySubscriptionManager, + relayAddresses: [URL] + ) { + self.startDate = startDate + self.filter = filter + self.relayService = relayService + self.subscriptionManager = subscriptionManager + Task { + // We keep two sets of subscriptions. One is always listening for new events and the other fetches + // progressively older events as we page down. + var pagedEventsFilter = filter + pagedEventsFilter.until = startDate + var newEventsFilter = filter + newEventsFilter.since = startDate + for relayAddress in relayAddresses { + newEventsSubscriptionIDs.append( + await subscriptionManager.queueSubscription(with: filter, to: relayAddress) + ) + pagedSubscriptionIDs.append( + await subscriptionManager.queueSubscription(with: pagedEventsFilter, to: relayAddress) + ) + } + } + } + + deinit { + for subscriptionID in newEventsSubscriptionIDs { + relayService.decrementSubscriptionCount(for: subscriptionID) + } + + for subscriptionID in pagedSubscriptionIDs { + relayService.decrementSubscriptionCount(for: subscriptionID) + } + } + + /// Instructs the pager to load older events for the given `filter` by decrementing the `until` parameter on the + /// `Filter` and updating all its managed subscriptions. + func loadMore() { + Task { [self] in + var newUntilDates = [URL: Date]() + + for subscriptionID in pagedSubscriptionIDs { + if let subscription = await subscriptionManager.subscription(from: subscriptionID), + let newDate = subscription.oldestEventCreationDate { + newUntilDates[subscription.relayAddress] = newDate + await subscriptionManager.decrementSubscriptionCount(for: subscriptionID) + } + } + + for (relayAddress, until) in newUntilDates { + var newEventsFilter = self.filter + newEventsFilter.until = until + pagedSubscriptionIDs.append( + await subscriptionManager.queueSubscription(with: newEventsFilter, to: relayAddress) + ) + } + } + } +} diff --git a/Nos/Service/RelayService.swift b/Nos/Service/Relay/RelayService.swift similarity index 82% rename from Nos/Service/RelayService.swift rename to Nos/Service/Relay/RelayService.swift index 7834717b1..f73f06f10 100644 --- a/Nos/Service/RelayService.swift +++ b/Nos/Service/Relay/RelayService.swift @@ -99,16 +99,18 @@ final class RelayService: ObservableObject { // MARK: Close subscriptions extension RelayService { - func decrementSubscriptionCount(for subscriptionIDs: [String]) async { + func decrementSubscriptionCount(for subscriptionIDs: [String]) { for subscriptionID in subscriptionIDs { - await self.decrementSubscriptionCount(for: subscriptionID) + self.decrementSubscriptionCount(for: subscriptionID) } } - func decrementSubscriptionCount(for subscriptionID: String) async { - let subscriptionStillActive = await subscriptions.decrementSubscriptionCount(for: subscriptionID) - if !subscriptionStillActive { - await self.sendCloseToAll(for: subscriptionID) + func decrementSubscriptionCount(for subscriptionID: String) { + Task { + let subscriptionStillActive = await subscriptions.decrementSubscriptionCount(for: subscriptionID) + if !subscriptionStillActive { + await self.sendCloseToAll(for: subscriptionID) + } } } @@ -126,7 +128,7 @@ extension RelayService { private func sendCloseToAll(for subscription: RelaySubscription.ID) async { await subscriptions.sockets.forEach { self.sendClose(from: $0, subscription: subscription) } - Task { await processSubscriptionQueue(overrideRelays: nil) } + Task { await processSubscriptionQueue() } } func closeConnection(to relayAddress: String?) async { @@ -144,18 +146,54 @@ extension RelayService { // MARK: Events extension RelayService { - func openSubscription(with filter: Filter, to overrideRelays: [URL]? = nil) async -> RelaySubscription.ID { - let subscriptionID = await subscriptions.queueSubscription(with: filter, to: overrideRelays) + /// Asks the service to start downloading events matching the given `filter` from relays and save them to Core + /// Data. If `specificRelays` are passed then those relays will be requested, otherwise we will use the user's list + /// of preferred relays. Subscriptions are internally de-duplicated. + /// + /// To close the subscription you can explicitly call `cancel()` on the returned `SubscriptionCancellable` or + /// let it be deallocated. + /// + /// - Parameter filter: an object describing the set of events you wish to fetch. + /// - Parameter specificRelays: an optional list of relays you would like to fetch from. The user's preferred relays + /// will be used if this is not set. + /// - Returns: A handle that allows the caller to cancel the subscription when it is no longer needed. + func subscribeToEvents( + matching filter: Filter, + from specificRelays: [URL]? = nil + ) async -> SubscriptionCancellable { + var relayAddresses: [URL] + if let specificRelays { + relayAddresses = specificRelays + } else { + relayAddresses = await self.relayAddresses(for: currentUser) + } + var subscriptionIDs = [RelaySubscription.ID]() + for relay in relayAddresses { + subscriptionIDs.append(await subscriptions.queueSubscription(with: filter, to: relay)) + } // Fire off REQs in the background - Task { await self.processSubscriptionQueue(overrideRelays: overrideRelays) } + Task { await self.processSubscriptionQueue() } - return subscriptionID + return SubscriptionCancellable(subscriptionIDs: subscriptionIDs, relayService: self) } - func requestMetadata(for authorKey: HexadecimalString?, since: Date?) async -> RelaySubscription.ID? { + /// Asks the relay to download a page of events matching the given `filter` from relays and save them to Core Data. + /// You can cause the service to download the next page by calling `loadMore()` on the returned subscription object. + /// The subscription will be cancelled when the returned subscription object is deallocated. + func subscribeToPagedEvents(matching filter: Filter) async -> PagedRelaySubscription { + PagedRelaySubscription( + startDate: .now, + filter: filter, + relayService: self, + subscriptionManager: subscriptions, + relayAddresses: await self.relayAddresses(for: currentUser) + ) + } + + func requestMetadata(for authorKey: HexadecimalString?, since: Date?) async -> SubscriptionCancellable { guard let authorKey else { - return nil + return SubscriptionCancellable.empty() } let metaFilter = Filter( @@ -164,12 +202,12 @@ extension RelayService { limit: 1, since: since ) - return await openSubscription(with: metaFilter) + return await subscribeToEvents(matching: metaFilter) } - func requestContactList(for authorKey: HexadecimalString?, since: Date?) async -> RelaySubscription.ID? { + func requestContactList(for authorKey: HexadecimalString?, since: Date?) async -> SubscriptionCancellable { guard let authorKey else { - return nil + return SubscriptionCancellable.empty() } let contactFilter = Filter( @@ -178,43 +216,39 @@ extension RelayService { limit: 1, since: since ) - return await openSubscription(with: contactFilter) + return await subscribeToEvents(matching: contactFilter) } func requestProfileData( for authorKey: HexadecimalString?, lastUpdateMetadata: Date?, lastUpdatedContactList: Date? - ) async -> [RelaySubscription.ID] { - var subscriptions = [RelaySubscription.ID]() + ) async -> SubscriptionCancellable { + var subscriptions = SubscriptionCancellables() guard let authorKey else { - return subscriptions + return SubscriptionCancellable.empty() } - if let metadataSubscriptionID = await requestMetadata(for: authorKey, since: lastUpdateMetadata) { - subscriptions.append(metadataSubscriptionID) - } - if let contactListSubscriptionID = await requestContactList(for: authorKey, since: lastUpdatedContactList) { - subscriptions.append(contactListSubscriptionID) - } + subscriptions.append(await requestMetadata(for: authorKey, since: lastUpdateMetadata)) + subscriptions.append(await requestContactList(for: authorKey, since: lastUpdatedContactList)) - return subscriptions + return SubscriptionCancellable(cancellables: subscriptions, relayService: self) } /// Requests a single event from all relays - func requestEvent(with eventID: String?) async -> RelaySubscription.ID? { + func requestEvent(with eventID: String?) async -> SubscriptionCancellable { guard let eventID = eventID else { - return nil + return SubscriptionCancellable.empty() } - return await openSubscription(with: Filter(eventIDs: [eventID], limit: 1)) + return await subscribeToEvents(matching: Filter(eventIDs: [eventID], limit: 1)) } - private func processSubscriptionQueue(overrideRelays: [URL]? = nil) async { - let relays = await openSockets(overrideRelays: overrideRelays) + private func processSubscriptionQueue() async { + _ = await openSockets() await clearStaleSubscriptions() - await subscriptions.processSubscriptionQueue(relays: relays) + await subscriptions.processSubscriptionQueue() let socketsCount = await subscriptions.sockets.count Task { @MainActor in @@ -262,7 +296,7 @@ extension RelayService { } #if DEBUG - Log.debug("from \(socket.host): EVENT type: \(eventJSON["kind"] ?? "nil") subID: \(subscriptionID)") + // Log.debug("from \(socket.host): EVENT type: \(eventJSON["kind"] ?? "nil") subID: \(subscriptionID)") #endif do { @@ -270,11 +304,23 @@ extension RelayService { let jsonEvent = try JSONDecoder().decode(JSONEvent.self, from: jsonData) await self.parseQueue.push(jsonEvent, from: socket) - if let subscription = await subscriptions.subscription(from: subscriptionID), - subscription.isOneTime { - Log.debug("detected subscription with id \(subscription.id) has been fulfilled. Closing.") - await subscriptions.forceCloseSubscriptionCount(for: subscription.id) - await sendCloseToAll(for: subscription.id) + if var subscription = await subscriptions.subscription(from: subscriptionID) { + if let oldestSeen = subscription.oldestEventCreationDate, + jsonEvent.createdDate < oldestSeen { + subscription.oldestEventCreationDate = jsonEvent.createdDate + subscription.receivedEventCount += 1 + await subscriptions.updateSubscriptions(with: subscription) + } else { + subscription.oldestEventCreationDate = jsonEvent.createdDate + subscription.receivedEventCount += 1 + await subscriptions.updateSubscriptions(with: subscription) + } + if subscription.isOneTime { + Log.debug("detected subscription with id \(subscription.id) has been fulfilled. Closing.") + await subscriptions.forceCloseSubscriptionCount(for: subscription.id) + await sendCloseToAll(for: subscription.id) + } + Log.debug("subscription \(subscriptionID) has received \(subscription.receivedEventCount) events.") } } catch { print("Error: parsing event from relay (\(socket.request.url?.absoluteString ?? "")): " + @@ -288,11 +334,25 @@ extension RelayService { if eventData.isEmpty { return false } else { + let remainingEventCount = await parseQueue.count try await self.parseContext.perform { + var savedEvents = 0 for (event, socket) in eventData { let relay = self.relay(from: socket, in: self.parseContext) - _ = try EventProcessor.parse(jsonEvent: event, from: relay, in: self.parseContext) + do { + if try EventProcessor.parse(jsonEvent: event, from: relay, in: self.parseContext) != nil { + savedEvents += 1 + } + } catch { + Log.error("RelayService: Error parsing event \(event.id): \(error.localizedDescription)") + } } + #if DEBUG + Log.debug( + "Parsed \(eventData.count) events and saved \(savedEvents) to database. " + + "\(remainingEventCount) events left in parse queue." + ) + #endif try self.parseContext.saveIfNeeded() try self.persistenceController.viewContext.saveIfNeeded() } @@ -363,6 +423,7 @@ extension RelayService { case "EVENT": await queueEventForParsing(responseArray, socket) case "NOTICE": + Log.debug("from \(socket.host): \(response)") if responseArray[safe: 1] as? String == "rate limited" { analytics.rateLimited(by: socket) } @@ -550,7 +611,7 @@ extension RelayService { if let overrideRelays { relayAddresses = overrideRelays } else { - relayAddresses = await relays(for: self.currentUser) + relayAddresses = await self.relayAddresses(for: self.currentUser) if relayAddresses.isEmpty { relayAddresses = Relay.allKnown.compactMap { URL(string: $0) } } @@ -575,7 +636,7 @@ extension RelayService { return relayAddresses } - func relays(for user: CurrentUser) async -> [URL] { + func relayAddresses(for user: CurrentUser) async -> [URL] { await backgroundContext.perform { () -> [URL] in if let currentUserPubKey = user.publicKeyHex, let currentUser = try? Author.find(by: currentUserPubKey, context: self.backgroundContext) { @@ -637,7 +698,7 @@ extension RelayService { Log.error("websocket connected with unknown host") } - for subscription in await subscriptions.active { + for subscription in await subscriptions.active where subscription.relayAddress == client.url { await subscriptions.requestEvents(from: client, subscription: subscription) } } diff --git a/Nos/Service/RelaySubscriptionManager.swift b/Nos/Service/Relay/RelaySubscriptionManager.swift similarity index 76% rename from Nos/Service/RelaySubscriptionManager.swift rename to Nos/Service/Relay/RelaySubscriptionManager.swift index 65787a7f5..7ba28de7d 100644 --- a/Nos/Service/RelaySubscriptionManager.swift +++ b/Nos/Service/Relay/RelaySubscriptionManager.swift @@ -36,7 +36,7 @@ actor RelaySubscriptionManager { } } - private func updateSubscriptions(with newValue: RelaySubscription) { + func updateSubscriptions(with newValue: RelaySubscription) { if let subscriptionIndex = self.all.firstIndex(where: { $0.id == newValue.id }) { all[subscriptionIndex] = newValue } else { @@ -56,6 +56,7 @@ actor RelaySubscriptionManager { removeSubscription(with: subscriptionID) } + @discardableResult func decrementSubscriptionCount(for subscriptionID: RelaySubscription.ID) async -> Bool { if var subscription = subscription(from: subscriptionID) { if subscription.referenceCount == 1 { @@ -81,7 +82,7 @@ actor RelaySubscriptionManager { } } for subscription in staleSubscriptions { - forceCloseSubscriptionCount(for: subscription.subscriptionID) + forceCloseSubscriptionCount(for: subscription.id) } return staleSubscriptions } @@ -130,56 +131,38 @@ actor RelaySubscriptionManager { private let subscriptionLimit = 10 private let minimimumOneTimeSubscriptions = 1 - func processSubscriptionQueue(relays: [URL]) async { + func processSubscriptionQueue() async { - // TODO: Make sure active subscriptions are open on all relays - - // Strategy: we have two types of subscriptions: long and one time. We can only have a certain number of - // subscriptions open at once. We want to: - // - Open as many long running subsriptions as we can, leaving room for `minimumOneTimeSubscriptions` - // - fill remaining slots with one time filters let waitingLongSubscriptions = all.filter { !$0.isOneTime && !$0.isActive } let waitingOneTimeSubscriptions = all.filter { $0.isOneTime && !$0.isActive } - let openSlots = subscriptionLimit - active.count - let openLongSlots = max(0, openSlots - minimimumOneTimeSubscriptions) - - for subscription in waitingLongSubscriptions.prefix(openLongSlots) { - start(subscription: subscription, relays: relays) - } - - let openOneTimeSlots = max(0, subscriptionLimit - active.count) - for subscription in waitingOneTimeSubscriptions.prefix(openOneTimeSlots) { - start(subscription: subscription, relays: relays) - } + waitingOneTimeSubscriptions.forEach { start(subscription: $0) } + waitingLongSubscriptions.forEach { start(subscription: $0) } Log.debug("\(active.count) active subscriptions. \(all.count - active.count) subscriptions waiting in queue.") } - func queueSubscription(with filter: Filter, to overrideRelays: [URL]? = nil) async -> RelaySubscription.ID { - var subscription: RelaySubscription + func queueSubscription(with filter: Filter, to relayAddress: URL) async -> RelaySubscription.ID { + var subscription = RelaySubscription(filter: filter, relayAddress: relayAddress) - if let existingSubscription = self.subscription(from: filter.id) { + if let existingSubscription = self.subscription(from: subscription.id) { // dedup subscription = existingSubscription - } else { - subscription = RelaySubscription(filter: filter) } + subscription.referenceCount += 1 updateSubscriptions(with: subscription) return subscription.id } - private func start(subscription: RelaySubscription, relays: [URL]) { + private func start(subscription: RelaySubscription) { var subscription = subscription subscription.subscriptionStartDate = .now updateSubscriptions(with: subscription) Log.debug("starting subscription: \(subscription.id), filter: \(subscription.filter)") - relays.forEach { relayURL in - if let socket = socket(for: relayURL) { - requestEvents(from: socket, subscription: subscription) - } + if let socket = socket(for: subscription.relayAddress) { + requestEvents(from: socket, subscription: subscription) } } diff --git a/Nos/Views/AuthorStoryView.swift b/Nos/Views/AuthorStoryView.swift index e47241cca..c4a9bf02a 100644 --- a/Nos/Views/AuthorStoryView.swift +++ b/Nos/Views/AuthorStoryView.swift @@ -24,7 +24,7 @@ struct AuthorStoryView: View { @Binding private var cutoffDate: Date - @State private var subscriptionIDs = [String]() + @State private var relaySubscriptions = SubscriptionCancellables() @EnvironmentObject private var router: Router @EnvironmentObject private var relayService: RelayService @@ -180,18 +180,14 @@ struct AuthorStoryView: View { /// Fetches replies to the list of stories from connected relays (to update reply count to each one) private func subscribeToReplies() async { // Close out stale requests - if !subscriptionIDs.isEmpty { - await relayService.decrementSubscriptionCount(for: subscriptionIDs) - subscriptionIDs.removeAll() - } + relaySubscriptions.removeAll() let eTags = notes.compactMap { $0.identifier } guard !eTags.isEmpty else { return } let filter = Filter(kinds: [.text, .like, .delete, .repost], eTags: eTags) - let subID = await relayService.openSubscription(with: filter) - subscriptionIDs.append(subID) + relaySubscriptions.append(await relayService.subscribeToEvents(matching: filter)) } } diff --git a/Nos/Views/CompactNoteView.swift b/Nos/Views/CompactNoteView.swift index 43df49a69..d061b8287 100644 --- a/Nos/Views/CompactNoteView.swift +++ b/Nos/Views/CompactNoteView.swift @@ -22,8 +22,6 @@ struct CompactNoteView: View { @State var showFullMessage: Bool @State private var intrinsicSize = CGSize.zero @State private var truncatedSize = CGSize.zero - @State private var noteContent = LoadingContent.loading - @State private var contentLinks = [URL]() private var loadLinks: Bool @EnvironmentObject private var router: Router @@ -36,7 +34,7 @@ struct CompactNoteView: View { } func updateShouldShowReadMore() { - shouldShowReadMore = intrinsicSize.height > truncatedSize.height + shouldShowReadMore = intrinsicSize.height > truncatedSize.height + 30 } var formattedText: some View { @@ -54,7 +52,7 @@ struct CompactNoteView: View { var noteText: some View { Group { - switch noteContent { + switch note.attributedContent { case .loading: Text(note.content ?? "") .redacted(reason: .placeholder) @@ -121,23 +119,13 @@ struct CompactNoteView: View { .frame(maxWidth: .infinity) .padding(EdgeInsets(top: 0, leading: 0, bottom: 10, trailing: 0)) } - if note.kind == EventKind.text.rawValue, loadLinks, !contentLinks.isEmpty { - LinkPreviewCarousel(links: contentLinks) + if note.kind == EventKind.text.rawValue, loadLinks, !note.contentLinks.isEmpty { + LinkPreviewCarousel(links: note.contentLinks) } } .frame(maxWidth: .infinity, alignment: .leading) - .task { - let backgroundContext = persistenceController.backgroundViewContext - if let parsedAttributedContent = await Event.attributedContentAndURLs( - note: note, - context: backgroundContext - ) { - withAnimation(.easeIn(duration: 0.1)) { - let (attributedString, contentLinks) = parsedAttributedContent - self.noteContent = .loaded(attributedString) - self.contentLinks = contentLinks - } - } + .onChange(of: note.attributedContent) { + updateShouldShowReadMore() } } } diff --git a/Nos/Views/DiscoverView.swift b/Nos/Views/DiscoverView.swift index ef29e4a29..f67e611ad 100644 --- a/Nos/Views/DiscoverView.swift +++ b/Nos/Views/DiscoverView.swift @@ -26,7 +26,7 @@ struct DiscoverView: View { @State private var performingInitialLoad = true static let initialLoadTime = 2 - @State private var subscriptionIDs = [String]() + @State private var relaySubscriptions = SubscriptionCancellables() @State private var isVisible = false private var featuredAuthors: [String] @@ -61,9 +61,8 @@ struct DiscoverView: View { limit: 200 ) - subscriptionIDs.append( - // TODO: I don't think the override relays will be honored when opening new sockets - await relayService.openSubscription(with: singleRelayFilter, to: [relayAddress]) + relaySubscriptions.append( + await relayService.subscribeToEvents(matching: singleRelayFilter, from: [relayAddress]) ) } else { let featuredFilter = Filter( @@ -74,15 +73,12 @@ struct DiscoverView: View { limit: 200 ) - subscriptionIDs.append(await relayService.openSubscription(with: featuredFilter)) + relaySubscriptions.append(await relayService.subscribeToEvents(matching: featuredFilter)) } } func cancelSubscriptions() async { - if !subscriptionIDs.isEmpty { - await relayService.decrementSubscriptionCount(for: subscriptionIDs) - subscriptionIDs.removeAll() - } + relaySubscriptions.removeAll() } var body: some View { diff --git a/Nos/Views/FollowCard.swift b/Nos/Views/FollowCard.swift index cd4d5e5fc..111a2666c 100644 --- a/Nos/Views/FollowCard.swift +++ b/Nos/Views/FollowCard.swift @@ -22,7 +22,7 @@ struct FollowCard: View { @Environment(CurrentUser.self) private var currentUser @EnvironmentObject private var relayService: RelayService - @State private var subscriptions = [RelaySubscription.ID]() + @State private var relaySubscriptions = SubscriptionCancellables() var body: some View { VStack(alignment: .leading, spacing: 0) { @@ -66,18 +66,15 @@ struct FollowCard: View { .cornerRadius(cornerRadius) .onAppear { Task(priority: .userInitiated) { - if let subscriptionID = await relayService.requestMetadata( + let subscription = await relayService.requestMetadata( for: author.hexadecimalPublicKey, since: author.lastUpdatedMetadata - ) { - subscriptions.append(subscriptionID) - } + ) + relaySubscriptions.append(subscription) } } .onDisappear { - subscriptions.forEach { subscriptionID in - Task { await relayService.decrementSubscriptionCount(for: subscriptionID) } - } + relaySubscriptions.removeAll() } } diff --git a/Nos/Views/HomeFeedView.swift b/Nos/Views/HomeFeedView.swift index a8cc756a3..a224a17b8 100644 --- a/Nos/Views/HomeFeedView.swift +++ b/Nos/Views/HomeFeedView.swift @@ -22,7 +22,7 @@ struct HomeFeedView: View { @FetchRequest private var authors: FetchedResults @State private var date = Date(timeIntervalSince1970: Date.now.timeIntervalSince1970 + Double(Self.initialLoadTime)) - @State private var subscriptionIDs = [String]() + @State private var relaySubscriptions = SubscriptionCancellables() @State private var isVisible = false @State private var cancellables = [AnyCancellable]() @State private var performingInitialLoad = true @@ -50,7 +50,7 @@ struct HomeFeedView: View { } func subscribeToNewEvents() async { - await cancelSubscriptions() + relaySubscriptions.removeAll() let followedKeys = await Array(currentUser.socialGraph.followedKeys) @@ -62,15 +62,8 @@ struct HomeFeedView: View { limit: 100, since: nil ) - let textSub = await relayService.openSubscription(with: textFilter) - subscriptionIDs.append(textSub) - } - } - - func cancelSubscriptions() async { - if !subscriptionIDs.isEmpty { - await relayService.decrementSubscriptionCount(for: subscriptionIDs) - subscriptionIDs.removeAll() + let textSubs = await relayService.subscribeToEvents(matching: textFilter) + relaySubscriptions.append(textSubs) } } @@ -221,7 +214,7 @@ struct HomeFeedView: View { analytics.showedHome() Task { await subscribeToNewEvents() } } else { - Task { await cancelSubscriptions() } + relaySubscriptions.removeAll() } } } diff --git a/Nos/Views/NoteButton.swift b/Nos/Views/NoteButton.swift index 9a72b1758..a8c93e601 100644 --- a/Nos/Views/NoteButton.swift +++ b/Nos/Views/NoteButton.swift @@ -16,7 +16,7 @@ import Dependencies /// The button opens the ThreadView for the note when tapped. struct NoteButton: View { - @ObservedObject var note: Event + var note: Event var style = CardStyle.compact var showFullMessage = false var hideOutOfNetwork = true @@ -31,8 +31,6 @@ struct NoteButton: View { @EnvironmentObject private var relayService: RelayService @Dependency(\.persistenceController) private var persistenceController - @State private var subscriptionIDs = [RelaySubscription.ID]() - init( note: Event, style: CardStyle = CardStyle.compact, @@ -85,21 +83,6 @@ struct NoteButton: View { } .padding(.horizontal) .readabilityPadding() - .onAppear { - Task(priority: .userInitiated) { - await subscriptionIDs += Event.requestAuthorsMetadataIfNeeded( - noteID: note.identifier, - using: relayService, - in: persistenceController.backgroundViewContext - ) - } - } - .onDisappear { - Task(priority: .userInitiated) { - await relayService.decrementSubscriptionCount(for: subscriptionIDs) - subscriptionIDs.removeAll() - } - } }) } diff --git a/Nos/Views/NoteCard.swift b/Nos/Views/NoteCard.swift index 25dc2a0d4..d12858215 100644 --- a/Nos/Views/NoteCard.swift +++ b/Nos/Views/NoteCard.swift @@ -16,11 +16,10 @@ import Dependencies /// Use this view inside MessageButton to have nice borders. struct NoteCard: View { - @ObservedObject var note: Event + var note: Event var style = CardStyle.compact - @State private var subscriptionIDs = [RelaySubscription.ID]() @State private var userTappedShowOutOfNetwork = false @State private var replyCount = 0 @State private var replyAvatarURLs = [URL]() @@ -80,12 +79,15 @@ struct NoteCard: View { case .compact: VStack(spacing: 0) { HStack(alignment: .center, spacing: 0) { - if !warningController.showWarning, let author = note.author { - Button { - router.currentPath.wrappedValue.append(author) - } label: { - NoteCardHeader(note: note, author: author) + if !warningController.showWarning { + if let author = note.author { + Button { + router.currentPath.wrappedValue.append(author) + } label: { + NoteCardHeader(note: note, author: author) + } } + Spacer() NoteOptionsButton(note: note) } else { Spacer() @@ -145,24 +147,10 @@ struct NoteCard: View { warningController.shouldHideOutOfNetwork = hideOutOfNetwork } .task { - if note.isStub { - _ = await relayService.requestEvent(with: note.identifier) - } - } - .onAppear { - Task(priority: .userInitiated) { - await subscriptionIDs += Event.requestAuthorsMetadataIfNeeded( - noteID: note.identifier, - using: relayService, - in: persistenceController.backgroundViewContext - ) - } + await note.loadViewData() } - .onDisappear { - Task(priority: .userInitiated) { - await relayService.decrementSubscriptionCount(for: subscriptionIDs) - subscriptionIDs.removeAll() - } + .onChange(of: note.content) { _, _ in + Task { await note.loadAttributedContent() } } .background(LinearGradient.cardBackground) .task { diff --git a/Nos/Views/NoteOptionsButton.swift b/Nos/Views/NoteOptionsButton.swift index c30aea3df..135ac87de 100644 --- a/Nos/Views/NoteOptionsButton.swift +++ b/Nos/Views/NoteOptionsButton.swift @@ -16,7 +16,7 @@ struct NoteOptionsButton: View { @Dependency(\.analytics) private var analytics @Dependency(\.persistenceController) private var persistenceController - var note: Event + @ObservedObject var note: Event @State private var showingOptions = false @State private var showingShare = false @@ -40,20 +40,22 @@ struct NoteOptionsButton: View { analytics.copiedNoteIdentifier() copyMessageIdentifier() } - Button(Localized.copyNoteText.string) { - analytics.copiedNoteText() - copyMessage() - } Button(Localized.copyLink.string) { analytics.copiedNoteLink() copyLink() } - Button(Localized.viewSource.string) { - analytics.viewedNoteSource() - showingSource = true - } - Button(Localized.reportNote.string, role: .destructive) { - showingReportMenu = true + if !note.isStub { + Button(Localized.copyNoteText.string) { + analytics.copiedNoteText() + copyMessage() + } + Button(Localized.viewSource.string) { + analytics.viewedNoteSource() + showingSource = true + } + Button(Localized.reportNote.string, role: .destructive) { + showingReportMenu = true + } } if note.author == currentUser.author { Button(Localized.deleteNote.string, role: .destructive) { diff --git a/Nos/Views/NotificationCard.swift b/Nos/Views/NotificationCard.swift index a6a4e4aa4..1b28449ee 100644 --- a/Nos/Views/NotificationCard.swift +++ b/Nos/Views/NotificationCard.swift @@ -17,7 +17,7 @@ struct NotificationCard: View { @Dependency(\.persistenceController) private var persistenceController @ObservedObject private var viewModel: NotificationViewModel - @State private var subscriptionIDs = [RelaySubscription.ID]() + @State private var relaySubscriptions = SubscriptionCancellables() @State private var content: AttributedString? init(viewModel: NotificationViewModel) { @@ -85,19 +85,14 @@ struct NotificationCard: View { .onAppear { Task(priority: .userInitiated) { let backgroundContext = persistenceController.backgroundViewContext - await subscriptionIDs += Event.requestAuthorsMetadataIfNeeded( + await relaySubscriptions.append(Event.requestAuthorsMetadataIfNeeded( noteID: viewModel.id, using: relayService, in: backgroundContext - ) - } - } - .onDisappear { - Task(priority: .userInitiated) { - await relayService.decrementSubscriptionCount(for: subscriptionIDs) - subscriptionIDs.removeAll() + )) } } + .onDisappear { relaySubscriptions.removeAll() } .task(priority: .userInitiated) { self.content = await viewModel.loadContent(in: persistenceController.viewContext) } diff --git a/Nos/Views/NotificationsView.swift b/Nos/Views/NotificationsView.swift index d5c1e2f88..335e67346 100644 --- a/Nos/Views/NotificationsView.swift +++ b/Nos/Views/NotificationsView.swift @@ -23,7 +23,7 @@ struct NotificationsView: View { private var eventRequest: FetchRequest = FetchRequest(fetchRequest: Event.emptyRequest()) private var events: FetchedResults { eventRequest.wrappedValue } - @State private var subscriptionIDs = [String]() + @State private var relaySubscriptions = SubscriptionCancellables() @State private var isVisible = false @State private var concecutiveTapsCancellable: AnyCancellable? @@ -50,15 +50,12 @@ struct NotificationsView: View { pTags: [currentUserKey], limit: 100 ) - let subscription = await relayService.openSubscription(with: filter) - subscriptionIDs.append(subscription) + let subscriptions = await relayService.subscribeToEvents(matching: filter) + relaySubscriptions.append(subscriptions) } func cancelSubscriptions() async { - if !subscriptionIDs.isEmpty { - await relayService.decrementSubscriptionCount(for: subscriptionIDs) - subscriptionIDs.removeAll() - } + relaySubscriptions.removeAll() } func markAllNotificationsRead() async { diff --git a/Nos/Views/PagedNoteListView.swift b/Nos/Views/PagedNoteListView.swift new file mode 100644 index 000000000..63e946f6f --- /dev/null +++ b/Nos/Views/PagedNoteListView.swift @@ -0,0 +1,198 @@ +// +// PagedNoteListView.swift +// Nos +// +// Created by Matthew Lorentz on 11/20/23. +// + +import SwiftUI +import CoreData +import Dependencies +import Logger + +/// The PagedNoteListView is designed to display an infinite list of notes in reverse-chronological order. +/// It takes two filters: one to load events from our local database (Core Data) and one to load them from the +/// relays. As the user scrolls down we will keep adjusting the relay filter to get older events. +/// +/// Under the hood PagedNoteListView is using UICollectionView and NSFetchedResultsController. We leverage the +/// UICollectionViewDataSourcePrefetching protocol to call Event.loadViewData() on events in advanced of them being +/// shown, which allows us to perform expensive tasks like downloading images, calculating attributed text, fetching +/// author metadata and linked notes, etc. before the view is displayed. +struct PagedNoteListView: UIViewRepresentable { + + /// A fetch request that specifies the events that should be shown. The events should be sorted in + /// reverse-chronological order and should match the events returned by `relayFilter`. + let databaseFilter: NSFetchRequest + + /// A Filter that specifies the events that should be shown. The Filter should not have `limit`, `since`, or `until` + /// set as they will be overmanaged internally. The events downloaded by this filter should match the ones returned + /// by the `databaseFilter`. + let relayFilter: Filter + + let context: NSManagedObjectContext + + /// A view that will be displayed as the collectionView header. + let header: () -> Header + + /// A view that will be displayed below the header when no notes are being shown. + let emptyPlaceholder: () -> EmptyPlaceholder + + /// A closure that will be called when the user pulls-to-refresh. You probably want to update the `databaseFilter` + /// in this closure. + let onRefresh: () -> NSFetchRequest + + func makeCoordinator() -> Coordinator { + Coordinator() + } + + func makeUIView(context: Context) -> UICollectionView { + let layout = Self.buildLayout() + let collectionView = UICollectionView(frame: .zero, collectionViewLayout: layout) + collectionView.backgroundColor = .clear + collectionView.autoresizingMask = [.flexibleWidth, .flexibleHeight] + collectionView.contentInset = .zero + collectionView.layoutMargins = .zero + let dataSource = context.coordinator.dataSource( + databaseFilter: databaseFilter, + relayFilter: relayFilter, + collectionView: collectionView, + context: self.context, + header: header, + emptyPlaceholder: emptyPlaceholder, + onRefresh: onRefresh + ) + collectionView.dataSource = dataSource + collectionView.prefetchDataSource = dataSource + + let refreshControl = UIRefreshControl() + refreshControl.addTarget( + context.coordinator, + action: #selector(Coordinator.refreshData(_:)), + for: .valueChanged + ) + collectionView.refreshControl = refreshControl + + return collectionView + } + + func updateUIView(_ collectionView: UICollectionView, context: Context) {} + + /// Builds a one section, one column layout with dynamic cell sizes and a header and footer view. + static func buildLayout() -> UICollectionViewLayout { + let size = NSCollectionLayoutSize( + widthDimension: NSCollectionLayoutDimension.fractionalWidth(1), + heightDimension: NSCollectionLayoutDimension.estimated(140) + ) + let item = NSCollectionLayoutItem(layoutSize: size) + let group = NSCollectionLayoutGroup.horizontal(layoutSize: size, subitems: [item]) + + let section = NSCollectionLayoutSection(group: group) + section.contentInsets = .zero + section.interGroupSpacing = 16 + + let headerSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .estimated(300)) + let headerItem = NSCollectionLayoutBoundarySupplementaryItem( + layoutSize: headerSize, + elementKind: UICollectionView.elementKindSectionHeader, + alignment: .top + ) + headerItem.edgeSpacing = .none + + let footerSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .estimated(300)) + let footerItem = NSCollectionLayoutBoundarySupplementaryItem( + layoutSize: footerSize, + elementKind: UICollectionView.elementKindSectionFooter, + alignment: .bottom + ) + headerItem.edgeSpacing = .none + + section.boundarySupplementaryItems = [headerItem, footerItem] + + return UICollectionViewCompositionalLayout(section: section) + } + + // swiftlint:disable generic_type_name + /// The coordinator mainly holds a strong reference to the `dataSource` and proxies pull-to-refresh events. + class Coordinator { + // swiftlint:enable generic_type_name + + var dataSource: PagedNoteDataSource? + var collectionView: UICollectionView? + var onRefresh: (() -> NSFetchRequest)? + + func dataSource( + databaseFilter: NSFetchRequest, + relayFilter: Filter, + collectionView: UICollectionView, + context: NSManagedObjectContext, + @ViewBuilder header: @escaping () -> CoordinatorHeader, + @ViewBuilder emptyPlaceholder: @escaping () -> CoordinatorEmptyPlaceholder, + onRefresh: @escaping () -> NSFetchRequest + ) -> PagedNoteDataSource { + if let dataSource { + return dataSource + } + self.collectionView = collectionView + self.onRefresh = onRefresh + + let dataSource = PagedNoteDataSource( + databaseFilter: databaseFilter, + relayFilter: relayFilter, + collectionView: collectionView, + context: context, + header: header, + emptyPlaceholder: emptyPlaceholder + ) + self.dataSource = dataSource + return dataSource + } + + @objc func refreshData(_ sender: Any) { + if let onRefresh { + dataSource?.updateFetchRequest(onRefresh()) + collectionView?.reloadData() + } + + if let refreshControl = sender as? UIRefreshControl { + // Dismiss the refresh control + DispatchQueue.main.async { + refreshControl.endRefreshing() + } + } + } + } +} + +#Preview { + var previewData = PreviewData() + + return PagedNoteListView( + databaseFilter: previewData.alice.allPostsRequest(), + relayFilter: Filter(), + context: previewData.previewContext, + header: { + ProfileHeader(author: previewData.alice) + .compositingGroup() + .shadow(color: .profileShadow, radius: 10, x: 0, y: 4) + .id(previewData.alice.id) + }, + emptyPlaceholder: { + Text("empty") + }, + onRefresh: { + previewData.alice.allPostsRequest() + } + ) + .background(Color.appBg) + .inject(previewData: previewData) + .onAppear { + for i in 0..<100 { + let note = Event(context: previewData.previewContext) + note.identifier = "ProfileNotesView-\(i)" + note.kind = EventKind.text.rawValue + note.content = "\(i)" + note.author = previewData.alice + note.createdAt = Date(timeIntervalSince1970: Date.now.timeIntervalSince1970 - Double(i)) + } + } +} diff --git a/Nos/Views/ProfileHeader.swift b/Nos/Views/ProfileHeader.swift index feb30d4e7..bcda97d70 100644 --- a/Nos/Views/ProfileHeader.swift +++ b/Nos/Views/ProfileHeader.swift @@ -16,8 +16,6 @@ struct ProfileHeader: View { @EnvironmentObject private var relayService: RelayService @Environment(CurrentUser.self) private var currentUser - @State private var subscriptionId: String = "" - var followsRequest: FetchRequest var followsResult: FetchedResults { followsRequest.wrappedValue } @@ -144,12 +142,6 @@ struct ProfileHeader: View { endPoint: .bottom ) ) - .onDisappear { - Task(priority: .userInitiated) { - await relayService.decrementSubscriptionCount(for: subscriptionId) - subscriptionId = "" - } - } } } diff --git a/Nos/Views/ProfileView.swift b/Nos/Views/ProfileView.swift index 3ef1ae861..7274b6d9b 100644 --- a/Nos/Views/ProfileView.swift +++ b/Nos/Views/ProfileView.swift @@ -17,9 +17,9 @@ struct ProfileView: View { var addDoubleTapToPop = false @Environment(\.managedObjectContext) private var viewContext - @EnvironmentObject private var relayService: RelayService @Environment(CurrentUser.self) private var currentUser @EnvironmentObject private var router: Router + @Dependency(\.relayService) private var relayService: RelayService @Dependency(\.analytics) private var analytics @Dependency(\.unsAPI) private var unsAPI @@ -28,26 +28,13 @@ struct ProfileView: View { @State private var usbcAddress: USBCAddress? @State private var usbcBalance: Double? @State private var usbcBalanceTimer: Timer? - - @State private var subscriptionIds: [String] = [] + @State private var relaySubscriptions = SubscriptionCancellables() @State private var alert: AlertState? @FetchRequest private var events: FetchedResults - @State private var unmutedEvents: [Event] = [] - - private func computeUnmutedEvents() async { - unmutedEvents = events.filter { - if let author = $0.author { - let notDeleted = $0.deletedOn.count == 0 - return !author.muted && notDeleted - } - return false - } - } - var isShowingLoggedInUser: Bool { author.hexadecimalPublicKey == currentUser.publicKeyHex } @@ -58,34 +45,6 @@ struct ProfileView: View { _events = FetchRequest(fetchRequest: author.allPostsRequest()) } - func refreshProfileFeed() async { - // Close out stale requests - if !subscriptionIds.isEmpty { - await relayService.decrementSubscriptionCount(for: subscriptionIds) - subscriptionIds.removeAll() - } - - guard let authorKey = author.hexadecimalPublicKey else { - return - } - - let authors = [authorKey] - let textFilter = Filter(authorKeys: authors, kinds: [.text, .delete, .repost, .longFormContent], limit: 50) - async let textSub = relayService.openSubscription(with: textFilter) - subscriptionIds.append(await textSub) - subscriptionIds.append( - contentsOf: await relayService.requestProfileData( - for: authorKey, - lastUpdateMetadata: author.lastUpdatedMetadata, - lastUpdatedContactList: nil // always grab contact list because we purge follows aggressively - ) - ) - - // reports - let reportFilter = Filter(kinds: [.report], pTags: [authorKey]) - subscriptionIds.append(await relayService.openSubscription(with: reportFilter)) - } - func loadUSBCBalance() async { guard let unsName = author.uns, !unsName.isEmpty else { usbcAddress = nil @@ -110,34 +69,66 @@ struct ProfileView: View { } } + func downloadAuthorData() async { + relaySubscriptions.removeAll() + + guard let authorKey = author.hexadecimalPublicKey else { + return + } + + // Profile data + relaySubscriptions.append( + await relayService.requestProfileData( + for: authorKey, + lastUpdateMetadata: author.lastUpdatedMetadata, + lastUpdatedContactList: nil // always grab contact list because we purge follows aggressively + ) + ) + + // reports + let reportFilter = Filter(kinds: [.report], pTags: [authorKey]) + relaySubscriptions.append(await relayService.subscribeToEvents(matching: reportFilter)) + } + var body: some View { VStack(spacing: 0) { - ScrollView(.vertical, showsIndicators: false) { - ProfileHeader(author: author) - .compositingGroup() - .shadow(color: .profileShadow, radius: 10, x: 0, y: 4) - .id(author.id) - - LazyVStack { - if unmutedEvents.isEmpty { - Localized.noEventsOnProfile.view - .padding() - } else { - ForEach(unmutedEvents) { event in - VStack { - NoteButton(note: event, hideOutOfNetwork: false, displayRootMessage: true) - .padding(.bottom, 15) - } + VStack { + let profileNotesFilter = Filter( + authorKeys: [author.hexadecimalPublicKey ?? "error"], + kinds: [.text, .delete, .repost, .longFormContent] + ) + + PagedNoteListView( + databaseFilter: author.allPostsRequest(), + relayFilter: profileNotesFilter, + context: viewContext, + header: { + ProfileHeader(author: author) + .compositingGroup() + .shadow(color: .profileShadow, radius: 10, x: 0, y: 4) + .id(author.id) + }, + emptyPlaceholder: { + VStack { + Localized.noEventsOnProfile.view + .padding() + .readabilityPadding() } + .frame(minHeight: 300) + }, + onRefresh: { + author.allPostsRequest(before: .now) } - } - .padding(.top, 10) + ) + .padding(0) + .frame(maxWidth: .infinity, maxHeight: .infinity) } - .background(Color.appBg) + .id(author.id) .doubleTapToPop(tab: .profile, enabled: addDoubleTapToPop) { proxy in proxy.scrollTo(author.id) } } + .background(Color.appBg) .nosNavigationBar(title: .profileTitle) .navigationDestination(for: Event.self) { note in RepliesView(note: note) @@ -238,12 +229,6 @@ struct ProfileView: View { } ) .reportMenu($showingReportMenu, reportedObject: .author(author)) - .task { - await refreshProfileFeed() - } - .task { - await computeUnmutedEvents() - } .onChange(of: author.uns) { Task { await loadUSBCBalance() @@ -251,28 +236,14 @@ struct ProfileView: View { } .alert(unwrapping: $alert) .onAppear { - Task { await loadUSBCBalance() } - analytics.showedProfile() - } - .refreshable { - await refreshProfileFeed() - await computeUnmutedEvents() - } - .onChange(of: author.muted) { - Task { - await computeUnmutedEvents() - } - } - .onChange(of: author.events.count) { - Task { - await computeUnmutedEvents() + Task { + await downloadAuthorData() + await loadUSBCBalance() } + analytics.showedProfile() } .onDisappear { - Task(priority: .userInitiated) { - await relayService.decrementSubscriptionCount(for: subscriptionIds) - subscriptionIds.removeAll() - } + relaySubscriptions.removeAll() } } } diff --git a/Nos/Views/RepliesView.swift b/Nos/Views/RepliesView.swift index be96c4857..50b401eff 100644 --- a/Nos/Views/RepliesView.swift +++ b/Nos/Views/RepliesView.swift @@ -26,7 +26,7 @@ struct RepliesView: View { @State private var alert: AlertState? - @State private var subscriptionIDs = [String]() + @State private var relaySubscriptions = SubscriptionCancellables() @FocusState private var focusTextView: Bool @State private var showKeyboardOnAppear: Bool @@ -80,15 +80,12 @@ struct RepliesView: View { func subscribeToReplies() { Task(priority: .userInitiated) { // Close out stale requests - if !subscriptionIDs.isEmpty { - await relayService.decrementSubscriptionCount(for: subscriptionIDs) - subscriptionIDs.removeAll() - } + relaySubscriptions.removeAll() let eTags = ([note.identifier] + replies.map { $0.identifier }).compactMap { $0 } let filter = Filter(kinds: [.text, .like, .delete, .repost, .report, .label], eTags: eTags) - let subID = await relayService.openSubscription(with: filter) - subscriptionIDs.append(subID) + let subIDs = await relayService.subscribeToEvents(matching: filter) + relaySubscriptions.append(subIDs) // download reports for this user and the replies' authors guard let authorKey = note.author?.hexadecimalPublicKey else { @@ -96,7 +93,7 @@ struct RepliesView: View { } let pTags = Array(Set([authorKey] + replies.compactMap { $0.author?.hexadecimalPublicKey })) let reportFilter = Filter(kinds: [.report], pTags: pTags) - subscriptionIDs.append(await relayService.openSubscription(with: reportFilter)) + relaySubscriptions.append(await relayService.subscribeToEvents(matching: reportFilter)) } } @@ -132,10 +129,7 @@ struct RepliesView: View { subscribeToReplies() } .onDisappear { - Task(priority: .userInitiated) { - await relayService.decrementSubscriptionCount(for: subscriptionIDs) - subscriptionIDs.removeAll() - } + relaySubscriptions.removeAll() } VStack { Spacer()