From c19ccb625f2b7e5c684cd7b342afb20d908180f2 Mon Sep 17 00:00:00 2001 From: mplorentz Date: Wed, 24 Jul 2024 10:43:09 -0400 Subject: [PATCH 1/8] Revert "Merge pull request #1314 from planetary-social/revert-home-feed-filter" This reverts commit c5da9cd4e8d901e35c7c41ce87874875bf147ce6, reversing changes made to 02b7653fa9fe344590ce54a04d128d64758eea40. --- CHANGELOG.md | 1 + Nos/Assets/Localization/Localizable.xcstrings | 47 ++++-- Nos/Controller/PagedNoteDataSource.swift | 30 +++- Nos/Models/CoreData/Event+CoreDataClass.swift | 33 ++-- Nos/Service/Relay/Filter.swift | 4 +- Nos/Service/Relay/RelayService.swift | 1 + .../Relay/RelaySubscriptionManager.swift | 1 + Nos/Views/Home/HomeFeedView.swift | 124 ++++++++++---- Nos/Views/PagedNoteListView.swift | 22 ++- Nos/Views/Profile/ProfileView.swift | 3 +- Nos/Views/RelayPicker.swift | 154 ++++++++++++------ 11 files changed, 301 insertions(+), 119 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 593c0da78..ead89eeb7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Fix a bug where multiple connections could be opened with the same relay. - Fixed an issue where Profile views would sometimes not display any notes. - Add impersonation flag category and better NIP-56 mapping. +- Added a filter button to the Home tab that lets you browse all notes on a specific relay. - Add a Tap to Refresh button in empty profiles. - Support nostr:naddr links to text and long-form content notes. - Update the reply count shown below each note in a Feed. diff --git a/Nos/Assets/Localization/Localizable.xcstrings b/Nos/Assets/Localization/Localizable.xcstrings index 9deca4c4a..547a7e97b 100644 --- a/Nos/Assets/Localization/Localizable.xcstrings +++ b/Nos/Assets/Localization/Localizable.xcstrings @@ -433,6 +433,17 @@ } } }, + "accountsIFollow" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Accounts I Follow" + } + } + } + }, "activity" : { "extractionState" : "manual", "localizations" : { @@ -4316,6 +4327,18 @@ } } }, + "filter" : { + "comment" : "verb for filtering your feed in various ways", + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "filter" + } + } + } + }, "flagUser" : { "extractionState" : "manual", "localizations" : { @@ -7151,73 +7174,73 @@ "localizations" : { "ar" : { "stringUnit" : { - "state" : "translated", + "state" : "needs_review", "value" : "لا يوجد ملاحظات (بعد)! اطلع على علامة التصفح واتبع بعض الأشخاص للبدء." } }, "de" : { "stringUnit" : { - "state" : "translated", + "state" : "needs_review", "value" : "(Noch) keine Notizen! Durchsuche das Tab \\\"Entdecken\\\" und folge einigen Leuten, um loszulegen." } }, "en" : { "stringUnit" : { "state" : "translated", - "value" : "No notes (yet)! Browse the Discover tab and follow some people to get started." + "value" : "No notes (yet), but we'll keep looking!" } }, "es" : { "stringUnit" : { - "state" : "translated", + "state" : "needs_review", "value" : "¡No hay notas (aún)! Navega a la pestaña de Descubrir y sigue a algunas personas para empezar." } }, "fa" : { "stringUnit" : { - "state" : "translated", + "state" : "needs_review", "value" : "(هنوز) هیچ یادداشتی نیست! برای شروع سربرگ یافتن را بگردید و چند نفر را دنبال کنید." } }, "fr" : { "stringUnit" : { - "state" : "translated", + "state" : "needs_review", "value" : "Aucune note (pour l'instant) ! Allez sur l'onglet \\\"Découvrir\\\" et suivez des gens pour commencer." } }, "ja" : { "stringUnit" : { - "state" : "translated", + "state" : "needs_review", "value" : "メモはまだありません!ディスカバー / 検索 タブをタップして、何人かのユーザーをフォローして始めましょう。" } }, "nl" : { "stringUnit" : { - "state" : "translated", + "state" : "needs_review", "value" : "Nog geen notes! Blader de Ontdek tab en volg enkele accounts om te beginnen." } }, "pt-BR" : { "stringUnit" : { - "state" : "translated", + "state" : "needs_review", "value" : "Sem notas (ainda)! Navegue na guia \"Descobrir\" e siga algumas pessoas para começar." } }, "sv" : { "stringUnit" : { - "state" : "translated", + "state" : "needs_review", "value" : "Inga anteckningar (ännu)! Bläddra i fliken Upptäck och följ några personer för att komma igång." } }, "zh-Hans" : { "stringUnit" : { - "state" : "translated", + "state" : "needs_review", "value" : "(还)没有笔记!浏览发现选项卡并关注一些帐户以开始操作。" } }, "zh-Hant" : { "stringUnit" : { - "state" : "translated", + "state" : "needs_review", "value" : "(還)沒有筆記!瀏覽發現選項卡並關注一些帳戶以開始操作。" } } diff --git a/Nos/Controller/PagedNoteDataSource.swift b/Nos/Controller/PagedNoteDataSource.swift index 0d91af550..2fd866537 100644 --- a/Nos/Controller/PagedNoteDataSource.swift +++ b/Nos/Controller/PagedNoteDataSource.swift @@ -11,6 +11,9 @@ class PagedNoteDataSource: NSObject, UICol var collectionView: UICollectionView @Dependency(\.relayService) private var relayService: RelayService + private(set) var databaseFilter: NSFetchRequest + private(set) var relayFilter: Filter + private(set) var relay: Relay? private var pager: PagedRelaySubscription? private var context: NSManagedObjectContext private var header: () -> Header @@ -26,12 +29,14 @@ class PagedNoteDataSource: NSObject, UICol init( databaseFilter: NSFetchRequest, relayFilter: Filter, + relay: Relay?, collectionView: UICollectionView, context: NSManagedObjectContext, @ViewBuilder header: @escaping () -> Header, @ViewBuilder emptyPlaceholder: @escaping (@escaping () -> Void) -> EmptyPlaceholder, onRefresh: @escaping () -> NSFetchRequest ) { + self.databaseFilter = databaseFilter self.fetchedResultsController = NSFetchedResultsController( fetchRequest: databaseFilter, managedObjectContext: context, @@ -40,6 +45,8 @@ class PagedNoteDataSource: NSObject, UICol ) self.collectionView = collectionView self.context = context + self.relayFilter = relayFilter + self.relay = relay self.header = header self.emptyPlaceholder = emptyPlaceholder self.onRefresh = onRefresh @@ -67,15 +74,23 @@ class PagedNoteDataSource: NSObject, UICol Log.error(error) } - Task { - var limitedFilter = relayFilter + subscribeToEvents(matching: relayFilter, from: relay) + } + + func subscribeToEvents(matching filter: Filter, from relay: Relay?) { + self.relayFilter = filter + self.relay = relay + + Task { + var limitedFilter = filter limitedFilter.limit = pageSize - self.pager = await relayService.subscribeToPagedEvents(matching: limitedFilter) + self.pager = await relayService.subscribeToPagedEvents(matching: limitedFilter, from: relay?.addressURL) loadMoreIfNeeded(for: IndexPath(row: 0, section: 0)) } } func updateFetchRequest(_ fetchRequest: NSFetchRequest) { + self.databaseFilter = fetchRequest self.fetchedResultsController = NSFetchedResultsController( fetchRequest: fetchRequest, managedObjectContext: context, @@ -86,6 +101,7 @@ class PagedNoteDataSource: NSObject, UICol try? self.fetchedResultsController.performFetch() loadMoreIfNeeded(for: IndexPath(row: 0, section: 0)) collectionView.reloadData() + collectionView.setContentOffset(.zero, animated: false) } // MARK: - UICollectionViewDataSource @@ -208,7 +224,9 @@ class PagedNoteDataSource: NSObject, UICol startAggressivePaging() return } else if indexPath.row.isMultiple(of: pageSize / 2) { - Task { await pager?.loadMore() } + Task { + await pager?.loadMore() + } } } @@ -241,7 +259,9 @@ class PagedNoteDataSource: NSObject, UICol if self.largestLoadedRowIndex > lastPageStartIndex { // we are still on the last page of results, keep loading - Task { await self.pager?.loadMore() } + Task { + await self.pager?.loadMore() + } } else { // we've loaded enough, go back to normal paging self.stopAggressivePaging() diff --git a/Nos/Models/CoreData/Event+CoreDataClass.swift b/Nos/Models/CoreData/Event+CoreDataClass.swift index 11f4e21b9..686832675 100644 --- a/Nos/Models/CoreData/Event+CoreDataClass.swift +++ b/Nos/Models/CoreData/Event+CoreDataClass.swift @@ -325,21 +325,32 @@ public class Event: NosManagedObject, VerifiableEvent { return fetchRequest } - @nonobjc public class func homeFeedPredicate(for user: Author, before: Date) -> NSPredicate { - NSPredicate( - // swiftlint:disable line_length - format: "((kind = 1 AND SUBQUERY(eventReferences, $reference, $reference.marker = 'root' OR $reference.marker = 'reply' OR $reference.marker = nil).@count = 0) OR kind = 6 OR kind = 30023) AND (ANY author.followers.source = %@ OR author = %@) AND author.muted = 0 AND createdAt <= %@ AND deletedOn.@count = 0", - // swiftlint:enable line_length - user, - user, - before as CVarArg - ) + @nonobjc public class func homeFeedPredicate( + for user: Author, + before: Date, + seenOn relay: Relay? = nil + ) -> NSPredicate { + // swiftlint:disable:next line_length + var queryString = "((kind = 1 AND SUBQUERY(eventReferences, $reference, $reference.marker = 'root' OR $reference.marker = 'reply' OR $reference.marker = nil).@count = 0) OR kind = 6 OR kind = 30023) AND author.muted = 0 AND createdAt <= %@ AND deletedOn.@count = 0" + var arguments: [CVarArg] = [before as CVarArg] + if let relay { + queryString.append(" AND ANY seenOnRelays = %@") + arguments.append(relay) + } else { + queryString.append(" AND (ANY author.followers.source = %@ OR author = %@)") + arguments += [user, user] + } + return NSPredicate(format: queryString, argumentArray: arguments) } - @nonobjc public class func homeFeed(for user: Author, before: Date) -> NSFetchRequest { + @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) + fetchRequest.predicate = homeFeedPredicate(for: user, before: before, seenOn: relay) return fetchRequest } diff --git a/Nos/Service/Relay/Filter.swift b/Nos/Service/Relay/Filter.swift index 1248042ea..7c5a99830 100644 --- a/Nos/Service/Relay/Filter.swift +++ b/Nos/Service/Relay/Filter.swift @@ -5,13 +5,13 @@ import Foundation struct Filter: Hashable, Identifiable { /// List of author identifiers the Filter should be constrained to. - let authorKeys: [RawAuthorID] + var authorKeys: [RawAuthorID] /// List of event identifiers the Filter should be constrained to. let eventIDs: [RawEventID] /// List of Note kinds to filter - let kinds: [EventKind] + var kinds: [EventKind] /// An array of replaceable identifiers, or `"d"` tags, to match. let dTags: [RawReplaceableID] diff --git a/Nos/Service/Relay/RelayService.swift b/Nos/Service/Relay/RelayService.swift index dd63759fd..18ede3a84 100644 --- a/Nos/Service/Relay/RelayService.swift +++ b/Nos/Service/Relay/RelayService.swift @@ -341,6 +341,7 @@ extension RelayService { if let subID = responseArray[1] as? String, let subscription = await subscriptionManager.subscription(from: subID), subscription.closesAfterResponse { + Log.debug("\(socket.host) has finished responding on \(subID). Closing subscription.") // This is a one-off request. Close it. await sendClose(from: socket, subscriptionID: subID) } diff --git a/Nos/Service/Relay/RelaySubscriptionManager.swift b/Nos/Service/Relay/RelaySubscriptionManager.swift index 80bdaea56..c2d4ee11d 100644 --- a/Nos/Service/Relay/RelaySubscriptionManager.swift +++ b/Nos/Service/Relay/RelaySubscriptionManager.swift @@ -331,6 +331,7 @@ actor RelaySubscriptionManagerActor: RelaySubscriptionManager { } // MARK: - Error Tracking + /// This constant is used to calculate the maximum amount of time we will wait before retrying an errored socket. /// We backoff exponentially for 2^x seconds, increasing x by 1 on each consecutive error /// until x == `maxBackoffPower`. diff --git a/Nos/Views/Home/HomeFeedView.swift b/Nos/Views/Home/HomeFeedView.swift index de2ba11d6..adff65873 100644 --- a/Nos/Views/Home/HomeFeedView.swift +++ b/Nos/Views/Home/HomeFeedView.swift @@ -14,19 +14,28 @@ struct HomeFeedView: View { @FetchRequest private var authors: FetchedResults @State private var lastRefreshDate = Date( - timeIntervalSince1970: Date.now.timeIntervalSince1970 + Double(Self.initialLoadTime) + timeIntervalSince1970: Date.now.timeIntervalSince1970 + Double(Self.staticLoadTime) ) @State private var isVisible = false @State private var relaySubscriptions = [SubscriptionCancellable]() - @State private var performingInitialLoad = true @State private var isShowingRelayList = false - static let initialLoadTime = 2 + + /// When set to true this will display a fullscreen progress wheel for a set amount of time to give us a chance + /// to get some data from relay. The amount of time is defined in `staticLoadTime`. + @State private var showTimedLoadingIndicator = true + + /// The amount of time (in seconds) the loading indicator will be shown when showTimedLoadingIndicator is set to + /// true. + static let staticLoadTime: TimeInterval = 2 @ObservedObject var user: Author @State private var stories: [Author] = [] @State private var selectedStoryAuthor: Author? @State private var storiesCutoffDate = Calendar.current.date(byAdding: .day, value: -2, to: .now)! + + @State private var showRelayPicker = false + @State private var selectedRelay: Relay? private var isShowingStories: Bool { selectedStoryAuthor != nil @@ -41,6 +50,28 @@ struct HomeFeedView: View { ) } + var homeFeedFetchRequest: NSFetchRequest { + Event.homeFeed(for: user, before: lastRefreshDate, seenOn: selectedRelay) + } + + var homeFeedFilter: Filter { + var filter = Filter(kinds: [.text, .delete, .repost, .longFormContent, .report]) + if selectedRelay == nil { + filter.authorKeys = user.followedKeys.sorted() + } + return filter + } + + var navigationBarTitle: LocalizedStringResource { + if let relayName = selectedRelay?.host { + LocalizedStringResource(stringLiteral: relayName) + } else if isShowingStories { + .localizable.stories + } else { + .localizable.accountsIFollow + } + } + /// Downloads the data we need to show stories. func downloadStories() async { relaySubscriptions.removeAll() @@ -60,23 +91,23 @@ struct HomeFeedView: View { var body: some View { ZStack { - let homeFeedFilter = Filter( - authorKeys: user.followedKeys, - kinds: [.text, .delete, .repost, .longFormContent, .report], - limit: 100, - since: nil, - keepSubscriptionOpen: true - ) PagedNoteListView( - databaseFilter: Event.homeFeed(for: user, before: lastRefreshDate), + databaseFilter: homeFeedFetchRequest, relayFilter: homeFeedFilter, + relay: selectedRelay, context: viewContext, tab: .home, header: { - AuthorStoryCarousel( - authors: $stories, - selectedStoryAuthor: $selectedStoryAuthor - ) + Group { + if selectedRelay == nil { + AuthorStoryCarousel( + authors: $stories, + selectedStoryAuthor: $selectedStoryAuthor + ) + } else { + EmptyView() + } + } }, emptyPlaceholder: { _ in VStack { @@ -106,12 +137,29 @@ struct HomeFeedView: View { .opacity(isShowingStories ? 1 : 0) .animation(.default, value: selectedStoryAuthor) - if performingInitialLoad { + if showTimedLoadingIndicator { FullscreenProgressView( - isPresented: $performingInitialLoad, - hideAfter: .now() + .seconds(Self.initialLoadTime) + isPresented: $showTimedLoadingIndicator, + hideAfter: .now() + .seconds(Int(Self.staticLoadTime)) ) } + + if showRelayPicker { + RelayPicker( + selectedRelay: $selectedRelay, + defaultSelection: String(localized: .localizable.accountsIFollow), + author: user, + isPresented: $showRelayPicker + ) + .onChange(of: selectedRelay) { _, _ in + showTimedLoadingIndicator = true + Task { + withAnimation { + showRelayPicker = false + } + } + } + } } .doubleTapToPop(tab: .home) { _ in if isShowingStories { @@ -138,29 +186,21 @@ struct HomeFeedView: View { .animation(.default, value: selectedStoryAuthor) } } else { - Button { - isShowingRelayList = true - } label: { - HStack(spacing: 3) { - Image("relay-left") - .colorMultiply(relayService.numberOfConnectedRelays > 0 ? .white : .red) - Text("\(relayService.numberOfConnectedRelays)") - .font(.clarity(.bold, textStyle: .title3)) - .foregroundColor(.primaryTxt) - Image("relay-right") - .colorMultiply(relayService.numberOfConnectedRelays > 0 ? .white : .red) - } - } - .sheet(isPresented: $isShowingRelayList) { - NavigationView { - RelayView(author: user) + Button { + withAnimation { + showRelayPicker.toggle() } + } label: { + Image(systemName: "line.3.horizontal.decrease.circle") + .foregroundStyle(Color.secondaryTxt) + .accessibilityLabel(Text(.localizable.filter)) } + .frame(minWidth: 40, minHeight: 40) } } } .padding(.top, 1) - .nosNavigationBar(title: isShowingStories ? .localizable.stories : .localizable.homeFeed) + .nosNavigationBar(title: navigationBarTitle) .task { await downloadStories() } @@ -192,11 +232,23 @@ struct HomeFeedView: View { #Preview { var previewData = PreviewData() + func createTestData() { + let user = previewData.alice + let addresses = Relay.recommended + addresses.forEach { address in + let relay = try? Relay.findOrCreate(by: address, context: previewData.previewContext) + relay?.relayDescription = "A Nostr relay that aims to cultivate a healthy community." + relay?.addToAuthors(user) + } + + _ = previewData.shortNote + } + return NavigationStack { HomeFeedView(user: previewData.alice) } .inject(previewData: previewData) .onAppear { - _ = previewData.shortNote + createTestData() } } diff --git a/Nos/Views/PagedNoteListView.swift b/Nos/Views/PagedNoteListView.swift index 7604f677d..288fb6402 100644 --- a/Nos/Views/PagedNoteListView.swift +++ b/Nos/Views/PagedNoteListView.swift @@ -25,6 +25,8 @@ struct PagedNoteListView: UIViewRepresenta /// by the `databaseFilter`. let relayFilter: Filter + let relay: Relay? + let context: NSManagedObjectContext /// The tab in which this PagedNoteListView appears. @@ -55,6 +57,7 @@ struct PagedNoteListView: UIViewRepresenta let dataSource = context.coordinator.dataSource( databaseFilter: databaseFilter, relayFilter: relayFilter, + relay: relay, collectionView: collectionView, context: self.context, header: header, @@ -81,8 +84,17 @@ struct PagedNoteListView: UIViewRepresenta return collectionView } - func updateUIView(_ collectionView: UICollectionView, context: Context) {} - + func updateUIView(_ collectionView: UICollectionView, context: Context) { + if let dataSource = collectionView.dataSource as? PagedNoteDataSource { + if relayFilter != dataSource.relayFilter || relay != dataSource.relay { + dataSource.subscribeToEvents(matching: relayFilter, from: relay) + } + if databaseFilter != dataSource.databaseFilter { + dataSource.updateFetchRequest(databaseFilter) + } + } + } + static func dismantleUIView(_ uiView: UICollectionView, coordinator: Coordinator) { tearDownObservers(coordinator: coordinator) } @@ -164,6 +176,7 @@ struct PagedNoteListView: UIViewRepresenta func dataSource( databaseFilter: NSFetchRequest, relayFilter: Filter, + relay: Relay?, collectionView: UICollectionView, context: NSManagedObjectContext, @ViewBuilder header: @escaping () -> CoordinatorHeader, @@ -179,6 +192,7 @@ struct PagedNoteListView: UIViewRepresenta let dataSource = PagedNoteDataSource( databaseFilter: databaseFilter, relayFilter: relayFilter, + relay: relay, collectionView: collectionView, context: context, header: header, @@ -192,7 +206,6 @@ struct PagedNoteListView: UIViewRepresenta @objc func refreshData(_ sender: Any) { if let onRefresh { dataSource?.updateFetchRequest(onRefresh()) - collectionView?.reloadData() } if let refreshControl = sender as? UIRefreshControl { @@ -217,7 +230,8 @@ extension Notification.Name { return PagedNoteListView( databaseFilter: previewData.alice.allPostsRequest(onlyRootPosts: false), - relayFilter: Filter(keepSubscriptionOpen: true), + relayFilter: Filter(), + relay: nil, context: previewData.previewContext, tab: .home, header: { diff --git a/Nos/Views/Profile/ProfileView.swift b/Nos/Views/Profile/ProfileView.swift index f7dfe99f8..e09c26175 100644 --- a/Nos/Views/Profile/ProfileView.swift +++ b/Nos/Views/Profile/ProfileView.swift @@ -81,7 +81,8 @@ struct ProfileView: View { VStack { PagedNoteListView( databaseFilter: selectedTab.databaseFilter(author: author), - relayFilter: selectedTab.relayFilter(author: author), + relayFilter: selectedTab.relayFilter(author: author), + relay: nil, context: viewContext, tab: .profile, header: { diff --git a/Nos/Views/RelayPicker.swift b/Nos/Views/RelayPicker.swift index cb9ffe670..40c06be9e 100644 --- a/Nos/Views/RelayPicker.swift +++ b/Nos/Views/RelayPicker.swift @@ -18,27 +18,54 @@ struct RelayPicker: View { } var body: some View { - VStack { - ScrollView { - VStack(spacing: 0) { - // TODO: scrolling - RelayPickerRow(string: defaultSelection, selection: $selectedRelay) - ForEach(relays) { relay in - - BeveledSeparator() - .padding(.horizontal, 20) - - RelayPickerRow(relay: relay, selection: $selectedRelay) - .fixedSize(horizontal: false, vertical: true) + VStack(spacing: 0) { + + let pickerRows = VStack(spacing: 0) { + // shadow effect at the top + Rectangle() + .frame(height: 1) + .foregroundStyle(.clear) + .shadow(radius: 15, y: 10) + + RelayPickerRow(string: defaultSelection, selection: $selectedRelay) + ForEach(relays) { relay in + + BeveledSeparator() + .padding(.horizontal, 20) + + RelayPickerRow(relay: relay, selection: $selectedRelay) + .fixedSize(horizontal: false, vertical: true) + } + } + .background( + Rectangle() + .foregroundStyle(LinearGradient.cardBackground) + .cornerRadius(20, corners: [.bottomLeft, .bottomRight]) + .shadow(radius: 15, y: 10) + ) + .readabilityPadding() + + VStack(spacing: 0) { + ViewThatFits(in: .vertical) { + VStack { + pickerRows + Color.clear + .contentShape(Rectangle()) + .frame(minHeight: 0) + .onTapGesture { + withAnimation { + isPresented = false + } + } + } + ScrollView { + pickerRows } } - .cornerRadius(15, corners: [.bottomLeft, .bottomRight]) } - Spacer() } - .background(LinearGradient.cardBackground) - .frame(maxWidth: .infinity, maxHeight: .infinity) .transition(.move(edge: .top)) + .frame(maxWidth: .infinity, maxHeight: .infinity) .zIndex(99) // Fixes dismissal animation } } @@ -80,12 +107,29 @@ struct RelayPickerRow: View { selection = relay } label: { HStack { - Text(title) - .foregroundColor(.primaryTxt) - .font(.clarity(.bold)) - .lineLimit(1) - .padding(.horizontal, 19) - .padding(.vertical, 19) + VStack(spacing: 6) { + HStack { + Text(title) + .foregroundColor(.primaryTxt) + .font(.clarity(.bold)) + .lineLimit(1) + .shadow(radius: 4, y: 4) + Spacer() + } + + if let description = relay?.relayDescription { + HStack { + Text(description) + .font(.clarityRegular(.callout)) + .multilineTextAlignment(.leading) + .foregroundColor(.secondaryTxt) + .lineLimit(2) + Spacer() + } + } + } + .padding(.horizontal, 14) + .padding(.vertical, 14) Spacer() if isSelected { Image(systemName: "checkmark") @@ -100,40 +144,54 @@ struct RelayPickerRow: View { } } -struct RelayPicker_Previews: PreviewProvider { +#Preview("Without ScrollView") { - static var previewData = PreviewData() - static var persistenceController = PersistenceController.preview - static var previewContext = persistenceController.container.viewContext - static var relayService = previewData.relayService + @State var selectedRelay: Relay? + var previewData = PreviewData() - static var user: Author { - let author = Author(context: previewContext) - author.hexadecimalPublicKey = KeyFixture.alice.publicKeyHex - createTestData(in: previewContext, user: author) - return author - } - - static func createTestData(in context: NSManagedObjectContext, user: Author) { + func createTestData() { + let user = previewData.alice let addresses = ["wss://nostr.com", "wss://nos.social", "wss://alongdomainnametoseewhathappens.com"] addresses.forEach { address in - let relay = try? Relay.findOrCreate(by: address, context: previewContext) + let relay = try? Relay.findOrCreate(by: address, context: previewData.previewContext) + relay?.relayDescription = "A Nostr relay that aims to cultivate a healthy community." relay?.addToAuthors(user) } - - try? previewContext.save() } - @State static var selectedRelay: Relay? + return RelayPicker( + selectedRelay: $selectedRelay, + defaultSelection: String(localized: .localizable.allMyRelays), + author: previewData.alice, + isPresented: .constant(true) + ) + .onAppear { createTestData() } + .inject(previewData: previewData) + .background(Color.appBg) +} + +#Preview("With ScrollView") { + + @State var selectedRelay: Relay? + var previewData = PreviewData() - static var previews: some View { - RelayPicker( - selectedRelay: $selectedRelay, - defaultSelection: String(localized: .localizable.allMyRelays), - author: user, - isPresented: .constant(true) - ) - .environment(\.managedObjectContext, previewContext) - .background(Color.appBg) + func createTestData() { + let user = previewData.alice + let addresses = Relay.allKnown + addresses.forEach { address in + let relay = try? Relay.findOrCreate(by: address, context: previewData.previewContext) + relay?.relayDescription = "A Nostr relay that aims to cultivate a healthy community." + relay?.addToAuthors(user) + } } + + return RelayPicker( + selectedRelay: $selectedRelay, + defaultSelection: String(localized: .localizable.allMyRelays), + author: previewData.alice, + isPresented: .constant(true) + ) + .onAppear { createTestData() } + .inject(previewData: previewData) + .background(Color.appBg) } From 0e7248e1e62f76e5e5149ae402716af7578a47f4 Mon Sep 17 00:00:00 2001 From: mplorentz Date: Wed, 24 Jul 2024 11:40:34 -0400 Subject: [PATCH 2/8] Fix random scrolls to top on ProfileView --- Nos/Views/Profile/ProfileFeedType.swift | 4 ++-- Nos/Views/Profile/ProfileView.swift | 6 ++++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/Nos/Views/Profile/ProfileFeedType.swift b/Nos/Views/Profile/ProfileFeedType.swift index dff30ef3d..c19e9d503 100644 --- a/Nos/Views/Profile/ProfileFeedType.swift +++ b/Nos/Views/Profile/ProfileFeedType.swift @@ -5,8 +5,8 @@ enum ProfileFeedType { case activity case notes - func databaseFilter(author: Author) -> NSFetchRequest { - author.allPostsRequest(onlyRootPosts: self == .notes) + func databaseFilter(author: Author, before date: Date) -> NSFetchRequest { + author.allPostsRequest(before: date, onlyRootPosts: self == .notes) } func relayFilter(author: Author) -> Filter { diff --git a/Nos/Views/Profile/ProfileView.swift b/Nos/Views/Profile/ProfileView.swift index e09c26175..09bb79e36 100644 --- a/Nos/Views/Profile/ProfileView.swift +++ b/Nos/Views/Profile/ProfileView.swift @@ -19,6 +19,7 @@ struct ProfileView: View { @State private var showingOptions = false @State private var showingReportMenu = false @State private var relaySubscriptions = SubscriptionCancellables() + @State private var lastRefreshDate = Date.now @State private var selectedTab: ProfileFeedType = .notes @@ -80,7 +81,7 @@ struct ProfileView: View { VStack(spacing: 0) { VStack { PagedNoteListView( - databaseFilter: selectedTab.databaseFilter(author: author), + databaseFilter: selectedTab.databaseFilter(author: author, before: lastRefreshDate), relayFilter: selectedTab.relayFilter(author: author), relay: nil, context: viewContext, @@ -104,7 +105,8 @@ struct ProfileView: View { .frame(minHeight: 300) }, onRefresh: { - selectedTab.databaseFilter(author: author) + lastRefreshDate = .now + return selectedTab.databaseFilter(author: author, before: lastRefreshDate) } ) .padding(0) From b84dc0a1cb02d0510b7b2c40edcecb35acedcb51 Mon Sep 17 00:00:00 2001 From: mplorentz Date: Wed, 24 Jul 2024 17:19:22 -0400 Subject: [PATCH 3/8] Fixed an issue where using the relay picker wouldn't show the latest notes --- Nos/Views/Home/HomeFeedView.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Nos/Views/Home/HomeFeedView.swift b/Nos/Views/Home/HomeFeedView.swift index adff65873..fc907762f 100644 --- a/Nos/Views/Home/HomeFeedView.swift +++ b/Nos/Views/Home/HomeFeedView.swift @@ -153,6 +153,7 @@ struct HomeFeedView: View { ) .onChange(of: selectedRelay) { _, _ in showTimedLoadingIndicator = true + lastRefreshDate = .now + Self.staticLoadTime Task { withAnimation { showRelayPicker = false From 6596e615d9d48c2b3d8561d84c78a41e3123b07a Mon Sep 17 00:00:00 2001 From: mplorentz Date: Wed, 24 Jul 2024 17:35:51 -0400 Subject: [PATCH 4/8] Added catch-up routine to paging code --- Nos/Controller/PagedNoteDataSource.swift | 17 +++++- .../Relay/PagedRelaySubscription.swift | 57 +++++++++++++++---- 2 files changed, 59 insertions(+), 15 deletions(-) diff --git a/Nos/Controller/PagedNoteDataSource.swift b/Nos/Controller/PagedNoteDataSource.swift index 2fd866537..d5d6e5f1d 100644 --- a/Nos/Controller/PagedNoteDataSource.swift +++ b/Nos/Controller/PagedNoteDataSource.swift @@ -224,8 +224,9 @@ class PagedNoteDataSource: NSObject, UICol startAggressivePaging() return } else if indexPath.row.isMultiple(of: pageSize / 2) { + let displayedDate = displayedDate(for: indexPath.row) Task { - await pager?.loadMore() + await pager?.loadMore(displayingContentAt: displayedDate) } } } @@ -249,7 +250,11 @@ class PagedNoteDataSource: NSObject, UICol /// `loadMoreIfNeeded(for:)` won't be called which means we'll never ask for the next page. So we need the timer. private func startAggressivePaging() { if aggressivePagingTimer == nil { - aggressivePagingTimer = Timer.scheduledTimer(withTimeInterval: 0.25, repeats: true) { [weak self] timer in + // Fire manually once because the timer doesn't fire immediately + let displayedDate = self.displayedDate(for: largestLoadedRowIndex) + Task { await self.pager?.loadMore(displayingContentAt: displayedDate) } + + aggressivePagingTimer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { [weak self] timer in guard let self else { timer.invalidate() return @@ -259,8 +264,9 @@ class PagedNoteDataSource: NSObject, UICol if self.largestLoadedRowIndex > lastPageStartIndex { // we are still on the last page of results, keep loading + let displayedDate = self.displayedDate(for: largestLoadedRowIndex) Task { - await self.pager?.loadMore() + await self.pager?.loadMore(displayingContentAt: displayedDate) } } else { // we've loaded enough, go back to normal paging @@ -278,6 +284,11 @@ class PagedNoteDataSource: NSObject, UICol } } + /// Returns the `created_at` date of the event at the given index, if one exists. + private func displayedDate(for index: Int) -> Date? { + fetchedResultsController.fetchedObjects?[safe: index]?.createdAt + } + // MARK: - NSFetchedResultsControllerDelegate private var insertedIndexes = [IndexPath]() diff --git a/Nos/Service/Relay/PagedRelaySubscription.swift b/Nos/Service/Relay/PagedRelaySubscription.swift index 0bf1de6a2..313c47884 100644 --- a/Nos/Service/Relay/PagedRelaySubscription.swift +++ b/Nos/Service/Relay/PagedRelaySubscription.swift @@ -5,6 +5,12 @@ import Combine /// 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. +/// +/// Paging in Nostr is very different from traditional HTTP paging, because we can't just ask for "the next 20 events +/// after index 100". Instead we have to use dates and ask for "the next 20 events older than X". Moreover because we +/// are fetching from a lot of relays the date X is different for every relay. `PagedRelaySubscription` abstracts away +/// these details, so the caller basically only needs to know what kinds of events they want from what relays, and then +/// call `loadMore()` whenever they user scrolls a page. @RelaySubscriptionManagerActor class PagedRelaySubscription { let startDate: Date @@ -23,7 +29,7 @@ class PagedRelaySubscription { private let relayAddresses: Set /// The oldest event each relay has returned. Used to load the next page. - private var oldestEventByRelay = [URL: Date]() + var oldestEventByRelay = [URL: Date]() private var cancellables = [AnyCancellable]() @@ -69,9 +75,10 @@ class PagedRelaySubscription { } } - /// 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() { + /// Instructs the pager to load the next page of events from each relay. The given date should be roughly the date + /// of the content the user is looking at and is used to put a given realy into "catch up" mode where we fetch + /// more events to catch up what the user is looking at. + func loadMore(displayingContentAt displayedDate: Date? = nil) { Task { [self] in // Remove old subscriptions for subscriptionID in pagedSubscriptionIDs { @@ -82,17 +89,39 @@ class PagedRelaySubscription { // Open new subscriptions for relayAddress in relayAddresses { - let newPageStartDate = oldestEventByRelay[relayAddress] ?? startDate - var newPageFilter = self.filter - newPageFilter.until = newPageStartDate - newPageFilter.keepSubscriptionOpen = false - let pagedEventSubscription = await subscriptionManager.queueSubscription( - with: newPageFilter, + // To fetch the next "page" we need to know what the last event we got was, then we ask for the next + // `limit` events older than that. + let nextPageStartDate = oldestEventByRelay[relayAddress] ?? startDate + var nextPageFilter = self.filter + nextPageFilter.until = nextPageStartDate + nextPageFilter.keepSubscriptionOpen = false + + // If the most recent event we got is older than what the user is looking at, open an extra subscription + // with no limit so we can "catch up". + if let displayedDate, nextPageStartDate >= displayedDate { + var catchUpFilter = nextPageFilter + catchUpFilter.since = displayedDate + catchUpFilter.until = nextPageStartDate + catchUpFilter.limit = nil + let catchUpSubscription = await subscriptionManager.queueSubscription( + with: nextPageFilter, + to: relayAddress + ) + pagedSubscriptionIDs.insert(catchUpSubscription.id) + + nextPageFilter.until = displayedDate + } + + + let nextPageSubscription = await subscriptionManager.queueSubscription( + with: nextPageFilter, to: relayAddress ) - pagedEventSubscription.events + /// Keep track of the oldest event seen for this relay so we can use it when it's time to load the next + /// page. + nextPageSubscription.events .sink { [weak self] jsonEvent in Task { await self?.track(event: jsonEvent, from: relayAddress) @@ -100,17 +129,21 @@ class PagedRelaySubscription { } .store(in: &cancellables) - pagedSubscriptionIDs.insert(pagedEventSubscription.id) + pagedSubscriptionIDs.insert(nextPageSubscription.id) await subscriptionManager.processSubscriptionQueue() } } } + /// Used to record the oldest event we've seen from a relay. We need this because `track(event:from:)` is + /// nonisolated so it can be called from a Combine chain. func updateOldestEvent(for relay: URL, to date: Date) { oldestEventByRelay[relay] = date } + /// Records the `created_at` date from given event if it's the oldest one we've seen so far. This information + /// is needed to load the next page when it's time. nonisolated func track(event: JSONEvent, from relay: URL) async { if let oldestSeen = await oldestEventByRelay[relay] { if event.createdDate < oldestSeen { From aa29ddcafeb30276413c988d572a328200636bb0 Mon Sep 17 00:00:00 2001 From: mplorentz Date: Thu, 25 Jul 2024 11:30:42 -0400 Subject: [PATCH 5/8] Update Nos/Service/Relay/PagedRelaySubscription.swift Co-authored-by: Josh Brown --- Nos/Service/Relay/PagedRelaySubscription.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Nos/Service/Relay/PagedRelaySubscription.swift b/Nos/Service/Relay/PagedRelaySubscription.swift index 313c47884..143f868e3 100644 --- a/Nos/Service/Relay/PagedRelaySubscription.swift +++ b/Nos/Service/Relay/PagedRelaySubscription.swift @@ -10,7 +10,7 @@ import Combine /// after index 100". Instead we have to use dates and ask for "the next 20 events older than X". Moreover because we /// are fetching from a lot of relays the date X is different for every relay. `PagedRelaySubscription` abstracts away /// these details, so the caller basically only needs to know what kinds of events they want from what relays, and then -/// call `loadMore()` whenever they user scrolls a page. +/// call `loadMore()` whenever the user scrolls a page. @RelaySubscriptionManagerActor class PagedRelaySubscription { let startDate: Date From 8d5749a3eaa45cd8d1141fa5820038b633d8a990 Mon Sep 17 00:00:00 2001 From: mplorentz Date: Thu, 25 Jul 2024 11:30:51 -0400 Subject: [PATCH 6/8] Update Nos/Service/Relay/PagedRelaySubscription.swift Co-authored-by: Josh Brown --- Nos/Service/Relay/PagedRelaySubscription.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Nos/Service/Relay/PagedRelaySubscription.swift b/Nos/Service/Relay/PagedRelaySubscription.swift index 143f868e3..446d00c2d 100644 --- a/Nos/Service/Relay/PagedRelaySubscription.swift +++ b/Nos/Service/Relay/PagedRelaySubscription.swift @@ -29,7 +29,7 @@ class PagedRelaySubscription { private let relayAddresses: Set /// The oldest event each relay has returned. Used to load the next page. - var oldestEventByRelay = [URL: Date]() + private var oldestEventByRelay = [URL: Date]() private var cancellables = [AnyCancellable]() From 0844a8d98998a6d9a11671893355d2508fec58d5 Mon Sep 17 00:00:00 2001 From: mplorentz Date: Thu, 25 Jul 2024 11:31:41 -0400 Subject: [PATCH 7/8] Update Nos/Service/Relay/Filter.swift Co-authored-by: Josh Brown --- Nos/Service/Relay/Filter.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Nos/Service/Relay/Filter.swift b/Nos/Service/Relay/Filter.swift index 7c5a99830..9a9e3d9bc 100644 --- a/Nos/Service/Relay/Filter.swift +++ b/Nos/Service/Relay/Filter.swift @@ -11,7 +11,7 @@ struct Filter: Hashable, Identifiable { let eventIDs: [RawEventID] /// List of Note kinds to filter - var kinds: [EventKind] + let kinds: [EventKind] /// An array of replaceable identifiers, or `"d"` tags, to match. let dTags: [RawReplaceableID] From 4d0cb670b76796a0e8f451f4572e7eee3e2b272f Mon Sep 17 00:00:00 2001 From: mplorentz Date: Thu, 25 Jul 2024 11:37:29 -0400 Subject: [PATCH 8/8] Address PR feedback --- Nos/Controller/PagedNoteDataSource.swift | 6 +++--- Nos/Service/Relay/PagedRelaySubscription.swift | 1 - Nos/Views/PagedNoteListView.swift | 1 + 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Nos/Controller/PagedNoteDataSource.swift b/Nos/Controller/PagedNoteDataSource.swift index d5d6e5f1d..b5150eb3c 100644 --- a/Nos/Controller/PagedNoteDataSource.swift +++ b/Nos/Controller/PagedNoteDataSource.swift @@ -250,9 +250,6 @@ class PagedNoteDataSource: NSObject, UICol /// `loadMoreIfNeeded(for:)` won't be called which means we'll never ask for the next page. So we need the timer. private func startAggressivePaging() { if aggressivePagingTimer == nil { - // Fire manually once because the timer doesn't fire immediately - let displayedDate = self.displayedDate(for: largestLoadedRowIndex) - Task { await self.pager?.loadMore(displayingContentAt: displayedDate) } aggressivePagingTimer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { [weak self] timer in guard let self else { @@ -273,6 +270,9 @@ class PagedNoteDataSource: NSObject, UICol self.stopAggressivePaging() } } + + // Fire manually once because the timer doesn't fire immediately + aggressivePagingTimer?.fire() } } diff --git a/Nos/Service/Relay/PagedRelaySubscription.swift b/Nos/Service/Relay/PagedRelaySubscription.swift index 446d00c2d..628b7f0dd 100644 --- a/Nos/Service/Relay/PagedRelaySubscription.swift +++ b/Nos/Service/Relay/PagedRelaySubscription.swift @@ -113,7 +113,6 @@ class PagedRelaySubscription { nextPageFilter.until = displayedDate } - let nextPageSubscription = await subscriptionManager.queueSubscription( with: nextPageFilter, to: relayAddress diff --git a/Nos/Views/PagedNoteListView.swift b/Nos/Views/PagedNoteListView.swift index 288fb6402..f946b08a4 100644 --- a/Nos/Views/PagedNoteListView.swift +++ b/Nos/Views/PagedNoteListView.swift @@ -25,6 +25,7 @@ struct PagedNoteListView: UIViewRepresenta /// by the `databaseFilter`. let relayFilter: Filter + /// The relay to load data from. If `nil` then all the relays in the user's list will be used. let relay: Relay? let context: NSManagedObjectContext