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 + } }