diff --git a/CHANGELOG.md b/CHANGELOG.md index 24894dfa7..f5a09be28 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] - 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. - Update the reply count shown below each Note in a Feed. - Removed follower count from profile screen. diff --git a/Nos.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Nos.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 5a4eecff2..7292031fe 100644 --- a/Nos.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Nos.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -216,5 +216,5 @@ } } ], - "version" : 2 + "version": 2 } diff --git a/Nos/Assets/Localization/Localizable.xcstrings b/Nos/Assets/Localization/Localizable.xcstrings index 547a7e97b..9deca4c4a 100644 --- a/Nos/Assets/Localization/Localizable.xcstrings +++ b/Nos/Assets/Localization/Localizable.xcstrings @@ -433,17 +433,6 @@ } } }, - "accountsIFollow" : { - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Accounts I Follow" - } - } - } - }, "activity" : { "extractionState" : "manual", "localizations" : { @@ -4327,18 +4316,6 @@ } } }, - "filter" : { - "comment" : "verb for filtering your feed in various ways", - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "filter" - } - } - } - }, "flagUser" : { "extractionState" : "manual", "localizations" : { @@ -7174,73 +7151,73 @@ "localizations" : { "ar" : { "stringUnit" : { - "state" : "needs_review", + "state" : "translated", "value" : "لا يوجد ملاحظات (بعد)! اطلع على علامة التصفح واتبع بعض الأشخاص للبدء." } }, "de" : { "stringUnit" : { - "state" : "needs_review", + "state" : "translated", "value" : "(Noch) keine Notizen! Durchsuche das Tab \\\"Entdecken\\\" und folge einigen Leuten, um loszulegen." } }, "en" : { "stringUnit" : { "state" : "translated", - "value" : "No notes (yet), but we'll keep looking!" + "value" : "No notes (yet)! Browse the Discover tab and follow some people to get started." } }, "es" : { "stringUnit" : { - "state" : "needs_review", + "state" : "translated", "value" : "¡No hay notas (aún)! Navega a la pestaña de Descubrir y sigue a algunas personas para empezar." } }, "fa" : { "stringUnit" : { - "state" : "needs_review", + "state" : "translated", "value" : "(هنوز) هیچ یادداشتی نیست! برای شروع سربرگ یافتن را بگردید و چند نفر را دنبال کنید." } }, "fr" : { "stringUnit" : { - "state" : "needs_review", + "state" : "translated", "value" : "Aucune note (pour l'instant) ! Allez sur l'onglet \\\"Découvrir\\\" et suivez des gens pour commencer." } }, "ja" : { "stringUnit" : { - "state" : "needs_review", + "state" : "translated", "value" : "メモはまだありません!ディスカバー / 検索 タブをタップして、何人かのユーザーをフォローして始めましょう。" } }, "nl" : { "stringUnit" : { - "state" : "needs_review", + "state" : "translated", "value" : "Nog geen notes! Blader de Ontdek tab en volg enkele accounts om te beginnen." } }, "pt-BR" : { "stringUnit" : { - "state" : "needs_review", + "state" : "translated", "value" : "Sem notas (ainda)! Navegue na guia \"Descobrir\" e siga algumas pessoas para começar." } }, "sv" : { "stringUnit" : { - "state" : "needs_review", + "state" : "translated", "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" : "needs_review", + "state" : "translated", "value" : "(还)没有笔记!浏览发现选项卡并关注一些帐户以开始操作。" } }, "zh-Hant" : { "stringUnit" : { - "state" : "needs_review", + "state" : "translated", "value" : "(還)沒有筆記!瀏覽發現選項卡並關注一些帳戶以開始操作。" } } diff --git a/Nos/Controller/PagedNoteDataSource.swift b/Nos/Controller/PagedNoteDataSource.swift index 2fd866537..6cc56f057 100644 --- a/Nos/Controller/PagedNoteDataSource.swift +++ b/Nos/Controller/PagedNoteDataSource.swift @@ -11,9 +11,7 @@ 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 relayFilter: Filter private var pager: PagedRelaySubscription? private var context: NSManagedObjectContext private var header: () -> Header @@ -29,14 +27,12 @@ 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, @@ -46,7 +42,6 @@ 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 @@ -74,23 +69,15 @@ class PagedNoteDataSource: NSObject, UICol Log.error(error) } - subscribeToEvents(matching: relayFilter, from: relay) - } - - func subscribeToEvents(matching filter: Filter, from relay: Relay?) { - self.relayFilter = filter - self.relay = relay - - Task { - var limitedFilter = filter + Task { + var limitedFilter = relayFilter limitedFilter.limit = pageSize - self.pager = await relayService.subscribeToPagedEvents(matching: limitedFilter, from: relay?.addressURL) + self.pager = await relayService.subscribeToPagedEvents(matching: limitedFilter) loadMoreIfNeeded(for: IndexPath(row: 0, section: 0)) } } func updateFetchRequest(_ fetchRequest: NSFetchRequest) { - self.databaseFilter = fetchRequest self.fetchedResultsController = NSFetchedResultsController( fetchRequest: fetchRequest, managedObjectContext: context, @@ -100,8 +87,6 @@ class PagedNoteDataSource: NSObject, UICol self.fetchedResultsController.delegate = self try? self.fetchedResultsController.performFetch() loadMoreIfNeeded(for: IndexPath(row: 0, section: 0)) - collectionView.reloadData() - collectionView.setContentOffset(.zero, animated: false) } // MARK: - UICollectionViewDataSource @@ -224,9 +209,7 @@ class PagedNoteDataSource: NSObject, UICol startAggressivePaging() return } else if indexPath.row.isMultiple(of: pageSize / 2) { - Task { - await pager?.loadMore() - } + pager?.loadMore() } } @@ -259,9 +242,7 @@ class PagedNoteDataSource: NSObject, UICol if self.largestLoadedRowIndex > lastPageStartIndex { // we are still on the last page of results, keep loading - Task { - await self.pager?.loadMore() - } + 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 a4ca6b94e..fd1a22b0d 100644 --- a/Nos/Models/CoreData/Event+CoreDataClass.swift +++ b/Nos/Models/CoreData/Event+CoreDataClass.swift @@ -480,32 +480,21 @@ public class Event: NosManagedObject, VerifiableEvent { return fetchRequest } - @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 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 homeFeed( - for user: Author, - before: Date, - seenOn relay: Relay? = nil - ) -> NSFetchRequest { + @nonobjc public class func homeFeed(for user: Author, before: Date) -> NSFetchRequest { let fetchRequest = NSFetchRequest(entityName: "Event") fetchRequest.sortDescriptors = [NSSortDescriptor(keyPath: \Event.createdAt, ascending: false)] - fetchRequest.predicate = homeFeedPredicate(for: user, before: before, seenOn: relay) + fetchRequest.predicate = homeFeedPredicate(for: user, before: before) return fetchRequest } diff --git a/Nos/Models/RelaySubscription.swift b/Nos/Models/RelaySubscription.swift index 952da34b9..236d5a559 100644 --- a/Nos/Models/RelaySubscription.swift +++ b/Nos/Models/RelaySubscription.swift @@ -1,9 +1,8 @@ import Foundation import Logger -import Combine /// Models a request to a relay for Nostr Events. -class RelaySubscription: Identifiable, Hashable { +struct RelaySubscription: Identifiable, Hashable { var id: String @@ -15,6 +14,9 @@ class RelaySubscription: Identifiable, Hashable { /// 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 @@ -22,9 +24,6 @@ class RelaySubscription: Identifiable, Hashable { /// when a filter can be closed. var referenceCount: Int = 0 - /// An observable stream of events that should emit every event downloaded on this subscription - let events: PassthroughSubject = PassthroughSubject() - var isActive: Bool { subscriptionStartDate != nil } @@ -39,6 +38,7 @@ class RelaySubscription: Identifiable, Hashable { filter: Filter, relayAddress: URL, subscriptionStartDate: Date? = nil, + oldestEventCreationDate: Date? = nil, referenceCount: Int = 0 ) { self.filter = filter @@ -46,24 +46,7 @@ class RelaySubscription: Identifiable, Hashable { // Compute a unique ID but predictable ID. The sha256 cuts the length down to an acceptable size. self.id = (filter.id + "-" + relayAddress.absoluteString).data(using: .utf8)?.sha256 ?? "error" self.subscriptionStartDate = subscriptionStartDate + self.oldestEventCreationDate = oldestEventCreationDate self.referenceCount = referenceCount } - - static func == (lhs: RelaySubscription, rhs: RelaySubscription) -> Bool { - lhs.id == rhs.id && - lhs.filter == rhs.filter && - lhs.relayAddress == rhs.relayAddress && - lhs.subscriptionStartDate == rhs.subscriptionStartDate && - lhs.referenceCount == rhs.referenceCount && - lhs.isActive == rhs.isActive - } - - func hash(into hasher: inout Hasher) { - hasher.combine(id) - hasher.combine(filter) - hasher.combine(relayAddress) - hasher.combine(subscriptionStartDate) - hasher.combine(referenceCount) - hasher.combine(isActive) - } } diff --git a/Nos/Service/MockRelaySubscriptionManager.swift b/Nos/Service/MockRelaySubscriptionManager.swift index bbc6c1556..a0605d655 100644 --- a/Nos/Service/MockRelaySubscriptionManager.swift +++ b/Nos/Service/MockRelaySubscriptionManager.swift @@ -43,9 +43,9 @@ class MockRelaySubscriptionManager: RelaySubscriptionManager { } var queueSubscriptionFilter: Filter? - func queueSubscription(with filter: Filter, to relayAddress: URL) async -> RelaySubscription { + func queueSubscription(with filter: Filter, to relayAddress: URL) async -> RelaySubscription.ID { queueSubscriptionFilter = filter - return RelaySubscription(filter: filter, relayAddress: relayAddress) + return RelaySubscription.ID() } func remove(_ socket: any WebSocketClient) async { diff --git a/Nos/Service/Relay/Filter.swift b/Nos/Service/Relay/Filter.swift index 98d3a855e..9b55a5450 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. - var authorKeys: [RawAuthorID] + let authorKeys: [RawAuthorID] /// List of event identifiers the Filter should be constrained to. let eventIDs: [RawEventID] /// List of Note kinds to filter - var kinds: [EventKind] + let kinds: [EventKind] /// Ask the relay to return events mentioned in the `tags` field. let eTags: [RawEventID] @@ -69,7 +69,7 @@ struct Filter: Hashable, Identifiable { until: Date? = nil, keepSubscriptionOpen: Bool = false ) { - self.authorKeys = authorKeys.sorted() + self.authorKeys = authorKeys.sorted(by: { $0 > $1 }) self.eventIDs = eventIDs self.kinds = kinds.sorted(by: { $0.rawValue > $1.rawValue }) self.eTags = eTags diff --git a/Nos/Service/Relay/PagedRelaySubscription.swift b/Nos/Service/Relay/PagedRelaySubscription.swift index 8b610aa80..cf07fa1f2 100644 --- a/Nos/Service/Relay/PagedRelaySubscription.swift +++ b/Nos/Service/Relay/PagedRelaySubscription.swift @@ -1,11 +1,9 @@ import Foundation import Logger -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. -@RelaySubscriptionManagerActor +/// goes out of scope. class PagedRelaySubscription { let startDate: Date let filter: Filter @@ -19,14 +17,6 @@ class PagedRelaySubscription { /// A set of subscriptions always listening for new events published after the `startDate`. private var newEventsSubscriptionIDs = Set() - /// The relays we are fetching events from - private var relayAddresses: Set - - /// The oldest event each relay has returned. Used to load the next page. - private var oldestEventByRelay = [URL: Date]() - - private var cancellables = [AnyCancellable]() - init( startDate: Date, filter: Filter, @@ -38,7 +28,6 @@ class PagedRelaySubscription { self.filter = filter self.relayService = relayService self.subscriptionManager = subscriptionManager - self.relayAddresses = relayAddresses 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. @@ -46,16 +35,17 @@ class PagedRelaySubscription { pagedEventsFilter.until = startDate pagedEventsFilter.keepSubscriptionOpen = false var newEventsFilter = filter - newEventsFilter.since = startDate newEventsFilter.keepSubscriptionOpen = true newEventsFilter.limit = nil for relayAddress in relayAddresses { newEventsSubscriptionIDs.insert( - await subscriptionManager.queueSubscription(with: filter, to: relayAddress).id + await subscriptionManager.queueSubscription(with: filter, to: relayAddress) + ) + pagedSubscriptionIDs.insert( + await subscriptionManager.queueSubscription(with: pagedEventsFilter, to: relayAddress) ) } - loadMore() } } @@ -73,48 +63,33 @@ class PagedRelaySubscription { /// `Filter` and updating all its managed subscriptions. func loadMore() { Task { [self] in - // Remove old subscriptions + var newUntilDates = [URL: Date]() + var subscriptionsToRemove = Set() + for subscriptionID in pagedSubscriptionIDs { - relayService.decrementSubscriptionCount(for: subscriptionID) + if let subscription = await subscriptionManager.subscription(from: subscriptionID), + let newDate = subscription.oldestEventCreationDate { + + guard newDate != subscription.filter.until else { + // Optimization. Don't close and reopen an identical filter. + continue + } + + newUntilDates[subscription.relayAddress] = newDate + relayService.decrementSubscriptionCount(for: subscriptionID) + subscriptionsToRemove.insert(subscription.id) + } } - pagedSubscriptionIDs.removeAll() - cancellables.removeAll() - // 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: relayAddress + pagedSubscriptionIDs.subtract(subscriptionsToRemove) + + for (relayAddress, until) in newUntilDates { + var newEventsFilter = self.filter + newEventsFilter.until = until + pagedSubscriptionIDs.insert( + await subscriptionManager.queueSubscription(with: newEventsFilter, to: relayAddress) ) - - pagedEventSubscription.events - .sink { [weak self] jsonEvent in - Task { - await self?.track(event: jsonEvent, from: relayAddress) - } - } - .store(in: &cancellables) - - pagedSubscriptionIDs.insert(pagedEventSubscription.id) } } } - - func updateOldestEvent(for relay: URL, to date: Date) { - oldestEventByRelay[relay] = date - } - - nonisolated func track(event: JSONEvent, from relay: URL) async { - if let oldestSeen = await oldestEventByRelay[relay], - event.createdDate < oldestSeen { - await updateOldestEvent(for: relay, to: event.createdDate) - } else { - await updateOldestEvent(for: relay, to: event.createdDate) - } - } } diff --git a/Nos/Service/Relay/RelayService.swift b/Nos/Service/Relay/RelayService.swift index cd3ae05d8..c6502ea58 100644 --- a/Nos/Service/Relay/RelayService.swift +++ b/Nos/Service/Relay/RelayService.swift @@ -170,7 +170,7 @@ extension RelayService { } var subscriptionIDs = [RelaySubscription.ID]() for relay in relayAddresses { - subscriptionIDs.append(await subscriptionManager.queueSubscription(with: filter, to: relay).id) + subscriptionIDs.append(await subscriptionManager.queueSubscription(with: filter, to: relay)) } // Fire off REQs in the background @@ -182,28 +182,13 @@ extension RelayService { /// 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. - /// - Parameters: - /// - filter: an object describing the set of events that should be downloaded. - /// - specificRelay: a specific relay to download events from. If `nil` the user's relay list will be used. - /// - Returns: A handle that can be used to load more pages of events. It will close the relay subscriptions - /// when deallocated. - func subscribeToPagedEvents( - matching filter: Filter, - from specificRelay: URL? = nil - ) async -> PagedRelaySubscription { - var relays = Set() - if let specificRelay { - relays.insert(specificRelay) - } else { - relays = await self.relayAddresses(for: currentUser) - } - - return await PagedRelaySubscription( + func subscribeToPagedEvents(matching filter: Filter) async -> PagedRelaySubscription { + PagedRelaySubscription( startDate: .now, filter: filter, relayService: self, subscriptionManager: subscriptionManager, - relayAddresses: relays + relayAddresses: await self.relayAddresses(for: currentUser) ) } @@ -332,7 +317,6 @@ 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) } @@ -359,9 +343,17 @@ extension RelayService { let jsonEvent = try JSONDecoder().decode(JSONEvent.self, from: jsonData) await self.parseQueue.push(jsonEvent, from: socket) - if let subscription = await subscriptionManager.subscription(from: subscriptionID) { - subscription.receivedEventCount += 1 - subscription.events.send(jsonEvent) + if var subscription = await subscriptionManager.subscription(from: subscriptionID) { + if let oldestSeen = subscription.oldestEventCreationDate, + jsonEvent.createdDate < oldestSeen { + subscription.oldestEventCreationDate = jsonEvent.createdDate + subscription.receivedEventCount += 1 + await subscriptionManager.updateSubscriptions(with: subscription) + } else { + subscription.oldestEventCreationDate = jsonEvent.createdDate + subscription.receivedEventCount += 1 + await subscriptionManager.updateSubscriptions(with: subscription) + } if subscription.closesAfterResponse { Log.debug("detected subscription with id \(subscription.id) has been fulfilled. Closing.") await subscriptionManager.forceCloseSubscriptionCount(for: subscription.id) diff --git a/Nos/Service/Relay/RelaySubscriptionManager.swift b/Nos/Service/Relay/RelaySubscriptionManager.swift index e0b4fc222..3acc6bab0 100644 --- a/Nos/Service/Relay/RelaySubscriptionManager.swift +++ b/Nos/Service/Relay/RelaySubscriptionManager.swift @@ -14,7 +14,7 @@ protocol RelaySubscriptionManager { func forceCloseSubscriptionCount(for subscriptionID: RelaySubscription.ID) async func markHealthy(socket: WebSocket) async func processSubscriptionQueue() async - func queueSubscription(with filter: Filter, to relayAddress: URL) async -> RelaySubscription + func queueSubscription(with filter: Filter, to relayAddress: URL) async -> RelaySubscription.ID func remove(_ socket: WebSocketClient) async func requestEvents(from socket: WebSocketClient, subscription: RelaySubscription) async func socket(for address: String) async -> WebSocket? @@ -22,14 +22,11 @@ protocol RelaySubscriptionManager { func staleSubscriptions() async -> [RelaySubscription] func subscription(from subscriptionID: RelaySubscription.ID) async -> RelaySubscription? func trackError(socket: WebSocket) async + func updateSubscriptions(with newValue: RelaySubscription) async } /// An actor that manages state for a `RelayService` including lists of open sockets and subscriptions. -@globalActor actor RelaySubscriptionManagerActor: RelaySubscriptionManager { - - static let shared = RelaySubscriptionManagerActor() - // MARK: - Public Properties var all = [RelaySubscription]() @@ -67,6 +64,14 @@ actor RelaySubscriptionManagerActor: RelaySubscriptionManager { } } + func updateSubscriptions(with newValue: RelaySubscription) { + if let subscriptionIndex = self.all.firstIndex(where: { $0.id == newValue.id }) { + all[subscriptionIndex] = newValue + } else { + all.append(newValue) + } + } + private func removeSubscription(with subscriptionID: RelaySubscription.ID) { if let subscriptionIndex = self.all.firstIndex( where: { $0.id == subscriptionID } @@ -87,12 +92,13 @@ actor RelaySubscriptionManagerActor: RelaySubscriptionManager { /// do that in the future. @discardableResult func decrementSubscriptionCount(for subscriptionID: RelaySubscription.ID) async -> Bool { - if let subscription = subscription(from: subscriptionID) { + if var subscription = subscription(from: subscriptionID) { if subscription.referenceCount == 1 { removeSubscription(with: subscriptionID) return false } else { subscription.referenceCount -= 1 + updateSubscriptions(with: subscription) return true } } @@ -209,24 +215,24 @@ actor RelaySubscriptionManagerActor: RelaySubscriptionManager { #endif } - func queueSubscription(with filter: Filter, to relayAddress: URL) async -> 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: subscription.id) { // dedup subscription = existingSubscription - } else { - all.append(subscription) } subscription.referenceCount += 1 + updateSubscriptions(with: subscription) - return subscription + return subscription.id } private func start(subscription: RelaySubscription) { - let subscription = subscription + var subscription = subscription subscription.subscriptionStartDate = .now + updateSubscriptions(with: subscription) if let socket = socket(for: subscription.relayAddress) { requestEvents(from: socket, subscription: subscription) } diff --git a/Nos/Views/Home/HomeFeedView.swift b/Nos/Views/Home/HomeFeedView.swift index adff65873..de2ba11d6 100644 --- a/Nos/Views/Home/HomeFeedView.swift +++ b/Nos/Views/Home/HomeFeedView.swift @@ -14,28 +14,19 @@ struct HomeFeedView: View { @FetchRequest private var authors: FetchedResults @State private var lastRefreshDate = Date( - timeIntervalSince1970: Date.now.timeIntervalSince1970 + Double(Self.staticLoadTime) + timeIntervalSince1970: Date.now.timeIntervalSince1970 + Double(Self.initialLoadTime) ) @State private var isVisible = false @State private var relaySubscriptions = [SubscriptionCancellable]() + @State private var performingInitialLoad = true @State private var isShowingRelayList = false - - /// 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 + static let initialLoadTime = 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 @@ -50,28 +41,6 @@ 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() @@ -91,23 +60,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: homeFeedFetchRequest, + databaseFilter: Event.homeFeed(for: user, before: lastRefreshDate), relayFilter: homeFeedFilter, - relay: selectedRelay, context: viewContext, tab: .home, header: { - Group { - if selectedRelay == nil { - AuthorStoryCarousel( - authors: $stories, - selectedStoryAuthor: $selectedStoryAuthor - ) - } else { - EmptyView() - } - } + AuthorStoryCarousel( + authors: $stories, + selectedStoryAuthor: $selectedStoryAuthor + ) }, emptyPlaceholder: { _ in VStack { @@ -137,29 +106,12 @@ struct HomeFeedView: View { .opacity(isShowingStories ? 1 : 0) .animation(.default, value: selectedStoryAuthor) - if showTimedLoadingIndicator { + if performingInitialLoad { FullscreenProgressView( - isPresented: $showTimedLoadingIndicator, - hideAfter: .now() + .seconds(Int(Self.staticLoadTime)) + isPresented: $performingInitialLoad, + hideAfter: .now() + .seconds(Self.initialLoadTime) ) } - - 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 { @@ -186,21 +138,29 @@ struct HomeFeedView: View { .animation(.default, value: selectedStoryAuthor) } } else { - Button { - withAnimation { - showRelayPicker.toggle() + 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) } - } 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: navigationBarTitle) + .nosNavigationBar(title: isShowingStories ? .localizable.stories : .localizable.homeFeed) .task { await downloadStories() } @@ -232,23 +192,11 @@ 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 { - createTestData() + _ = previewData.shortNote } } diff --git a/Nos/Views/PagedNoteListView.swift b/Nos/Views/PagedNoteListView.swift index 288fb6402..7604f677d 100644 --- a/Nos/Views/PagedNoteListView.swift +++ b/Nos/Views/PagedNoteListView.swift @@ -25,8 +25,6 @@ struct PagedNoteListView: UIViewRepresenta /// by the `databaseFilter`. let relayFilter: Filter - let relay: Relay? - let context: NSManagedObjectContext /// The tab in which this PagedNoteListView appears. @@ -57,7 +55,6 @@ struct PagedNoteListView: UIViewRepresenta let dataSource = context.coordinator.dataSource( databaseFilter: databaseFilter, relayFilter: relayFilter, - relay: relay, collectionView: collectionView, context: self.context, header: header, @@ -84,17 +81,8 @@ struct PagedNoteListView: UIViewRepresenta return collectionView } - 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) - } - } - } - + func updateUIView(_ collectionView: UICollectionView, context: Context) {} + static func dismantleUIView(_ uiView: UICollectionView, coordinator: Coordinator) { tearDownObservers(coordinator: coordinator) } @@ -176,7 +164,6 @@ struct PagedNoteListView: UIViewRepresenta func dataSource( databaseFilter: NSFetchRequest, relayFilter: Filter, - relay: Relay?, collectionView: UICollectionView, context: NSManagedObjectContext, @ViewBuilder header: @escaping () -> CoordinatorHeader, @@ -192,7 +179,6 @@ struct PagedNoteListView: UIViewRepresenta let dataSource = PagedNoteDataSource( databaseFilter: databaseFilter, relayFilter: relayFilter, - relay: relay, collectionView: collectionView, context: context, header: header, @@ -206,6 +192,7 @@ struct PagedNoteListView: UIViewRepresenta @objc func refreshData(_ sender: Any) { if let onRefresh { dataSource?.updateFetchRequest(onRefresh()) + collectionView?.reloadData() } if let refreshControl = sender as? UIRefreshControl { @@ -230,8 +217,7 @@ extension Notification.Name { return PagedNoteListView( databaseFilter: previewData.alice.allPostsRequest(onlyRootPosts: false), - relayFilter: Filter(), - relay: nil, + relayFilter: Filter(keepSubscriptionOpen: true), context: previewData.previewContext, tab: .home, header: { diff --git a/Nos/Views/Profile/ProfileView.swift b/Nos/Views/Profile/ProfileView.swift index e09c26175..f7dfe99f8 100644 --- a/Nos/Views/Profile/ProfileView.swift +++ b/Nos/Views/Profile/ProfileView.swift @@ -81,8 +81,7 @@ struct ProfileView: View { VStack { PagedNoteListView( databaseFilter: selectedTab.databaseFilter(author: author), - relayFilter: selectedTab.relayFilter(author: author), - relay: nil, + relayFilter: selectedTab.relayFilter(author: author), context: viewContext, tab: .profile, header: { diff --git a/Nos/Views/RelayPicker.swift b/Nos/Views/RelayPicker.swift index 40c06be9e..cb9ffe670 100644 --- a/Nos/Views/RelayPicker.swift +++ b/Nos/Views/RelayPicker.swift @@ -18,54 +18,27 @@ struct RelayPicker: View { } var body: some View { - 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 + 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) } } + .cornerRadius(15, corners: [.bottomLeft, .bottomRight]) } + Spacer() } - .transition(.move(edge: .top)) + .background(LinearGradient.cardBackground) .frame(maxWidth: .infinity, maxHeight: .infinity) + .transition(.move(edge: .top)) .zIndex(99) // Fixes dismissal animation } } @@ -107,29 +80,12 @@ struct RelayPickerRow: View { selection = relay } label: { HStack { - 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) + Text(title) + .foregroundColor(.primaryTxt) + .font(.clarity(.bold)) + .lineLimit(1) + .padding(.horizontal, 19) + .padding(.vertical, 19) Spacer() if isSelected { Image(systemName: "checkmark") @@ -144,54 +100,40 @@ struct RelayPickerRow: View { } } -#Preview("Without ScrollView") { +struct RelayPicker_Previews: PreviewProvider { - @State var selectedRelay: Relay? - var previewData = PreviewData() + static var previewData = PreviewData() + static var persistenceController = PersistenceController.preview + static var previewContext = persistenceController.container.viewContext + static var relayService = previewData.relayService - func createTestData() { - let user = previewData.alice + 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) { let addresses = ["wss://nostr.com", "wss://nos.social", "wss://alongdomainnametoseewhathappens.com"] 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." + let relay = try? Relay.findOrCreate(by: address, context: previewContext) relay?.addToAuthors(user) } + + try? previewContext.save() } - 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() + @State static var selectedRelay: Relay? - 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) - } + static var previews: some View { + RelayPicker( + selectedRelay: $selectedRelay, + defaultSelection: String(localized: .localizable.allMyRelays), + author: user, + isPresented: .constant(true) + ) + .environment(\.managedObjectContext, previewContext) + .background(Color.appBg) } - - return RelayPicker( - selectedRelay: $selectedRelay, - defaultSelection: String(localized: .localizable.allMyRelays), - author: previewData.alice, - isPresented: .constant(true) - ) - .onAppear { createTestData() } - .inject(previewData: previewData) - .background(Color.appBg) }