Improve NostrNetworkManager interfaces

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 <daniel@daquino.me>
This commit is contained in:
Daniel D’Aquino
2025-09-10 13:52:39 -07:00
parent 2bea2faf3f
commit 3290e1f9d2
21 changed files with 312 additions and 296 deletions

View File

@@ -89,95 +89,6 @@ class NostrNetworkManager {
self.pool.send(.event(event), to: targetRelays, skip_ephemeral: skipEphemeralRelays) 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? { func getRelay(_ id: RelayURL) -> RelayPool.Relay? {
pool.get_relay(id) pool.get_relay(id)
} }

View File

@@ -23,7 +23,29 @@ extension NostrNetworkManager {
self.taskManager = TaskManager() 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<NdbNoteLender> {
let timeout = timeout ?? .seconds(10)
return AsyncStream<NdbNoteLender> { 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. /// 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: case .eose:
continuation.yield(.eose) continuation.yield(.eose)
case .event(let noteKey): case .event(let noteKey):
let lender: NdbNoteLender = { lend in let lender = NdbNoteLender(ndb: self.ndb, noteKey: noteKey)
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)
}
try Task.checkCancellation() 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 { func cancelAllTasks() async {
await self.taskManager.cancelAllTasks() await self.taskManager.cancelAllTasks()
} }
@@ -199,7 +313,7 @@ extension NostrNetworkManager {
enum StreamItem { enum StreamItem {
/// An event which can be borrowed from NostrDB /// An event which can be borrowed from NostrDB
case event(borrow: NdbNoteLender) case event(lender: NdbNoteLender)
/// The end of stored events /// The end of stored events
case eose case eose
} }

View File

@@ -135,15 +135,15 @@ extension NostrNetworkManager {
let filter = NostrFilter(kinds: [.relay_list], authors: [delegate.keypair.pubkey]) let filter = NostrFilter(kinds: [.relay_list], authors: [delegate.keypair.pubkey])
for await item in self.reader.subscribe(filters: [filter]) { for await item in self.reader.subscribe(filters: [filter]) {
switch item { 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() 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.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 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 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 try? self.set(userRelayList: relayList) // Set the validated list
} })
case .eose: continue case .eose: continue
} }
} }

View File

@@ -219,9 +219,10 @@ class RelayPool {
/// - Parameters: /// - Parameters:
/// - filters: The filters specifying the desired content. /// - 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 /// - 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 /// - 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<StreamItem> { func subscribe(filters: [NostrFilter], to desiredRelays: [RelayURL]? = nil, eoseTimeout: Duration? = nil) -> AsyncStream<StreamItem> {
let eoseTimeout = eoseTimeout ?? .seconds(10)
let desiredRelays = desiredRelays ?? self.relays.map({ $0.descriptor.url }) let desiredRelays = desiredRelays ?? self.relays.map({ $0.descriptor.url })
return AsyncStream<StreamItem> { continuation in return AsyncStream<StreamItem> { continuation in
let sub_id = UUID().uuidString let sub_id = UUID().uuidString
@@ -255,7 +256,7 @@ class RelayPool {
} }
}, to: desiredRelays) }, to: desiredRelays)
Task { 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)) } if !eoseSent { continuation.yield(with: .success(.eose)) }
} }
continuation.onTermination = { @Sendable _ in continuation.onTermination = { @Sendable _ in

View File

@@ -117,10 +117,8 @@ class ThreadModel: ObservableObject {
Log.info("subscribing to thread %s ", for: .render, original_event.id.hex()) 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) { for await item in damus_state.nostrNetwork.reader.subscribe(filters: base_filters + meta_filters) {
switch item { switch item {
case .event(let borrow): case .event(let lender):
try? borrow { event in lender.justUseACopy({ handle_event(ev: $0) })
handle_event(ev: event.toOwned())
}
case .eose: case .eose:
guard let txn = NdbTxn(ndb: damus_state.ndb) else { return } 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) load_profiles(context: "thread", load: .from_events(Array(event_map.events)), damus_state: damus_state, txn: txn)

View File

@@ -28,27 +28,16 @@ struct EventLoaderView<Content: View>: View {
self.loadingTask?.cancel() self.loadingTask?.cancel()
} }
func subscribe(filters: [NostrFilter]) { func subscribe() {
self.loadingTask?.cancel() self.loadingTask?.cancel()
self.loadingTask = Task { self.loadingTask = Task {
for await item in await damus_state.nostrNetwork.reader.subscribe(filters: filters) { let lender = try? await damus_state.nostrNetwork.reader.lookup(noteId: self.event_id)
switch item { lender?.justUseACopy({ event = $0 })
case .event(let borrow):
try? borrow { ev in
event = ev.toOwned()
}
break
case .eose:
break
}
}
} }
} }
func load() { func load() {
subscribe(filters: [ subscribe()
NostrFilter(ids: [self.event_id], limit: 1)
])
} }
var body: some View { var body: some View {

View File

@@ -73,16 +73,13 @@ class EventsModel: ObservableObject {
DispatchQueue.main.async { self.loading = true } DispatchQueue.main.async { self.loading = true }
outerLoop: for await item in state.nostrNetwork.reader.subscribe(filters: [get_filter()]) { outerLoop: for await item in state.nostrNetwork.reader.subscribe(filters: [get_filter()]) {
switch item { switch item {
case .event(let borrow): case .event(let lender):
var event: NostrEvent? = nil
try? borrow { ev in
event = ev.toOwned()
}
guard let event else { return }
Task { Task {
if await events.insert(event) { await lender.justUseACopy({ event in
DispatchQueue.main.async { self.objectWillChange.send() } if await events.insert(event) {
} DispatchQueue.main.async { self.objectWillChange.send() }
}
})
} }
case .eose: case .eose:
DispatchQueue.main.async { self.loading = false } DispatchQueue.main.async { self.loading = false }

View File

@@ -50,7 +50,7 @@ class LoadableNostrEventViewModel: ObservableObject {
/// Asynchronously find an event from NostrDB or from the network (if not available on NostrDB) /// Asynchronously find an event from NostrDB or from the network (if not available on NostrDB)
private func loadEvent(noteId: NoteId) async -> NostrEvent? { 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 } guard let res, case .event(let ev) = res else { return nil }
return ev return ev
} }
@@ -78,7 +78,7 @@ class LoadableNostrEventViewModel: ObservableObject {
return .unknown_or_unsupported_kind return .unknown_or_unsupported_kind
} }
case .naddr(let naddr): 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))) return .loaded(route: Route.Thread(thread: ThreadModel(event: event, damus_state: damus_state)))
} }
} }

View File

@@ -45,21 +45,18 @@ class FollowPackModel: ObservableObject {
for await item in damus_state.nostrNetwork.reader.subscribe(filters: [filter], to: to_relays) { for await item in damus_state.nostrNetwork.reader.subscribe(filters: [filter], to: to_relays) {
switch item { switch item {
case .event(borrow: let borrow): case .event(lender: let lender):
var event: NostrEvent? = nil await lender.justUseACopy({ event in
try? borrow { ev in let should_show_event = await should_show_event(state: damus_state, ev: event)
event = ev.toOwned() if event.is_textlike && should_show_event && !event.is_reply()
} {
guard let event else { return } if await self.events.insert(event) {
let should_show_event = await should_show_event(state: damus_state, ev: event) DispatchQueue.main.async {
if event.is_textlike && should_show_event && !event.is_reply() self.objectWillChange.send()
{ }
if await self.events.insert(event) {
DispatchQueue.main.async {
self.objectWillChange.send()
} }
} }
} })
case .eose: case .eose:
continue continue
} }

View File

@@ -38,12 +38,10 @@ class FollowersModel: ObservableObject {
let filters = [filter] let filters = [filter]
self.listener?.cancel() self.listener?.cancel()
self.listener = Task { 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 { switch item {
case .event(let borrow): case .event(let lender):
try? borrow { event in lender.justUseACopy({ self.handle_event(ev: $0) })
self.handle_event(ev: event.toOwned())
}
case .eose: case .eose:
guard let txn = NdbTxn(ndb: self.damus_state.ndb) else { return } guard let txn = NdbTxn(ndb: self.damus_state.ndb) else { return }
load_profiles(txn: txn) load_profiles(txn: txn)
@@ -82,10 +80,8 @@ class FollowersModel: ObservableObject {
self.profilesListener = Task { self.profilesListener = Task {
for await item in await damus_state.nostrNetwork.reader.subscribe(filters: [filter]) { for await item in await damus_state.nostrNetwork.reader.subscribe(filters: [filter]) {
switch item { switch item {
case .event(let borrow): case .event(let lender):
try? borrow { event in lender.justUseACopy({ self.handle_event(ev: $0) })
self.handle_event(ev: event.toOwned())
}
case .eose: break case .eose: break
} }
} }

View File

@@ -66,16 +66,11 @@ class NIP05DomainEventsModel: ObservableObject {
for await item in state.nostrNetwork.reader.subscribe(filters: [filter]) { for await item in state.nostrNetwork.reader.subscribe(filters: [filter]) {
switch item { switch item {
case .event(borrow: let borrow): case .event(let lender):
var event: NostrEvent? = nil await lender.justUseACopy({ await self.add_event($0) })
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 .eose: 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 } DispatchQueue.main.async { self.loading = false }
continue continue
} }

View File

@@ -194,9 +194,9 @@ class SuggestedUsersViewModel: ObservableObject {
guard !Task.isCancelled else { break } guard !Task.isCancelled else { break }
switch item { switch item {
case .event(let borrow): case .event(let lender):
try? borrow { event in lender.justUseACopy({ event in
let followPack = FollowPackEvent.parse(from: event.toOwned()) let followPack = FollowPackEvent.parse(from: event)
guard let id = followPack.uuid else { return } guard let id = followPack.uuid else { return }
@@ -209,7 +209,7 @@ class SuggestedUsersViewModel: ObservableObject {
} }
packsById[id] = latestPackForThisId packsById[id] = latestPackForThisId
} })
case .eose: case .eose:
break break
} }

View File

@@ -78,10 +78,8 @@ class ProfileModel: ObservableObject, Equatable {
text_filter.limit = 500 text_filter.limit = 500
for await item in damus.nostrNetwork.reader.subscribe(filters: [text_filter]) { for await item in damus.nostrNetwork.reader.subscribe(filters: [text_filter]) {
switch item { switch item {
case .event(let borrow): case .event(let lender):
try? borrow { event in lender.justUseACopy({ handleNostrEvent($0) })
handleNostrEvent(event.toOwned())
}
case .eose: break case .eose: break
} }
} }
@@ -96,10 +94,8 @@ class ProfileModel: ObservableObject, Equatable {
profile_filter.authors = [pubkey] profile_filter.authors = [pubkey]
for await item in damus.nostrNetwork.reader.subscribe(filters: [profile_filter, relay_list_filter]) { for await item in damus.nostrNetwork.reader.subscribe(filters: [profile_filter, relay_list_filter]) {
switch item { switch item {
case .event(let borrow): case .event(let lender):
try? borrow { event in lender.justUseACopy({ handleNostrEvent($0) })
handleNostrEvent(event.toOwned())
}
case .eose: break case .eose: break
} }
} }
@@ -129,8 +125,8 @@ class ProfileModel: ObservableObject, Equatable {
print("subscribing to conversation events from and to profile \(pubkey)") 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]) { for await item in self.damus.nostrNetwork.reader.subscribe(filters: [conversations_filter_them, conversations_filter_us]) {
switch item { switch item {
case .event(borrow: let borrow): case .event(let lender):
try? borrow { ev in try? lender.borrow { ev in
if !seen_event.contains(ev.id) { if !seen_event.contains(ev.id) {
let event = ev.toOwned() let event = ev.toOwned()
Task { await self.add_event(event) } Task { await self.add_event(event) }
@@ -210,8 +206,8 @@ class ProfileModel: ObservableObject, Equatable {
self.findRelaysListener = Task { self.findRelaysListener = Task {
for await item in await damus.nostrNetwork.reader.subscribe(filters: [profile_filter]) { for await item in await damus.nostrNetwork.reader.subscribe(filters: [profile_filter]) {
switch item { switch item {
case .event(let borrow): case .event(let lender):
try? borrow { event in try? lender.borrow { event in
if case .contacts = event.known_kind { if case .contacts = event.known_kind {
// TODO: Is this correct? // TODO: Is this correct?
self.legacy_relay_list = decode_json_relays(event.content) self.legacy_relay_list = decode_json_relays(event.content)

View File

@@ -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) { outerLoop: for await item in damus_state.nostrNetwork.reader.subscribe(filters: [get_base_filter(), follow_list_filter], to: to_relays) {
switch item { switch item {
case .event(let borrow): case .event(let lender):
var event: NostrEvent? = nil await lender.justUseACopy({ await self.handleEvent($0) })
try? borrow { ev in
event = ev.toOwned()
}
guard let event else { return }
await self.handleEvent(event)
case .eose: case .eose:
break outerLoop break outerLoop
} }
@@ -136,15 +131,12 @@ func load_profiles<Y>(context: String, load: PubkeysToLoad, damus_state: DamusSt
for await item in damus_state.nostrNetwork.reader.subscribe(filters: [filter]) { for await item in damus_state.nostrNetwork.reader.subscribe(filters: [filter]) {
let now = UInt64(Date.now.timeIntervalSince1970) let now = UInt64(Date.now.timeIntervalSince1970)
switch item { switch item {
case .event(let borrow): case .event(let lender):
var event: NostrEvent? = nil lender.justUseACopy({ event in
try? borrow { ev in if event.known_kind == .metadata {
event = ev.toOwned() damus_state.ndb.write_profile_last_fetched(pubkey: event.pubkey, fetched_at: now)
} }
guard let event else { return } })
if event.known_kind == .metadata {
damus_state.ndb.write_profile_last_fetched(pubkey: event.pubkey, fetched_at: now)
}
case .eose: case .eose:
break break
} }

View File

@@ -47,20 +47,13 @@ class SearchModel: ObservableObject {
} }
print("subscribing to search") print("subscribing to search")
try Task.checkCancellation() try Task.checkCancellation()
outerLoop: for await item in await state.nostrNetwork.reader.subscribe(filters: [search]) { let events = await state.nostrNetwork.reader.query(filters: [search])
try Task.checkCancellation() for event in events {
switch item { if event.is_textlike && event.should_show_event {
case .event(let borrow): await self.add_event(event)
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
} }
} }
guard let txn = NdbTxn(ndb: state.ndb) else { return } guard let txn = NdbTxn(ndb: state.ndb) else { return }
try Task.checkCancellation() try Task.checkCancellation()
load_profiles(context: "search", load: .from_events(self.events.all_events), damus_state: state, txn: txn) load_profiles(context: "search", load: .from_events(self.events.all_events), damus_state: state, txn: txn)

View File

@@ -78,7 +78,7 @@ struct SearchingEventView: View {
case .event(let note_id): case .event(let note_id):
Task { 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 { guard case .event(let ev) = res else {
self.search_state = .not_found self.search_state = .not_found
return return
@@ -87,7 +87,7 @@ struct SearchingEventView: View {
} }
case .profile(let pubkey): case .profile(let pubkey):
Task { 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 { guard case .profile(let pubkey) = res else {
self.search_state = .not_found self.search_state = .not_found
return return
@@ -96,7 +96,7 @@ struct SearchingEventView: View {
} }
case .naddr(let naddr): case .naddr(let naddr):
Task { Task {
let res = await state.nostrNetwork.lookup(naddr: naddr) let res = await state.nostrNetwork.reader.lookup(naddr: naddr)
guard let res = res else { guard let res = res else {
self.search_state = .not_found self.search_state = .not_found
return return

View File

@@ -453,13 +453,8 @@ class HomeModel: ContactsDelegate, ObservableObject {
let filter = NostrFilter(kinds: [.contacts], limit: 1, authors: [damus_state.pubkey]) let filter = NostrFilter(kinds: [.contacts], limit: 1, authors: [damus_state.pubkey])
for await item in damus_state.nostrNetwork.reader.subscribe(filters: [filter]) { for await item in damus_state.nostrNetwork.reader.subscribe(filters: [filter]) {
switch item { switch item {
case .event(let borrow): case .event(let lender):
var event: NostrEvent? = nil await lender.justUseACopy({ await process_event(ev: $0, context: .initialContactList) })
try? borrow { ev in
event = ev.toOwned()
}
guard let event else { return }
await process_event(ev: event, context: .initialContactList)
continue continue
case .eose: case .eose:
if !done_init { if !done_init {
@@ -476,13 +471,8 @@ class HomeModel: ContactsDelegate, ObservableObject {
let relayListFilter = NostrFilter(kinds: [.relay_list], limit: 1, authors: [damus_state.pubkey]) let relayListFilter = NostrFilter(kinds: [.relay_list], limit: 1, authors: [damus_state.pubkey])
for await item in damus_state.nostrNetwork.reader.subscribe(filters: [relayListFilter]) { for await item in damus_state.nostrNetwork.reader.subscribe(filters: [relayListFilter]) {
switch item { switch item {
case .event(let borrow): case .event(let lender):
var event: NostrEvent? = nil await lender.justUseACopy({ await process_event(ev: $0, context: .initialRelayList) })
try? borrow { ev in
event = ev.toOwned()
}
guard let event else { return }
await process_event(ev: event, context: .initialRelayList)
case .eose: break case .eose: break
} }
} }
@@ -545,13 +535,8 @@ class HomeModel: ContactsDelegate, ObservableObject {
self.contactsHandlerTask = Task { self.contactsHandlerTask = Task {
for await item in damus_state.nostrNetwork.reader.subscribe(filters: contacts_filters) { for await item in damus_state.nostrNetwork.reader.subscribe(filters: contacts_filters) {
switch item { switch item {
case .event(let borrow): case .event(let lender):
var event: NostrEvent? = nil await lender.justUseACopy({ await process_event(ev: $0, context: .contacts) })
try? borrow { ev in
var event = ev.toOwned()
}
guard let event else { return }
await self.process_event(ev: event, context: .contacts)
case .eose: continue case .eose: continue
} }
} }
@@ -560,13 +545,8 @@ class HomeModel: ContactsDelegate, ObservableObject {
self.notificationsHandlerTask = Task { self.notificationsHandlerTask = Task {
for await item in damus_state.nostrNetwork.reader.subscribe(filters: notifications_filters) { for await item in damus_state.nostrNetwork.reader.subscribe(filters: notifications_filters) {
switch item { switch item {
case .event(let borrow): case .event(let lender):
var event: NostrEvent? = nil await lender.justUseACopy({ await process_event(ev: $0, context: .notifications) })
try? borrow { ev in
event = ev.toOwned()
}
guard let theEvent = event else { return }
await self.process_event(ev: theEvent, context: .notifications)
case .eose: case .eose:
guard let txn = NdbTxn(ndb: damus_state.ndb) else { return } 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) 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 { self.dmsHandlerTask = Task {
for await item in damus_state.nostrNetwork.reader.subscribe(filters: dms_filters) { for await item in damus_state.nostrNetwork.reader.subscribe(filters: dms_filters) {
switch item { switch item {
case .event(let borrow): case .event(let lender):
var event: NostrEvent? = nil await lender.justUseACopy({ await process_event(ev: $0, context: .dms) })
try? borrow { ev in
event = ev.toOwned()
}
guard let event else { return }
await self.process_event(ev: event, context: .dms)
case .eose: case .eose:
guard let txn = NdbTxn(ndb: damus_state.ndb) else { return } guard let txn = NdbTxn(ndb: damus_state.ndb) else { return }
var dms = dms.dms.flatMap { $0.events } var dms = dms.dms.flatMap { $0.events }
@@ -602,13 +577,8 @@ class HomeModel: ContactsDelegate, ObservableObject {
filter.limit = 0 filter.limit = 0
for await item in damus_state.nostrNetwork.reader.subscribe(filters: [filter], to: [nwc.relay]) { for await item in damus_state.nostrNetwork.reader.subscribe(filters: [filter], to: [nwc.relay]) {
switch item { switch item {
case .event(let borrow): case .event(let lender):
var event: NostrEvent? = nil await lender.justUseACopy({ await process_event(ev: $0, context: .nwc) })
try? borrow { ev in
event = ev.toOwned()
}
guard let event else { return }
await self.process_event(ev: event, context: .nwc)
case .eose: continue case .eose: continue
} }
} }
@@ -663,13 +633,8 @@ class HomeModel: ContactsDelegate, ObservableObject {
} }
for await item in damus_state.nostrNetwork.reader.subscribe(filters: home_filters) { for await item in damus_state.nostrNetwork.reader.subscribe(filters: home_filters) {
switch item { switch item {
case .event(let borrow): case .event(let lender):
var event: NostrEvent? = nil await lender.justUseACopy({ await process_event(ev: $0, context: .home) })
try? borrow { ev in
event = ev.toOwned()
}
guard let event else { return }
await self.process_event(ev: event, context: .home)
case .eose: case .eose:
guard let txn = NdbTxn(ndb: damus_state.ndb) else { return } guard let txn = NdbTxn(ndb: damus_state.ndb) else { return }
DispatchQueue.main.async { DispatchQueue.main.async {

View File

@@ -184,10 +184,8 @@ class WalletModel: ObservableObject {
nostrNetwork.send(event: requestEvent, to: [currentNwcUrl.relay], skipEphemeralRelays: false) nostrNetwork.send(event: requestEvent, to: [currentNwcUrl.relay], skipEphemeralRelays: false)
for await item in nostrNetwork.reader.subscribe(filters: responseFilters, to: [currentNwcUrl.relay], timeout: timeout) { for await item in nostrNetwork.reader.subscribe(filters: responseFilters, to: [currentNwcUrl.relay], timeout: timeout) {
switch item { switch item {
case .event(borrow: let borrow): case .event(let lender):
var responseEvent: NostrEvent? = nil guard let responseEvent = try? lender.getCopy() else { throw .internalError }
try? borrow { ev in responseEvent = ev.toOwned() }
guard let responseEvent else { throw .internalError }
let fullWalletResponse: WalletConnect.FullWalletResponse let fullWalletResponse: WalletConnect.FullWalletResponse
do { fullWalletResponse = try WalletConnect.FullWalletResponse(from: responseEvent, nwc: currentNwcUrl) } do { fullWalletResponse = try WalletConnect.FullWalletResponse(from: responseEvent, nwc: currentNwcUrl) }

View File

@@ -35,13 +35,10 @@ class ZapsModel: ObservableObject {
zapCommsListener = Task { zapCommsListener = Task {
for await item in state.nostrNetwork.reader.subscribe(filters: [filter]) { for await item in state.nostrNetwork.reader.subscribe(filters: [filter]) {
switch item { switch item {
case .event(let borrow): case .event(let lender):
var event: NostrEvent? = nil await lender.justUseACopy({ event in
try? borrow { ev in await self.handle_event(ev: event)
event = ev.toOwned() })
}
guard let event else { return }
await self.handle_event(ev: event)
case .eose: case .eose:
let events = state.events.lookup_zaps(target: target).map { $0.request.ev } let events = state.events.lookup_zaps(target: target).map { $0.request.ev }
guard let txn = NdbTxn(ndb: state.ndb) else { return } guard let txn = NdbTxn(ndb: state.ndb) else { return }

View File

@@ -47,8 +47,8 @@ class NostrNetworkManagerTests: XCTestCase {
Task { Task {
for await item in self.damusState!.nostrNetwork.reader.subscribe(filters: [filter]) { for await item in self.damusState!.nostrNetwork.reader.subscribe(filters: [filter]) {
switch item { switch item {
case .event(borrow: let borrow): case .event(let lender):
try? borrow { event in try? lender.borrow { event in
receivedCount += 1 receivedCount += 1
if eventIds.contains(event.id) { if eventIds.contains(event.id) {
XCTFail("Got duplicate event ID: \(event.id) ") XCTFail("Got duplicate event ID: \(event.id) ")

View File

@@ -5,7 +5,7 @@
// Created by Daniel DAquino on 2025-03-25. // Created by Daniel DAquino 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. /// 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 /// ## Lending out or providing Ndb notes
/// ///
/// ```swift /// ```swift
/// let noteKey = functionThatDoesSomeLookupOrSubscriptionOnNDB()
/// // Define the lender /// // Define the lender
/// let lender: NdbNoteLender = { lend in /// let lender = NdbNoteLender(ndb: self.ndb, noteKey: noteKey)
/// 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
/// }
/// return lender // Return or pass the lender to another class /// 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: /// Assuming you are given a lender, here is how you can use it:
/// ///
/// ```swift /// ```swift
/// let borrow: NdbNoteLender = functionThatProvidesALender() /// func getTimestampForMyMutelist() throws -> UInt32 {
/// try? borrow { note in // You can optionally handle errors if borrowing fails /// let lender = functionThatSomehowReturnsMyMutelist()
/// self.date = note.createdAt // You can do things with the note without copying it over /// 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
/// // self.note = note // Not allowed by the compiler /// return event.created_at // No need to copy the entire note, we only need the timestamp
/// self.note = note.toOwned() // You can copy the note if needed /// })
/// } /// }
/// ``` /// ```
typealias NdbNoteLender = ((_: borrowing UnownedNdbNote) -> Void) throws -> Void ///
/// 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)
enum NdbNoteLenderError: Error { init(ndb: Ndb, noteKey: NoteKey) {
case errorLoadingNote self = .ndbNoteKey(ndb, noteKey)
}
init(ownedNdbNote: NdbNote) {
self = .owned(ownedNdbNote)
}
/// Borrows the note temporarily
func borrow<T>(_ 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<T>(_ 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<T>(_ 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
}
} }