From 3290e1f9d2649161f70d54e2773280c22ad1a5ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20D=E2=80=99Aquino?= Date: Wed, 10 Sep 2025 13:52:39 -0700 Subject: [PATCH] Improve NostrNetworkManager interfaces MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This commit improves NostrNetworkManager interfaces to be easier to use, and with more options on how to read data from the Nostr network This reduces the amount of duplicate logic in handling streams, and also prevents possible common mistakes when using the standard subscribe method. This fixes an issue with the mute list manager (which prompted for this interface improvement, as the root cause is similar to other similar issues). Closes: https://github.com/damus-io/damus/issues/3221 Signed-off-by: Daniel D’Aquino --- .../NostrNetworkManager.swift | 89 ----------- .../SubscriptionManager.swift | 138 ++++++++++++++++-- .../UserRelayListManager.swift | 6 +- damus/Core/Nostr/RelayPool.swift | 7 +- damus/Features/Chat/Models/ThreadModel.swift | 6 +- damus/Features/Events/EventLoaderView.swift | 19 +-- .../Features/Events/Models/EventsModel.swift | 15 +- .../Models/LoadableNostrEventView.swift | 4 +- .../FollowPack/Models/FollowPackModel.swift | 23 ++- .../Follows/Models/FollowersModel.swift | 14 +- .../NIP05/Models/NIP05DomainEventsModel.swift | 13 +- .../Onboarding/SuggestedUsersViewModel.swift | 8 +- .../Profile/Models/ProfileModel.swift | 20 +-- .../Search/Models/SearchHomeModel.swift | 24 +-- .../Features/Search/Models/SearchModel.swift | 17 +-- .../Search/Views/SearchingEventView.swift | 6 +- .../Features/Timeline/Models/HomeModel.swift | 63 ++------ .../Features/Wallet/Models/WalletModel.swift | 6 +- damus/Features/Zaps/Models/ZapsModel.swift | 11 +- .../NostrNetworkManagerTests.swift | 4 +- nostrdb/UnownedNdbNote.swift | 115 ++++++++++++--- 21 files changed, 312 insertions(+), 296 deletions(-) diff --git a/damus/Core/Networking/NostrNetworkManager/NostrNetworkManager.swift b/damus/Core/Networking/NostrNetworkManager/NostrNetworkManager.swift index 50f51f51..ac11b97a 100644 --- a/damus/Core/Networking/NostrNetworkManager/NostrNetworkManager.swift +++ b/damus/Core/Networking/NostrNetworkManager/NostrNetworkManager.swift @@ -89,95 +89,6 @@ class NostrNetworkManager { self.pool.send(.event(event), to: targetRelays, skip_ephemeral: skipEphemeralRelays) } - func query(filters: [NostrFilter], to: [RelayURL]? = nil) async -> [NostrEvent] { - var events: [NostrEvent] = [] - for await item in self.reader.subscribe(filters: filters, to: to) { - switch item { - case .event(let borrow): - try? borrow { event in - events.append(event.toOwned()) - } - case .eose: - break - } - } - return events - } - - /// Finds a replaceable event based on an `naddr` address. - /// - /// - Parameters: - /// - naddr: the `naddr` address - func lookup(naddr: NAddr) async -> NostrEvent? { - var nostrKinds: [NostrKind]? = NostrKind(rawValue: naddr.kind).map { [$0] } - - let filter = NostrFilter(kinds: nostrKinds, authors: [naddr.author]) - - for await item in self.reader.subscribe(filters: [filter]) { - switch item { - case .event(let borrow): - var event: NostrEvent? = nil - try? borrow { ev in - event = ev.toOwned() - } - if event?.referenced_params.first?.param.string() == naddr.identifier { - return event - } - case .eose: - break - } - } - return nil - } - - // TODO: Improve this. This is mostly intact to keep compatibility with its predecessor, but we can do better - func findEvent(query: FindEvent) async -> FoundEvent? { - var filter: NostrFilter? = nil - let find_from = query.find_from - let query = query.type - - switch query { - case .profile(let pubkey): - if let profile_txn = delegate.ndb.lookup_profile(pubkey), - let record = profile_txn.unsafeUnownedValue, - record.profile != nil - { - return .profile(pubkey) - } - filter = NostrFilter(kinds: [.metadata], limit: 1, authors: [pubkey]) - case .event(let evid): - if let event = delegate.ndb.lookup_note(evid)?.unsafeUnownedValue?.to_owned() { - return .event(event) - } - filter = NostrFilter(ids: [evid], limit: 1) - } - - var attempts: Int = 0 - var has_event = false - guard let filter else { return nil } - - for await item in self.reader.subscribe(filters: [filter], to: find_from) { - switch item { - case .event(let borrow): - var result: FoundEvent? = nil - try? borrow { event in - switch query { - case .profile: - if event.known_kind == .metadata { - result = .profile(event.pubkey) - } - case .event: - result = .event(event.toOwned()) - } - } - return result - case .eose: - return nil - } - } - return nil - } - func getRelay(_ id: RelayURL) -> RelayPool.Relay? { pool.get_relay(id) } diff --git a/damus/Core/Networking/NostrNetworkManager/SubscriptionManager.swift b/damus/Core/Networking/NostrNetworkManager/SubscriptionManager.swift index 476a05fe..e5ce9ab3 100644 --- a/damus/Core/Networking/NostrNetworkManager/SubscriptionManager.swift +++ b/damus/Core/Networking/NostrNetworkManager/SubscriptionManager.swift @@ -23,7 +23,29 @@ extension NostrNetworkManager { self.taskManager = TaskManager() } - // MARK: - Reading data from Nostr + // MARK: - Subscribing and Streaming data from Nostr + + /// Streams notes until the EOSE signal + func streamNotesUntilEndOfStoredEvents(filters: [NostrFilter], to desiredRelays: [RelayURL]? = nil, timeout: Duration? = nil) -> AsyncStream { + let timeout = timeout ?? .seconds(10) + return AsyncStream { continuation in + let streamingTask = Task { + outerLoop: for await item in self.subscribe(filters: filters, to: desiredRelays, timeout: timeout) { + try Task.checkCancellation() + switch item { + case .event(let lender): + continuation.yield(lender) + case .eose: + break outerLoop + } + } + continuation.finish() + } + continuation.onTermination = { @Sendable _ in + streamingTask.cancel() + } + } + } /// Subscribes to data from user's relays, for a maximum period of time — after which the stream will end. /// @@ -113,17 +135,9 @@ extension NostrNetworkManager { case .eose: continuation.yield(.eose) case .event(let noteKey): - let lender: NdbNoteLender = { lend in - guard let ndbNoteTxn = self.ndb.lookup_note_by_key(noteKey) else { - throw NdbNoteLenderError.errorLoadingNote - } - guard let unownedNote = UnownedNdbNote(ndbNoteTxn) else { - throw NdbNoteLenderError.errorLoadingNote - } - lend(unownedNote) - } + let lender = NdbNoteLender(ndb: self.ndb, noteKey: noteKey) try Task.checkCancellation() - continuation.yield(.event(borrow: lender)) + continuation.yield(.event(lender: lender)) } } } @@ -166,6 +180,106 @@ extension NostrNetworkManager { } } + // MARK: - Finding specific data from Nostr + + /// Finds a non-replaceable event based on a note ID + func lookup(noteId: NoteId, to targetRelays: [RelayURL]? = nil, timeout: Duration? = nil) async throws -> NdbNoteLender? { + let filter = NostrFilter(ids: [noteId], limit: 1) + + // Since note ids point to immutable objects, we can do a simple ndb lookup first + if let noteKey = self.ndb.lookup_note_key(noteId) { + return NdbNoteLender(ndb: self.ndb, noteKey: noteKey) + } + + // Not available in local ndb, stream from network + outerLoop: for await item in self.pool.subscribe(filters: [NostrFilter(ids: [noteId], limit: 1)], to: targetRelays, eoseTimeout: timeout) { + switch item { + case .event(let event): + return NdbNoteLender(ownedNdbNote: event) + case .eose: + break outerLoop + } + } + + return nil + } + + func query(filters: [NostrFilter], to: [RelayURL]? = nil, timeout: Duration? = nil) async -> [NostrEvent] { + var events: [NostrEvent] = [] + for await noteLender in self.streamNotesUntilEndOfStoredEvents(filters: filters, to: to, timeout: timeout) { + noteLender.justUseACopy({ events.append($0) }) + } + return events + } + + /// Finds a replaceable event based on an `naddr` address. + /// + /// - Parameters: + /// - naddr: the `naddr` address + func lookup(naddr: NAddr, to targetRelays: [RelayURL]? = nil, timeout: Duration? = nil) async -> NostrEvent? { + var nostrKinds: [NostrKind]? = NostrKind(rawValue: naddr.kind).map { [$0] } + + let filter = NostrFilter(kinds: nostrKinds, authors: [naddr.author]) + + for await noteLender in self.streamNotesUntilEndOfStoredEvents(filters: [filter], to: targetRelays, timeout: timeout) { + // TODO: This can be refactored to borrow the note instead of copying it. But we need to implement `referenced_params` on `UnownedNdbNote` to do so + guard let event = noteLender.justGetACopy() else { continue } + if event.referenced_params.first?.param.string() == naddr.identifier { + return event + } + } + + return nil + } + + // TODO: Improve this. This is mostly intact to keep compatibility with its predecessor, but we can do better + func findEvent(query: FindEvent) async -> FoundEvent? { + var filter: NostrFilter? = nil + let find_from = query.find_from + let query = query.type + + switch query { + case .profile(let pubkey): + if let profile_txn = self.ndb.lookup_profile(pubkey), + let record = profile_txn.unsafeUnownedValue, + record.profile != nil + { + return .profile(pubkey) + } + filter = NostrFilter(kinds: [.metadata], limit: 1, authors: [pubkey]) + case .event(let evid): + if let event = self.ndb.lookup_note(evid)?.unsafeUnownedValue?.to_owned() { + return .event(event) + } + filter = NostrFilter(ids: [evid], limit: 1) + } + + var attempts: Int = 0 + var has_event = false + guard let filter else { return nil } + + for await noteLender in self.streamNotesUntilEndOfStoredEvents(filters: [filter], to: find_from) { + let foundEvent: FoundEvent? = try? noteLender.borrow({ event in + switch query { + case .profile: + if event.known_kind == .metadata { + return .profile(event.pubkey) + } + case .event: + return .event(event.toOwned()) + } + return nil + }) + if let foundEvent { + return foundEvent + } + } + + return nil + } + + // MARK: - Task management + func cancelAllTasks() async { await self.taskManager.cancelAllTasks() } @@ -199,7 +313,7 @@ extension NostrNetworkManager { enum StreamItem { /// An event which can be borrowed from NostrDB - case event(borrow: NdbNoteLender) + case event(lender: NdbNoteLender) /// The end of stored events case eose } diff --git a/damus/Core/Networking/NostrNetworkManager/UserRelayListManager.swift b/damus/Core/Networking/NostrNetworkManager/UserRelayListManager.swift index 8f773378..01225fc5 100644 --- a/damus/Core/Networking/NostrNetworkManager/UserRelayListManager.swift +++ b/damus/Core/Networking/NostrNetworkManager/UserRelayListManager.swift @@ -135,15 +135,15 @@ extension NostrNetworkManager { let filter = NostrFilter(kinds: [.relay_list], authors: [delegate.keypair.pubkey]) for await item in self.reader.subscribe(filters: [filter]) { switch item { - case .event(borrow: let borrow): // Signature validity already ensured at this point + case .event(let lender): // Signature validity already ensured at this point let currentRelayListCreationDate = self.getUserCurrentRelayListCreationDate() - try? borrow { note in + try? lender.borrow({ note in guard note.pubkey == self.delegate.keypair.pubkey else { return } // Ensure this new list was ours guard note.createdAt > (currentRelayListCreationDate ?? 0) else { return } // Ensure this is a newer list guard let relayList = try? NIP65.RelayList(event: note) else { return } // Ensure it is a valid NIP-65 list try? self.set(userRelayList: relayList) // Set the validated list - } + }) case .eose: continue } } diff --git a/damus/Core/Nostr/RelayPool.swift b/damus/Core/Nostr/RelayPool.swift index a85fe6b9..d38ce7be 100644 --- a/damus/Core/Nostr/RelayPool.swift +++ b/damus/Core/Nostr/RelayPool.swift @@ -219,9 +219,10 @@ class RelayPool { /// - Parameters: /// - filters: The filters specifying the desired content. /// - desiredRelays: The desired relays which to subsctibe to. If `nil`, it defaults to the `RelayPool`'s default list - /// - eoseTimeout: The maximum timeout which to give up waiting for the eoseSignal, in seconds + /// - eoseTimeout: The maximum timeout which to give up waiting for the eoseSignal /// - Returns: Returns an async stream that callers can easily consume via a for-loop - func subscribe(filters: [NostrFilter], to desiredRelays: [RelayURL]? = nil, eoseTimeout: TimeInterval = 10) -> AsyncStream { + func subscribe(filters: [NostrFilter], to desiredRelays: [RelayURL]? = nil, eoseTimeout: Duration? = nil) -> AsyncStream { + let eoseTimeout = eoseTimeout ?? .seconds(10) let desiredRelays = desiredRelays ?? self.relays.map({ $0.descriptor.url }) return AsyncStream { continuation in let sub_id = UUID().uuidString @@ -255,7 +256,7 @@ class RelayPool { } }, to: desiredRelays) Task { - try? await Task.sleep(nanoseconds: 1_000_000_000 * UInt64(eoseTimeout)) + try? await Task.sleep(for: eoseTimeout) if !eoseSent { continuation.yield(with: .success(.eose)) } } continuation.onTermination = { @Sendable _ in diff --git a/damus/Features/Chat/Models/ThreadModel.swift b/damus/Features/Chat/Models/ThreadModel.swift index e9df2130..e22ca637 100644 --- a/damus/Features/Chat/Models/ThreadModel.swift +++ b/damus/Features/Chat/Models/ThreadModel.swift @@ -117,10 +117,8 @@ class ThreadModel: ObservableObject { Log.info("subscribing to thread %s ", for: .render, original_event.id.hex()) for await item in damus_state.nostrNetwork.reader.subscribe(filters: base_filters + meta_filters) { switch item { - case .event(let borrow): - try? borrow { event in - handle_event(ev: event.toOwned()) - } + case .event(let lender): + lender.justUseACopy({ handle_event(ev: $0) }) case .eose: guard let txn = NdbTxn(ndb: damus_state.ndb) else { return } load_profiles(context: "thread", load: .from_events(Array(event_map.events)), damus_state: damus_state, txn: txn) diff --git a/damus/Features/Events/EventLoaderView.swift b/damus/Features/Events/EventLoaderView.swift index 511314ca..82b02c16 100644 --- a/damus/Features/Events/EventLoaderView.swift +++ b/damus/Features/Events/EventLoaderView.swift @@ -28,27 +28,16 @@ struct EventLoaderView: View { self.loadingTask?.cancel() } - func subscribe(filters: [NostrFilter]) { + func subscribe() { self.loadingTask?.cancel() self.loadingTask = Task { - for await item in await damus_state.nostrNetwork.reader.subscribe(filters: filters) { - switch item { - case .event(let borrow): - try? borrow { ev in - event = ev.toOwned() - } - break - case .eose: - break - } - } + let lender = try? await damus_state.nostrNetwork.reader.lookup(noteId: self.event_id) + lender?.justUseACopy({ event = $0 }) } } func load() { - subscribe(filters: [ - NostrFilter(ids: [self.event_id], limit: 1) - ]) + subscribe() } var body: some View { diff --git a/damus/Features/Events/Models/EventsModel.swift b/damus/Features/Events/Models/EventsModel.swift index f9f47739..049ecfe7 100644 --- a/damus/Features/Events/Models/EventsModel.swift +++ b/damus/Features/Events/Models/EventsModel.swift @@ -73,16 +73,13 @@ class EventsModel: ObservableObject { DispatchQueue.main.async { self.loading = true } outerLoop: for await item in state.nostrNetwork.reader.subscribe(filters: [get_filter()]) { switch item { - case .event(let borrow): - var event: NostrEvent? = nil - try? borrow { ev in - event = ev.toOwned() - } - guard let event else { return } + case .event(let lender): Task { - if await events.insert(event) { - DispatchQueue.main.async { self.objectWillChange.send() } - } + await lender.justUseACopy({ event in + if await events.insert(event) { + DispatchQueue.main.async { self.objectWillChange.send() } + } + }) } case .eose: DispatchQueue.main.async { self.loading = false } diff --git a/damus/Features/Events/Models/LoadableNostrEventView.swift b/damus/Features/Events/Models/LoadableNostrEventView.swift index af9bf348..a886b821 100644 --- a/damus/Features/Events/Models/LoadableNostrEventView.swift +++ b/damus/Features/Events/Models/LoadableNostrEventView.swift @@ -50,7 +50,7 @@ class LoadableNostrEventViewModel: ObservableObject { /// Asynchronously find an event from NostrDB or from the network (if not available on NostrDB) private func loadEvent(noteId: NoteId) async -> NostrEvent? { - let res = await damus_state.nostrNetwork.findEvent(query: .event(evid: noteId)) + let res = await damus_state.nostrNetwork.reader.findEvent(query: .event(evid: noteId)) guard let res, case .event(let ev) = res else { return nil } return ev } @@ -78,7 +78,7 @@ class LoadableNostrEventViewModel: ObservableObject { return .unknown_or_unsupported_kind } case .naddr(let naddr): - guard let event = await damus_state.nostrNetwork.lookup(naddr: naddr) else { return .not_found } + guard let event = await damus_state.nostrNetwork.reader.lookup(naddr: naddr) else { return .not_found } return .loaded(route: Route.Thread(thread: ThreadModel(event: event, damus_state: damus_state))) } } diff --git a/damus/Features/FollowPack/Models/FollowPackModel.swift b/damus/Features/FollowPack/Models/FollowPackModel.swift index f938bd87..7049efa9 100644 --- a/damus/Features/FollowPack/Models/FollowPackModel.swift +++ b/damus/Features/FollowPack/Models/FollowPackModel.swift @@ -45,21 +45,18 @@ class FollowPackModel: ObservableObject { for await item in damus_state.nostrNetwork.reader.subscribe(filters: [filter], to: to_relays) { switch item { - case .event(borrow: let borrow): - var event: NostrEvent? = nil - try? borrow { ev in - event = ev.toOwned() - } - guard let event else { return } - let should_show_event = await should_show_event(state: damus_state, ev: event) - if event.is_textlike && should_show_event && !event.is_reply() - { - if await self.events.insert(event) { - DispatchQueue.main.async { - self.objectWillChange.send() + case .event(lender: let lender): + await lender.justUseACopy({ event in + let should_show_event = await should_show_event(state: damus_state, ev: event) + if event.is_textlike && should_show_event && !event.is_reply() + { + if await self.events.insert(event) { + DispatchQueue.main.async { + self.objectWillChange.send() + } } } - } + }) case .eose: continue } diff --git a/damus/Features/Follows/Models/FollowersModel.swift b/damus/Features/Follows/Models/FollowersModel.swift index df1d0ee8..e696525f 100644 --- a/damus/Features/Follows/Models/FollowersModel.swift +++ b/damus/Features/Follows/Models/FollowersModel.swift @@ -38,12 +38,10 @@ class FollowersModel: ObservableObject { let filters = [filter] self.listener?.cancel() self.listener = Task { - for await item in await damus_state.nostrNetwork.reader.subscribe(filters: filters) { + for await item in damus_state.nostrNetwork.reader.subscribe(filters: filters) { switch item { - case .event(let borrow): - try? borrow { event in - self.handle_event(ev: event.toOwned()) - } + case .event(let lender): + lender.justUseACopy({ self.handle_event(ev: $0) }) case .eose: guard let txn = NdbTxn(ndb: self.damus_state.ndb) else { return } load_profiles(txn: txn) @@ -82,10 +80,8 @@ class FollowersModel: ObservableObject { self.profilesListener = Task { for await item in await damus_state.nostrNetwork.reader.subscribe(filters: [filter]) { switch item { - case .event(let borrow): - try? borrow { event in - self.handle_event(ev: event.toOwned()) - } + case .event(let lender): + lender.justUseACopy({ self.handle_event(ev: $0) }) case .eose: break } } diff --git a/damus/Features/NIP05/Models/NIP05DomainEventsModel.swift b/damus/Features/NIP05/Models/NIP05DomainEventsModel.swift index 2037b9ba..545c0927 100644 --- a/damus/Features/NIP05/Models/NIP05DomainEventsModel.swift +++ b/damus/Features/NIP05/Models/NIP05DomainEventsModel.swift @@ -66,16 +66,11 @@ class NIP05DomainEventsModel: ObservableObject { for await item in state.nostrNetwork.reader.subscribe(filters: [filter]) { switch item { - case .event(borrow: let borrow): - var event: NostrEvent? = nil - try? borrow { ev in - event = ev.toOwned() - guard let txn = NdbTxn(ndb: state.ndb) else { return } - load_profiles(context: "search", load: .from_events(self.events.all_events), damus_state: state, txn: txn) - } - guard let event else { return } - await self.add_event(event) + case .event(let lender): + await lender.justUseACopy({ await self.add_event($0) }) case .eose: + guard let txn = NdbTxn(ndb: state.ndb) else { return } + load_profiles(context: "search", load: .from_events(self.events.all_events), damus_state: state, txn: txn) DispatchQueue.main.async { self.loading = false } continue } diff --git a/damus/Features/Onboarding/SuggestedUsersViewModel.swift b/damus/Features/Onboarding/SuggestedUsersViewModel.swift index 1d3fc583..d53f878b 100644 --- a/damus/Features/Onboarding/SuggestedUsersViewModel.swift +++ b/damus/Features/Onboarding/SuggestedUsersViewModel.swift @@ -194,9 +194,9 @@ class SuggestedUsersViewModel: ObservableObject { guard !Task.isCancelled else { break } switch item { - case .event(let borrow): - try? borrow { event in - let followPack = FollowPackEvent.parse(from: event.toOwned()) + case .event(let lender): + lender.justUseACopy({ event in + let followPack = FollowPackEvent.parse(from: event) guard let id = followPack.uuid else { return } @@ -209,7 +209,7 @@ class SuggestedUsersViewModel: ObservableObject { } packsById[id] = latestPackForThisId - } + }) case .eose: break } diff --git a/damus/Features/Profile/Models/ProfileModel.swift b/damus/Features/Profile/Models/ProfileModel.swift index 5004b725..a50e252a 100644 --- a/damus/Features/Profile/Models/ProfileModel.swift +++ b/damus/Features/Profile/Models/ProfileModel.swift @@ -78,10 +78,8 @@ class ProfileModel: ObservableObject, Equatable { text_filter.limit = 500 for await item in damus.nostrNetwork.reader.subscribe(filters: [text_filter]) { switch item { - case .event(let borrow): - try? borrow { event in - handleNostrEvent(event.toOwned()) - } + case .event(let lender): + lender.justUseACopy({ handleNostrEvent($0) }) case .eose: break } } @@ -96,10 +94,8 @@ class ProfileModel: ObservableObject, Equatable { profile_filter.authors = [pubkey] for await item in damus.nostrNetwork.reader.subscribe(filters: [profile_filter, relay_list_filter]) { switch item { - case .event(let borrow): - try? borrow { event in - handleNostrEvent(event.toOwned()) - } + case .event(let lender): + lender.justUseACopy({ handleNostrEvent($0) }) case .eose: break } } @@ -129,8 +125,8 @@ class ProfileModel: ObservableObject, Equatable { print("subscribing to conversation events from and to profile \(pubkey)") for await item in self.damus.nostrNetwork.reader.subscribe(filters: [conversations_filter_them, conversations_filter_us]) { switch item { - case .event(borrow: let borrow): - try? borrow { ev in + case .event(let lender): + try? lender.borrow { ev in if !seen_event.contains(ev.id) { let event = ev.toOwned() Task { await self.add_event(event) } @@ -210,8 +206,8 @@ class ProfileModel: ObservableObject, Equatable { self.findRelaysListener = Task { for await item in await damus.nostrNetwork.reader.subscribe(filters: [profile_filter]) { switch item { - case .event(let borrow): - try? borrow { event in + case .event(let lender): + try? lender.borrow { event in if case .contacts = event.known_kind { // TODO: Is this correct? self.legacy_relay_list = decode_json_relays(event.content) diff --git a/damus/Features/Search/Models/SearchHomeModel.swift b/damus/Features/Search/Models/SearchHomeModel.swift index acf1c085..723b5b56 100644 --- a/damus/Features/Search/Models/SearchHomeModel.swift +++ b/damus/Features/Search/Models/SearchHomeModel.swift @@ -53,13 +53,8 @@ class SearchHomeModel: ObservableObject { outerLoop: for await item in damus_state.nostrNetwork.reader.subscribe(filters: [get_base_filter(), follow_list_filter], to: to_relays) { switch item { - case .event(let borrow): - var event: NostrEvent? = nil - try? borrow { ev in - event = ev.toOwned() - } - guard let event else { return } - await self.handleEvent(event) + case .event(let lender): + await lender.justUseACopy({ await self.handleEvent($0) }) case .eose: break outerLoop } @@ -136,15 +131,12 @@ func load_profiles(context: String, load: PubkeysToLoad, damus_state: DamusSt for await item in damus_state.nostrNetwork.reader.subscribe(filters: [filter]) { let now = UInt64(Date.now.timeIntervalSince1970) switch item { - case .event(let borrow): - var event: NostrEvent? = nil - try? borrow { ev in - event = ev.toOwned() - } - guard let event else { return } - if event.known_kind == .metadata { - damus_state.ndb.write_profile_last_fetched(pubkey: event.pubkey, fetched_at: now) - } + 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 } diff --git a/damus/Features/Search/Models/SearchModel.swift b/damus/Features/Search/Models/SearchModel.swift index af0168db..3547f463 100644 --- a/damus/Features/Search/Models/SearchModel.swift +++ b/damus/Features/Search/Models/SearchModel.swift @@ -47,20 +47,13 @@ class SearchModel: ObservableObject { } print("subscribing to search") try Task.checkCancellation() - outerLoop: for await item in await state.nostrNetwork.reader.subscribe(filters: [search]) { - try Task.checkCancellation() - switch item { - case .event(let borrow): - try? borrow { ev in - let event = ev.toOwned() - if event.is_textlike && event.should_show_event { - Task { await self.add_event(event) } - } - } - case .eose: - break outerLoop + let events = await state.nostrNetwork.reader.query(filters: [search]) + for event in events { + if event.is_textlike && event.should_show_event { + await self.add_event(event) } } + guard let txn = NdbTxn(ndb: state.ndb) else { return } try Task.checkCancellation() load_profiles(context: "search", load: .from_events(self.events.all_events), damus_state: state, txn: txn) diff --git a/damus/Features/Search/Views/SearchingEventView.swift b/damus/Features/Search/Views/SearchingEventView.swift index 7f132bc3..b39a92bb 100644 --- a/damus/Features/Search/Views/SearchingEventView.swift +++ b/damus/Features/Search/Views/SearchingEventView.swift @@ -78,7 +78,7 @@ struct SearchingEventView: View { case .event(let note_id): Task { - let res = await state.nostrNetwork.findEvent(query: .event(evid: note_id)) + let res = await state.nostrNetwork.reader.findEvent(query: .event(evid: note_id)) guard case .event(let ev) = res else { self.search_state = .not_found return @@ -87,7 +87,7 @@ struct SearchingEventView: View { } case .profile(let pubkey): Task { - let res = await state.nostrNetwork.findEvent(query: .profile(pubkey: pubkey)) + let res = await state.nostrNetwork.reader.findEvent(query: .profile(pubkey: pubkey)) guard case .profile(let pubkey) = res else { self.search_state = .not_found return @@ -96,7 +96,7 @@ struct SearchingEventView: View { } case .naddr(let naddr): Task { - let res = await state.nostrNetwork.lookup(naddr: naddr) + let res = await state.nostrNetwork.reader.lookup(naddr: naddr) guard let res = res else { self.search_state = .not_found return diff --git a/damus/Features/Timeline/Models/HomeModel.swift b/damus/Features/Timeline/Models/HomeModel.swift index 4a5fa062..37f64d88 100644 --- a/damus/Features/Timeline/Models/HomeModel.swift +++ b/damus/Features/Timeline/Models/HomeModel.swift @@ -453,13 +453,8 @@ class HomeModel: ContactsDelegate, ObservableObject { let filter = NostrFilter(kinds: [.contacts], limit: 1, authors: [damus_state.pubkey]) for await item in damus_state.nostrNetwork.reader.subscribe(filters: [filter]) { switch item { - case .event(let borrow): - var event: NostrEvent? = nil - try? borrow { ev in - event = ev.toOwned() - } - guard let event else { return } - await process_event(ev: event, context: .initialContactList) + case .event(let lender): + await lender.justUseACopy({ await process_event(ev: $0, context: .initialContactList) }) continue case .eose: if !done_init { @@ -476,13 +471,8 @@ class HomeModel: ContactsDelegate, ObservableObject { let relayListFilter = NostrFilter(kinds: [.relay_list], limit: 1, authors: [damus_state.pubkey]) for await item in damus_state.nostrNetwork.reader.subscribe(filters: [relayListFilter]) { switch item { - case .event(let borrow): - var event: NostrEvent? = nil - try? borrow { ev in - event = ev.toOwned() - } - guard let event else { return } - await process_event(ev: event, context: .initialRelayList) + case .event(let lender): + await lender.justUseACopy({ await process_event(ev: $0, context: .initialRelayList) }) case .eose: break } } @@ -545,13 +535,8 @@ class HomeModel: ContactsDelegate, ObservableObject { self.contactsHandlerTask = Task { for await item in damus_state.nostrNetwork.reader.subscribe(filters: contacts_filters) { switch item { - case .event(let borrow): - var event: NostrEvent? = nil - try? borrow { ev in - var event = ev.toOwned() - } - guard let event else { return } - await self.process_event(ev: event, context: .contacts) + case .event(let lender): + await lender.justUseACopy({ await process_event(ev: $0, context: .contacts) }) case .eose: continue } } @@ -560,13 +545,8 @@ class HomeModel: ContactsDelegate, ObservableObject { self.notificationsHandlerTask = Task { for await item in damus_state.nostrNetwork.reader.subscribe(filters: notifications_filters) { switch item { - case .event(let borrow): - var event: NostrEvent? = nil - try? borrow { ev in - event = ev.toOwned() - } - guard let theEvent = event else { return } - await self.process_event(ev: theEvent, context: .notifications) + case .event(let lender): + await lender.justUseACopy({ await process_event(ev: $0, context: .notifications) }) case .eose: guard let txn = NdbTxn(ndb: damus_state.ndb) else { return } load_profiles(context: "notifications", load: .from_keys(notifications.uniq_pubkeys()), damus_state: damus_state, txn: txn) @@ -577,13 +557,8 @@ class HomeModel: ContactsDelegate, ObservableObject { self.dmsHandlerTask = Task { for await item in damus_state.nostrNetwork.reader.subscribe(filters: dms_filters) { switch item { - case .event(let borrow): - var event: NostrEvent? = nil - try? borrow { ev in - event = ev.toOwned() - } - guard let event else { return } - await self.process_event(ev: event, context: .dms) + case .event(let lender): + await lender.justUseACopy({ await process_event(ev: $0, context: .dms) }) case .eose: guard let txn = NdbTxn(ndb: damus_state.ndb) else { return } var dms = dms.dms.flatMap { $0.events } @@ -602,13 +577,8 @@ class HomeModel: ContactsDelegate, ObservableObject { filter.limit = 0 for await item in damus_state.nostrNetwork.reader.subscribe(filters: [filter], to: [nwc.relay]) { switch item { - case .event(let borrow): - var event: NostrEvent? = nil - try? borrow { ev in - event = ev.toOwned() - } - guard let event else { return } - await self.process_event(ev: event, context: .nwc) + case .event(let lender): + await lender.justUseACopy({ await process_event(ev: $0, context: .nwc) }) case .eose: continue } } @@ -663,13 +633,8 @@ class HomeModel: ContactsDelegate, ObservableObject { } for await item in damus_state.nostrNetwork.reader.subscribe(filters: home_filters) { switch item { - case .event(let borrow): - var event: NostrEvent? = nil - try? borrow { ev in - event = ev.toOwned() - } - guard let event else { return } - await self.process_event(ev: event, context: .home) + case .event(let lender): + await lender.justUseACopy({ await process_event(ev: $0, context: .home) }) case .eose: guard let txn = NdbTxn(ndb: damus_state.ndb) else { return } DispatchQueue.main.async { diff --git a/damus/Features/Wallet/Models/WalletModel.swift b/damus/Features/Wallet/Models/WalletModel.swift index 34d916ac..4c7d9036 100644 --- a/damus/Features/Wallet/Models/WalletModel.swift +++ b/damus/Features/Wallet/Models/WalletModel.swift @@ -184,10 +184,8 @@ class WalletModel: ObservableObject { nostrNetwork.send(event: requestEvent, to: [currentNwcUrl.relay], skipEphemeralRelays: false) for await item in nostrNetwork.reader.subscribe(filters: responseFilters, to: [currentNwcUrl.relay], timeout: timeout) { switch item { - case .event(borrow: let borrow): - var responseEvent: NostrEvent? = nil - try? borrow { ev in responseEvent = ev.toOwned() } - guard let responseEvent else { throw .internalError } + case .event(let lender): + guard let responseEvent = try? lender.getCopy() else { throw .internalError } let fullWalletResponse: WalletConnect.FullWalletResponse do { fullWalletResponse = try WalletConnect.FullWalletResponse(from: responseEvent, nwc: currentNwcUrl) } diff --git a/damus/Features/Zaps/Models/ZapsModel.swift b/damus/Features/Zaps/Models/ZapsModel.swift index 49af62d5..a3e26e67 100644 --- a/damus/Features/Zaps/Models/ZapsModel.swift +++ b/damus/Features/Zaps/Models/ZapsModel.swift @@ -35,13 +35,10 @@ class ZapsModel: ObservableObject { zapCommsListener = Task { for await item in state.nostrNetwork.reader.subscribe(filters: [filter]) { switch item { - case .event(let borrow): - var event: NostrEvent? = nil - try? borrow { ev in - event = ev.toOwned() - } - guard let event else { return } - await self.handle_event(ev: event) + case .event(let lender): + await lender.justUseACopy({ event in + await self.handle_event(ev: event) + }) case .eose: let events = state.events.lookup_zaps(target: target).map { $0.request.ev } guard let txn = NdbTxn(ndb: state.ndb) else { return } diff --git a/damusTests/NostrNetworkManagerTests/NostrNetworkManagerTests.swift b/damusTests/NostrNetworkManagerTests/NostrNetworkManagerTests.swift index 244b4061..271c6f15 100644 --- a/damusTests/NostrNetworkManagerTests/NostrNetworkManagerTests.swift +++ b/damusTests/NostrNetworkManagerTests/NostrNetworkManagerTests.swift @@ -47,8 +47,8 @@ class NostrNetworkManagerTests: XCTestCase { Task { for await item in self.damusState!.nostrNetwork.reader.subscribe(filters: [filter]) { switch item { - case .event(borrow: let borrow): - try? borrow { event in + case .event(let lender): + try? lender.borrow { event in receivedCount += 1 if eventIds.contains(event.id) { XCTFail("Got duplicate event ID: \(event.id) ") diff --git a/nostrdb/UnownedNdbNote.swift b/nostrdb/UnownedNdbNote.swift index ee03cbed..550c4b71 100644 --- a/nostrdb/UnownedNdbNote.swift +++ b/nostrdb/UnownedNdbNote.swift @@ -5,7 +5,7 @@ // Created by Daniel D’Aquino on 2025-03-25. // -/// A function that allows an unowned NdbNote to be lent out temporarily +/// Allows an unowned note to be safely lent out temporarily. /// /// Use this to provide access to NostrDB unowned notes in a way that has much better compile-time safety guarantees. /// @@ -14,16 +14,9 @@ /// ## Lending out or providing Ndb notes /// /// ```swift +/// let noteKey = functionThatDoesSomeLookupOrSubscriptionOnNDB() /// // Define the lender -/// let lender: NdbNoteLender = { lend in -/// guard let ndbNoteTxn = ndb.lookup_note(noteId) else { // Note: Must have access to `Ndb` -/// throw NdbNoteLenderError.errorLoadingNote // Throw errors if loading fails -/// } -/// guard let unownedNote = UnownedNdbNote(ndbNoteTxn) else { -/// throw NdbNoteLenderError.errorLoadingNote -/// } -/// lend(unownedNote) // Lend out the Unowned Ndb note -/// } +/// let lender = NdbNoteLender(ndb: self.ndb, noteKey: noteKey) /// return lender // Return or pass the lender to another class /// ``` /// @@ -32,17 +25,101 @@ /// Assuming you are given a lender, here is how you can use it: /// /// ```swift -/// let borrow: NdbNoteLender = functionThatProvidesALender() -/// try? borrow { note in // You can optionally handle errors if borrowing fails -/// self.date = note.createdAt // You can do things with the note without copying it over -/// // self.note = note // Not allowed by the compiler -/// self.note = note.toOwned() // You can copy the note if needed +/// func getTimestampForMyMutelist() throws -> UInt32 { +/// let lender = functionThatSomehowReturnsMyMutelist() +/// return try lender.borrow({ event in // Here we are only borrowing, so the compiler won't allow us to copy `event` to an external variable +/// return event.created_at // No need to copy the entire note, we only need the timestamp +/// }) /// } /// ``` -typealias NdbNoteLender = ((_: borrowing UnownedNdbNote) -> Void) throws -> Void - -enum NdbNoteLenderError: Error { - case errorLoadingNote +/// +/// If you need to retain the entire note, you may need to copy it. Here is how: +/// +/// ```swift +/// func getTimestampForMyContactList() throws -> NdbNote { +/// let lender = functionThatSomehowReturnsMyContactList() +/// return try lender.getNoteCopy() // This will automatically make an owned copy of the note, which can be passed around safely. +/// } +/// ``` +enum NdbNoteLender: Sendable { + case ndbNoteKey(Ndb, NoteKey) + case owned(NdbNote) + + init(ndb: Ndb, noteKey: NoteKey) { + self = .ndbNoteKey(ndb, noteKey) + } + + init(ownedNdbNote: NdbNote) { + self = .owned(ownedNdbNote) + } + + /// Borrows the note temporarily + func borrow(_ lendingFunction: (_: borrowing UnownedNdbNote) throws -> T) throws -> T { + switch self { + case .ndbNoteKey(let ndb, let noteKey): + guard !ndb.is_closed else { throw LendingError.ndbClosed } + guard let ndbNoteTxn = ndb.lookup_note_by_key(noteKey) else { throw LendingError.errorLoadingNote } + guard let unownedNote = UnownedNdbNote(ndbNoteTxn) else { throw LendingError.errorLoadingNote } + return try lendingFunction(unownedNote) + case .owned(let note): + return try lendingFunction(UnownedNdbNote(note)) + } + + } + + /// Gets an owned copy of the note + func getCopy() throws -> NdbNote { + return try self.borrow({ ev in + return ev.toOwned() + }) + } + + /// A lenient and simple function to just use a copy, where implementing custom error handling is unfeasible or too burdensome and failures should not stop flow. + /// + /// Since the errors related to borrowing and copying are unlikely, instead of implementing custom error handling, a simple default error handling logic may be used. + /// + /// This implements error handling in the following way: + /// - On debug builds, it will throw an assertion to alert developers that something is off + /// - On production builds, an error will be printed to the logs. + func justUseACopy(_ useFunction: (_: NdbNote) throws -> T) rethrows -> T? { + guard let event = self.justGetACopy() else { return nil } + return try useFunction(event) + } + + /// A lenient and simple function to just use a copy, where implementing custom error handling is unfeasible or too burdensome and failures should not stop flow. + /// + /// Since the errors related to borrowing and copying are unlikely, instead of implementing custom error handling, a simple default error handling logic may be used. + /// + /// This implements error handling in the following way: + /// - On debug builds, it will throw an assertion to alert developers that something is off + /// - On production builds, an error will be printed to the logs. + func justUseACopy(_ useFunction: (_: NdbNote) async throws -> T) async rethrows -> T? { + guard let event = self.justGetACopy() else { return nil } + return try await useFunction(event) + } + + /// A lenient and simple function to just get a copy, where implementing custom error handling is unfeasible or too burdensome and failures should not stop flow. + /// + /// Since the errors related to borrowing and copying are unlikely, instead of implementing custom error handling, a simple default error handling logic may be used. + /// + /// This implements error handling in the following way: + /// - On debug builds, it will throw an assertion to alert developers that something is off + /// - On production builds, an error will be printed to the logs. + func justGetACopy() -> NdbNote? { + do { + return try self.getCopy() + } + catch { + assertionFailure("Unexpected error while fetching a copy of an NdbNote: \(error.localizedDescription)") + Log.error("Unexpected error while fetching a copy of an NdbNote: %s", for: .ndb, error.localizedDescription) + } + return nil + } + + enum LendingError: Error { + case errorLoadingNote + case ndbClosed + } }