diff --git a/damus.xcodeproj/project.pbxproj b/damus.xcodeproj/project.pbxproj index c1ec36ec..1502ff40 100644 --- a/damus.xcodeproj/project.pbxproj +++ b/damus.xcodeproj/project.pbxproj @@ -1161,6 +1161,9 @@ D72A2D022AD9C136002AFF62 /* EventViewTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D72A2CFF2AD9B66B002AFF62 /* EventViewTests.swift */; }; D72A2D052AD9C1B5002AFF62 /* MockDamusState.swift in Sources */ = {isa = PBXBuildFile; fileRef = D72A2D042AD9C1B5002AFF62 /* MockDamusState.swift */; }; D72A2D072AD9C1FB002AFF62 /* MockProfiles.swift in Sources */ = {isa = PBXBuildFile; fileRef = D72A2D062AD9C1FB002AFF62 /* MockProfiles.swift */; }; + D72C01312E78C10500AACB67 /* CondensedProfilePicturesViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D72C01302E78C0FB00AACB67 /* CondensedProfilePicturesViewModel.swift */; }; + D72C01322E78C10500AACB67 /* CondensedProfilePicturesViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D72C01302E78C0FB00AACB67 /* CondensedProfilePicturesViewModel.swift */; }; + D72C01332E78C10500AACB67 /* CondensedProfilePicturesViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D72C01302E78C0FB00AACB67 /* CondensedProfilePicturesViewModel.swift */; }; D72E12782BEED22500F4F781 /* Array.swift in Sources */ = {isa = PBXBuildFile; fileRef = D72E12772BEED22400F4F781 /* Array.swift */; }; D72E127A2BEEEED000F4F781 /* NostrFilterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D72E12792BEEEED000F4F781 /* NostrFilterTests.swift */; }; D7315A2A2ACDF3B70036E30A /* DamusCacheManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7315A292ACDF3B70036E30A /* DamusCacheManager.swift */; }; @@ -2607,6 +2610,7 @@ D72A2CFF2AD9B66B002AFF62 /* EventViewTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EventViewTests.swift; sourceTree = ""; }; D72A2D042AD9C1B5002AFF62 /* MockDamusState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockDamusState.swift; sourceTree = ""; }; D72A2D062AD9C1FB002AFF62 /* MockProfiles.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockProfiles.swift; sourceTree = ""; }; + D72C01302E78C0FB00AACB67 /* CondensedProfilePicturesViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CondensedProfilePicturesViewModel.swift; sourceTree = ""; }; D72E12772BEED22400F4F781 /* Array.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Array.swift; sourceTree = ""; }; D72E12792BEEEED000F4F781 /* NostrFilterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NostrFilterTests.swift; sourceTree = ""; }; D7315A292ACDF3B70036E30A /* DamusCacheManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DamusCacheManager.swift; sourceTree = ""; }; @@ -4301,6 +4305,7 @@ 5C78A7922E3036F800CF177D /* Models */ = { isa = PBXGroup; children = ( + D72C01302E78C0FB00AACB67 /* CondensedProfilePicturesViewModel.swift */, 4C3BEFD12819DB9B00B3DE84 /* ProfileModel.swift */, 4C363A912825FCF2006E126D /* ProfileUpdate.swift */, ); @@ -5783,6 +5788,7 @@ D706C5AF2D5D31C20027C627 /* AutoSaveIndicatorView.swift in Sources */, 6439E014296790CF0020672B /* ProfilePicImageView.swift in Sources */, 4CE6DF1627F8DEBF00C66700 /* RelayConnection.swift in Sources */, + D72C01312E78C10500AACB67 /* CondensedProfilePicturesViewModel.swift in Sources */, 4C1253682A76D2470004F4B8 /* MuteNotify.swift in Sources */, 4CDA128C29EB19C40006FA5A /* LocalNotification.swift in Sources */, 4C3BEFD6281D995700B3DE84 /* ActionBarModel.swift in Sources */, @@ -6385,6 +6391,7 @@ 82D6FC082CD99F7900C925F4 /* ProfileZapLinkView.swift in Sources */, D71AD8FE2CEC176A002E2C3C /* AppAccessibilityIdentifiers.swift in Sources */, 82D6FC092CD99F7900C925F4 /* AboutView.swift in Sources */, + D72C01332E78C10500AACB67 /* CondensedProfilePicturesViewModel.swift in Sources */, 82D6FC0A2CD99F7900C925F4 /* ProfileName.swift in Sources */, 82D6FC0B2CD99F7900C925F4 /* ProfilePictureSelector.swift in Sources */, 82D6FC0C2CD99F7900C925F4 /* EditMetadataView.swift in Sources */, @@ -6940,6 +6947,7 @@ D703D7502C6709F500A400EA /* NdbTxn.swift in Sources */, D703D77E2C670C1100A400EA /* NostrKind.swift in Sources */, D73E5F972C6AA7B7007EB227 /* SuggestedHashtagsView.swift in Sources */, + D72C01322E78C10500AACB67 /* CondensedProfilePicturesViewModel.swift in Sources */, D703D7B22C6710AF00A400EA /* ContentParsing.swift in Sources */, D703D7522C670A1400A400EA /* Log.swift in Sources */, D73E5E1B2C6A9672007EB227 /* LikeCounter.swift in Sources */, diff --git a/damus/Core/Nostr/RelayPool.swift b/damus/Core/Nostr/RelayPool.swift index 6b24a8e6..c34a5e05 100644 --- a/damus/Core/Nostr/RelayPool.swift +++ b/damus/Core/Nostr/RelayPool.swift @@ -43,6 +43,10 @@ class RelayPool { private let network_monitor = NWPathMonitor() private let network_monitor_queue = DispatchQueue(label: "io.damus.network_monitor") private var last_network_status: NWPath.Status = .unsatisfied + + /// The limit of maximum concurrent subscriptions. Any subscriptions beyond this limit will be paused until subscriptions clear + /// This is to avoid error states and undefined behaviour related to hitting subscription limits on the relays, by letting those wait instead — with the principle that slower is better than broken. + static let MAX_CONCURRENT_SUBSCRIPTION_LIMIT = 10 // This number is only an educated guess at this point. func close() { disconnect() @@ -102,10 +106,17 @@ class RelayPool { } @MainActor - func register_handler(sub_id: String, handler: @escaping (RelayURL, NostrConnectionEvent) -> ()) { + func register_handler(sub_id: String, handler: @escaping (RelayURL, NostrConnectionEvent) -> ()) async { + while handlers.count > Self.MAX_CONCURRENT_SUBSCRIPTION_LIMIT { + Log.debug("%s: Too many subscriptions, waiting for subscription pool to clear", for: .networking, sub_id) + try? await Task.sleep(for: .seconds(1)) + } + Log.debug("%s: Subscription pool cleared", for: .networking, sub_id) for handler in handlers { // don't add duplicate handlers if handler.sub_id == sub_id { + assertionFailure("Duplicate handlers are not allowed. Proper error handling for this has not been built yet.") + Log.error("Duplicate handlers are not allowed. Error handling for this has not been built yet.", for: .networking) return } } diff --git a/damus/Features/Onboarding/Views/SaveKeysView.swift b/damus/Features/Onboarding/Views/SaveKeysView.swift index 3e92c8de..4a2bf947 100644 --- a/damus/Features/Onboarding/Views/SaveKeysView.swift +++ b/damus/Features/Onboarding/Views/SaveKeysView.swift @@ -142,7 +142,7 @@ struct SaveKeysView: View { add_rw_relay(self.pool, relay) } - self.pool.register_handler(sub_id: "signup", handler: handle_event) + Task { await self.pool.register_handler(sub_id: "signup", handler: handle_event) } self.loading = true diff --git a/damus/Features/Profile/Models/CondensedProfilePicturesViewModel.swift b/damus/Features/Profile/Models/CondensedProfilePicturesViewModel.swift new file mode 100644 index 00000000..a8c35f07 --- /dev/null +++ b/damus/Features/Profile/Models/CondensedProfilePicturesViewModel.swift @@ -0,0 +1,42 @@ +// +// CondensedProfilePicturesViewModel.swift +// damus +// +// Created by Daniel D’Aquino on 2025-09-15. +// +import Combine +import Foundation + +class CondensedProfilePicturesViewModel: ObservableObject { + let state: DamusState + let pubkeys: [Pubkey] + let maxPictures: Int + var shownPubkeys: [Pubkey] { + return Array(pubkeys.prefix(maxPictures)) + } + var loadingTask: Task? = nil + + init(state: DamusState, pubkeys: [Pubkey], maxPictures: Int) { + self.state = state + self.pubkeys = pubkeys + self.maxPictures = min(maxPictures, pubkeys.count) + } + + func load() { + loadingTask?.cancel() + loadingTask = Task { try? await loadingTask() } + } + + func loadingTask() async throws { + let filter = NostrFilter(kinds: [.metadata], authors: shownPubkeys) + let _ = await state.nostrNetwork.reader.query(filters: [filter]) + for await _ in state.nostrNetwork.reader.streamNotesUntilEndOfStoredEvents(filters: [filter]) { + // NO-OP, we just need it to be loaded into NostrDB. + try Task.checkCancellation() + } + DispatchQueue.main.async { + // Cause the view to re-render with the newly loaded profiles + self.objectWillChange.send() + } + } +} diff --git a/damus/Features/Profile/Views/CondensedProfilePicturesView.swift b/damus/Features/Profile/Views/CondensedProfilePicturesView.swift index ed5de758..a04666fd 100644 --- a/damus/Features/Profile/Views/CondensedProfilePicturesView.swift +++ b/damus/Features/Profile/Views/CondensedProfilePicturesView.swift @@ -8,26 +8,26 @@ import SwiftUI struct CondensedProfilePicturesView: View { - let state: DamusState - let pubkeys: [Pubkey] - let maxPictures: Int + let model: CondensedProfilePicturesViewModel init(state: DamusState, pubkeys: [Pubkey], maxPictures: Int) { - self.state = state - self.pubkeys = pubkeys - self.maxPictures = min(maxPictures, pubkeys.count) + self.model = CondensedProfilePicturesViewModel(state: state, pubkeys: pubkeys, maxPictures: maxPictures) } var body: some View { // Using ZStack to make profile pictures floating and stacked on top of each other. ZStack { - ForEach((0.. = Set() + var follow_pack_seen_pubkey: Set = Set() let damus_state: DamusState let base_subid = UUID().description let follow_pack_subid = UUID().description @@ -25,6 +27,9 @@ class SearchHomeModel: ObservableObject { self.events = EventHolder(on_queue: { ev in preload_events(state: damus_state, events: [ev]) }) + self.followPackEvents = EventHolder(on_queue: { ev in + preload_events(state: damus_state, events: [ev]) + }) } func get_base_filter() -> NostrFilter { @@ -40,6 +45,12 @@ class SearchHomeModel: ObservableObject { self.objectWillChange.send() } + @MainActor + func reload() async { + self.events.reset() + await self.load() + } + func load() async { DispatchQueue.main.async { self.loading = true @@ -51,16 +62,23 @@ class SearchHomeModel: ObservableObject { var follow_list_filter = NostrFilter(kinds: [.follow_list]) follow_list_filter.until = UInt32(Date.now.timeIntervalSince1970) - for await noteLender in damus_state.nostrNetwork.reader.streamNotesUntilEndOfStoredEvents(filters: [get_base_filter(), follow_list_filter], to: to_relays) { + for await noteLender in damus_state.nostrNetwork.reader.streamNotesUntilEndOfStoredEvents(filters: [follow_list_filter], to: to_relays) { + await noteLender.justUseACopy({ await self.handleFollowPackEvent($0) }) + } + + for await noteLender in damus_state.nostrNetwork.reader.streamNotesUntilEndOfStoredEvents(filters: [get_base_filter()], to: to_relays) { await noteLender.justUseACopy({ await self.handleEvent($0) }) } + guard let txn = NdbTxn(ndb: damus_state.ndb) else { return } + let allEvents = events.all_events + followPackEvents.all_events + let task = load_profiles(context: "universe", load: .from_events(allEvents), damus_state: damus_state, txn: txn) + + try? await task?.value + DispatchQueue.main.async { self.loading = false } - - guard let txn = NdbTxn(ndb: damus_state.ndb) else { return } - load_profiles(context: "universe", load: .from_events(events.all_events), damus_state: damus_state, txn: txn) } @MainActor @@ -76,6 +94,20 @@ class SearchHomeModel: ObservableObject { } } } + + @MainActor + func handleFollowPackEvent(_ ev: NostrEvent) { + if ev.known_kind == .follow_list && should_show_event(state: damus_state, ev: ev) && !ev.is_reply() { + if !damus_state.settings.multiple_events_per_pubkey && follow_pack_seen_pubkey.contains(ev.pubkey) { + return + } + follow_pack_seen_pubkey.insert(ev.pubkey) + + if self.followPackEvents.insert(ev) { + self.objectWillChange.send() + } + } + } } func find_profiles_to_fetch(profiles: Profiles, load: PubkeysToLoad, cache: EventCache, txn: NdbTxn) -> [Pubkey] { @@ -113,28 +145,23 @@ enum PubkeysToLoad { case from_keys([Pubkey]) } -func load_profiles(context: String, load: PubkeysToLoad, damus_state: DamusState, txn: NdbTxn) { +func load_profiles(context: String, load: PubkeysToLoad, damus_state: DamusState, txn: NdbTxn) -> Task? { let authors = find_profiles_to_fetch(profiles: damus_state.profiles, load: load, cache: damus_state.events, txn: txn) guard !authors.isEmpty else { - return + return nil } - Task { + return Task { print("load_profiles[\(context)]: requesting \(authors.count) profiles from relay pool") let filter = NostrFilter(kinds: [.metadata], authors: authors) - for await item in damus_state.nostrNetwork.reader.subscribe(filters: [filter]) { + for await noteLender in damus_state.nostrNetwork.reader.streamNotesUntilEndOfStoredEvents(filters: [filter]) { let now = UInt64(Date.now.timeIntervalSince1970) - switch item { - case .event(let lender): - lender.justUseACopy({ event in - if event.known_kind == .metadata { - damus_state.ndb.write_profile_last_fetched(pubkey: event.pubkey, fetched_at: now) - } - }) - case .eose: - break + try noteLender.borrow { event in + if event.known_kind == .metadata { + damus_state.ndb.write_profile_last_fetched(pubkey: event.pubkey, fetched_at: now) + } } } diff --git a/damus/Features/Search/Views/SearchHomeView.swift b/damus/Features/Search/Views/SearchHomeView.swift index 5f056dfa..be619bbc 100644 --- a/damus/Features/Search/Views/SearchHomeView.swift +++ b/damus/Features/Search/Views/SearchHomeView.swift @@ -54,7 +54,7 @@ struct SearchHomeView: View { loading: $model.loading, damus: damus_state, show_friend_icon: true, - filter:content_filter(FilterState.posts), + filter: content_filter(FilterState.posts), content: { AnyView(VStack(alignment: .leading) { HStack { @@ -66,7 +66,7 @@ struct SearchHomeView: View { .padding(.top) .padding(.horizontal) - FollowPackTimelineView(events: model.events, loading: $model.loading, damus: damus_state, show_friend_icon: true,filter:content_filter(FilterState.follow_list) + FollowPackTimelineView(events: model.followPackEvents, loading: $model.loading, damus: damus_state, show_friend_icon: true, filter: content_filter(FilterState.follow_list) ).padding(.bottom) Divider() @@ -83,20 +83,10 @@ struct SearchHomeView: View { }.padding(.bottom, 50)) } ) - .refreshable { - // Fetch new information by unsubscribing and resubscribing to the relay - loadingTask?.cancel() - loadingTask = Task { await model.load() } - } } var SearchContent: some View { SearchResultsView(damus_state: damus_state, search: $search) - .refreshable { - // Fetch new information by unsubscribing and resubscribing to the relay - loadingTask?.cancel() - loadingTask = Task { await model.load() } - } } var MainContent: some View { @@ -136,6 +126,12 @@ struct SearchHomeView: View { .onDisappear { loadingTask?.cancel() } + .refreshable { + // Fetch new information by unsubscribing and resubscribing to the relay + loadingTask?.cancel() + loadingTask = Task { await model.reload() } + try? await loadingTask?.value + } } } diff --git a/damus/Shared/Utilities/EventHolder.swift b/damus/Shared/Utilities/EventHolder.swift index cd615f33..f31e6c2b 100644 --- a/damus/Shared/Utilities/EventHolder.swift +++ b/damus/Shared/Utilities/EventHolder.swift @@ -95,4 +95,10 @@ class EventHolder: ObservableObject, ScrollQueue { self.incoming = [] } + + @MainActor + func reset() { + self.incoming = [] + self.events = [] + } }