From 991a4a86e61bc2aa1c921a279e11b27bcfab3852 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20D=E2=80=99Aquino?= Date: Fri, 10 Oct 2025 14:12:30 -0700 Subject: [PATCH] Move most of RelayPool away from the Main Thread MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This is a large refactor that aims to improve performance by offloading RelayPool computations into a separate actor outside the main thread. This should reduce congestion on the main thread and thus improve UI performance. Also, the internal subscription callback mechanism was changed to use AsyncStreams to prevent race conditions newly found in that area of the code. Changelog-Fixed: Added performance improvements to timeline scrolling Signed-off-by: Daniel Dโ€™Aquino --- .../xcshareddata/xcschemes/damus.xcscheme | 1 - damus/ContentView.swift | 212 ++++++++-------- .../NostrNetworkManager.swift | 46 ++-- .../SubscriptionManager.swift | 6 +- .../UserRelayListManager.swift | 83 ++++--- damus/Core/Nostr/RelayConnection.swift | 28 +-- damus/Core/Nostr/RelayPool.swift | 226 +++++++++++------- .../ActionBar/Models/ActionBarModel.swift | 5 +- .../ActionBar/Views/EventActionBar.swift | 33 ++- .../ActionBar/Views/EventDetailBar.swift | 14 +- .../Actions/ActionBar/Views/ShareAction.swift | 16 +- .../Actions/Reports/Views/ReportView.swift | 6 +- .../Actions/Reposts/Views/RepostAction.swift | 14 +- damus/Features/Chat/ChatEventView.swift | 12 +- damus/Features/DMs/Views/DMChatView.swift | 6 +- damus/Features/Events/EventMenu.swift | 8 +- damus/Features/Events/EventView.swift | 2 +- damus/Features/Events/SelectedEventView.swift | 2 +- .../FollowPack/Models/FollowPackModel.swift | 2 +- damus/Features/Follows/Models/Contacts+.swift | 8 +- .../Muting/Models/MutedThreadsManager.swift | 2 +- .../Muting/Views/AddMuteItemView.swift | 2 +- .../Features/Muting/Views/MutelistView.swift | 6 +- .../Views/OnboardingSuggestionsView.swift | 2 +- .../Onboarding/Views/SaveKeysView.swift | 31 ++- .../Features/Posting/Models/DraftsModel.swift | 14 +- damus/Features/Posting/Models/PostBox.swift | 31 ++- damus/Features/Posting/Views/PostView.swift | 20 +- .../Profile/Views/EditMetadataView.swift | 10 +- .../Features/Profile/Views/ProfileView.swift | 2 +- .../Features/Relays/Views/AddRelayView.swift | 48 ++-- .../Search/Models/SearchHomeModel.swift | 2 +- .../Search/Views/SearchHeaderView.swift | 4 +- damus/Features/Search/Views/SearchView.swift | 4 +- .../Features/Settings/Views/ConfigView.swift | 6 +- .../Settings/Views/FirstAidSettingsView.swift | 4 +- .../Status/Views/UserStatusSheet.swift | 20 +- .../Features/Timeline/Models/HomeModel.swift | 12 +- .../Models/WalletConnect/WalletConnect+.swift | 12 +- .../Features/Wallet/Models/WalletModel.swift | 2 +- damus/Features/Wallet/Views/NWCSettings.swift | 2 +- .../Wallet/Views/SendPaymentView.swift | 22 +- damus/Features/Zaps/Models/Zaps.swift | 2 +- damus/Features/Zaps/Views/NoteZapButton.swift | 4 +- damus/Notify/Notify.swift | 4 +- .../Notify/PresentFullScreenItemNotify.swift | 2 +- .../ActionViewController.swift | 16 +- nostrdb/UnownedNdbNote.swift | 14 +- nostrscript/NostrScript.swift | 13 +- share extension/ShareViewController.swift | 10 +- 50 files changed, 602 insertions(+), 451 deletions(-) diff --git a/damus.xcodeproj/xcshareddata/xcschemes/damus.xcscheme b/damus.xcodeproj/xcshareddata/xcschemes/damus.xcscheme index b59a9803..5ff8e186 100644 --- a/damus.xcodeproj/xcshareddata/xcschemes/damus.xcscheme +++ b/damus.xcodeproj/xcshareddata/xcschemes/damus.xcscheme @@ -55,7 +55,6 @@ buildConfiguration = "Debug" selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" - enableAddressSanitizer = "YES" launchStyle = "0" useCustomWorkingDirectory = "NO" ignoresPersistentStateOnLaunch = "NO" diff --git a/damus/ContentView.swift b/damus/ContentView.swift index 46c468c1..7799fb83 100644 --- a/damus/ContentView.swift +++ b/damus/ContentView.swift @@ -300,16 +300,18 @@ struct ContentView: View { .ignoresSafeArea(.keyboard) .edgesIgnoringSafeArea(hide_bar ? [.bottom] : []) .onAppear() { - self.connect() - try? AVAudioSession.sharedInstance().setCategory(AVAudioSession.Category.playback, mode: .default, options: .mixWithOthers) - setup_notifications() - if !hasSeenOnboardingSuggestions || damus_state!.settings.always_show_onboarding_suggestions { - active_sheet = .onboardingSuggestions - hasSeenOnboardingSuggestions = true - } - self.appDelegate?.state = damus_state - Task { // We probably don't need this to be a detached task. According to https://docs.swift.org/swift-book/documentation/the-swift-programming-language/concurrency/#Defining-and-Calling-Asynchronous-Functions, awaits are only suspension points that do not block the thread. - await self.listenAndHandleLocalNotifications() + Task { + await self.connect() + try? AVAudioSession.sharedInstance().setCategory(AVAudioSession.Category.playback, mode: .default, options: .mixWithOthers) + setup_notifications() + if !hasSeenOnboardingSuggestions || damus_state!.settings.always_show_onboarding_suggestions { + active_sheet = .onboardingSuggestions + hasSeenOnboardingSuggestions = true + } + self.appDelegate?.state = damus_state + Task { // We probably don't need this to be a detached task. According to https://docs.swift.org/swift-book/documentation/the-swift-programming-language/concurrency/#Defining-and-Calling-Asynchronous-Functions, awaits are only suspension points that do not block the thread. + await self.listenAndHandleLocalNotifications() + } } } .sheet(item: $active_sheet) { item in @@ -371,7 +373,7 @@ struct ContentView: View { self.hide_bar = !show } .onReceive(timer) { n in - self.damus_state?.nostrNetwork.postbox.try_flushing_events() + Task{ await self.damus_state?.nostrNetwork.postbox.try_flushing_events() } self.damus_state!.profiles.profile_data(self.damus_state!.pubkey).status.try_expire() } .onReceive(handle_notify(.report)) { target in @@ -382,45 +384,47 @@ struct ContentView: View { self.confirm_mute = true } .onReceive(handle_notify(.attached_wallet)) { nwc in - try? damus_state.nostrNetwork.userRelayList.load() // Reload relay list to apply changes - - // update the lightning address on our profile when we attach a - // wallet with an associated - guard let ds = self.damus_state, - let lud16 = nwc.lud16, - let keypair = ds.keypair.to_full(), - let profile_txn = ds.profiles.lookup(id: ds.pubkey), - let profile = profile_txn.unsafeUnownedValue, - lud16 != profile.lud16 else { - return + Task { + try? await damus_state.nostrNetwork.userRelayList.load() // Reload relay list to apply changes + + // update the lightning address on our profile when we attach a + // wallet with an associated + guard let ds = self.damus_state, + let lud16 = nwc.lud16, + let keypair = ds.keypair.to_full(), + let profile_txn = ds.profiles.lookup(id: ds.pubkey), + let profile = profile_txn.unsafeUnownedValue, + lud16 != profile.lud16 else { + return + } + + // clear zapper cache for old lud16 + if profile.lud16 != nil { + // TODO: should this be somewhere else, where we process profile events!? + invalidate_zapper_cache(pubkey: keypair.pubkey, profiles: ds.profiles, lnurl: ds.lnurls) + } + + let prof = Profile(name: profile.name, display_name: profile.display_name, about: profile.about, picture: profile.picture, banner: profile.banner, website: profile.website, lud06: profile.lud06, lud16: lud16, nip05: profile.nip05, damus_donation: profile.damus_donation, reactions: profile.reactions) + + guard let ev = make_metadata_event(keypair: keypair, metadata: prof) else { return } + await ds.nostrNetwork.postbox.send(ev) } - - // clear zapper cache for old lud16 - if profile.lud16 != nil { - // TODO: should this be somewhere else, where we process profile events!? - invalidate_zapper_cache(pubkey: keypair.pubkey, profiles: ds.profiles, lnurl: ds.lnurls) - } - - let prof = Profile(name: profile.name, display_name: profile.display_name, about: profile.about, picture: profile.picture, banner: profile.banner, website: profile.website, lud06: profile.lud06, lud16: lud16, nip05: profile.nip05, damus_donation: profile.damus_donation, reactions: profile.reactions) - - guard let ev = make_metadata_event(keypair: keypair, metadata: prof) else { return } - ds.nostrNetwork.postbox.send(ev) } .onReceive(handle_notify(.broadcast)) { ev in guard let ds = self.damus_state else { return } - ds.nostrNetwork.postbox.send(ev) + Task { await ds.nostrNetwork.postbox.send(ev) } } .onReceive(handle_notify(.unfollow)) { target in guard let state = self.damus_state else { return } - _ = handle_unfollow(state: state, unfollow: target.follow_ref) + Task { _ = await handle_unfollow(state: state, unfollow: target.follow_ref) } } .onReceive(handle_notify(.unfollowed)) { unfollow in home.resubscribe(.unfollowing(unfollow)) } .onReceive(handle_notify(.follow)) { target in guard let state = self.damus_state else { return } - handle_follow_notif(state: state, target: target) + Task { await handle_follow_notif(state: state, target: target) } } .onReceive(handle_notify(.followed)) { _ in home.resubscribe(.following) @@ -431,8 +435,10 @@ struct ContentView: View { return } - if !handle_post_notification(keypair: keypair, postbox: state.nostrNetwork.postbox, events: state.events, post: post) { - self.active_sheet = nil + Task { + if await !handle_post_notification(keypair: keypair, postbox: state.nostrNetwork.postbox, events: state.events, post: post) { + self.active_sheet = nil + } } } .onReceive(handle_notify(.new_mutes)) { _ in @@ -475,7 +481,7 @@ struct ContentView: View { } } .onReceive(handle_notify(.disconnect_relays)) { () in - damus_state.nostrNetwork.disconnectRelays() + Task { await damus_state.nostrNetwork.disconnectRelays() } } .onReceive(NotificationCenter.default.publisher(for: UIApplication.willEnterForegroundNotification)) { obj in print("txn: ๐Ÿ“™ DAMUS ACTIVE NOTIFY") @@ -540,27 +546,29 @@ struct ContentView: View { damusClosingTask = nil damus_state.ndb.reopen() // Pinging the network will automatically reconnect any dead websocket connections - damus_state.nostrNetwork.ping() + await damus_state.nostrNetwork.ping() } @unknown default: break } } .onReceive(handle_notify(.onlyzaps_mode)) { hide in - home.filter_events() - - guard let ds = damus_state, - let profile_txn = ds.profiles.lookup(id: ds.pubkey), - let profile = profile_txn.unsafeUnownedValue, - let keypair = ds.keypair.to_full() - else { - return + Task { + home.filter_events() + + guard let ds = damus_state, + let profile_txn = ds.profiles.lookup(id: ds.pubkey), + let profile = profile_txn.unsafeUnownedValue, + let keypair = ds.keypair.to_full() + else { + return + } + + let prof = Profile(name: profile.name, display_name: profile.display_name, about: profile.about, picture: profile.picture, banner: profile.banner, website: profile.website, lud06: profile.lud06, lud16: profile.lud16, nip05: profile.nip05, damus_donation: profile.damus_donation, reactions: !hide) + + guard let profile_ev = make_metadata_event(keypair: keypair, metadata: prof) else { return } + await ds.nostrNetwork.postbox.send(profile_ev) } - - let prof = Profile(name: profile.name, display_name: profile.display_name, about: profile.about, picture: profile.picture, banner: profile.banner, website: profile.website, lud06: profile.lud06, lud16: profile.lud16, nip05: profile.nip05, damus_donation: profile.damus_donation, reactions: !hide) - - guard let profile_ev = make_metadata_event(keypair: keypair, metadata: prof) else { return } - ds.nostrNetwork.postbox.send(profile_ev) } .alert(NSLocalizedString("User muted", comment: "Alert message to indicate the user has been muted"), isPresented: $user_muted_confirm, actions: { Button(NSLocalizedString("Thanks!", comment: "Button to close out of alert that informs that the action to muted a user was successful.")) { @@ -583,20 +591,22 @@ struct ContentView: View { } Button(NSLocalizedString("Yes, Overwrite", comment: "Text of button that confirms to overwrite the existing mutelist.")) { - guard let ds = damus_state, - let keypair = ds.keypair.to_full(), - let muting, - let mutelist = create_or_update_mutelist(keypair: keypair, mprev: nil, to_add: muting) - else { - return + Task { + guard let ds = damus_state, + let keypair = ds.keypair.to_full(), + let muting, + let mutelist = create_or_update_mutelist(keypair: keypair, mprev: nil, to_add: muting) + else { + return + } + + ds.mutelist_manager.set_mutelist(mutelist) + await ds.nostrNetwork.postbox.send(mutelist) + + confirm_overwrite_mutelist = false + confirm_mute = false + user_muted_confirm = true } - - ds.mutelist_manager.set_mutelist(mutelist) - ds.nostrNetwork.postbox.send(mutelist) - - confirm_overwrite_mutelist = false - confirm_mute = false - user_muted_confirm = true } }, message: { Text("No mute list found, create a new one? This will overwrite any previous mute lists.", comment: "Alert message prompt that asks if the user wants to create a new mute list, overwriting previous mute lists.") @@ -624,7 +634,7 @@ struct ContentView: View { } ds.mutelist_manager.set_mutelist(ev) - ds.nostrNetwork.postbox.send(ev) + Task { await ds.nostrNetwork.postbox.send(ev) } } } }, message: { @@ -676,7 +686,7 @@ struct ContentView: View { self.execute_open_action(openAction) } - func connect() { + func connect() async { // nostrdb var mndb = Ndb() if mndb == nil { @@ -698,7 +708,7 @@ struct ContentView: View { let settings = UserSettingsStore.globally_load_for(pubkey: pubkey) - let new_relay_filters = load_relay_filters(pubkey) == nil + let new_relay_filters = await load_relay_filters(pubkey) == nil self.damus_state = DamusState(keypair: keypair, likes: EventCounter(our_pubkey: pubkey), @@ -756,7 +766,7 @@ struct ContentView: View { Log.error("Failed to configure tips: %s", for: .tips, error.localizedDescription) } } - damus_state.nostrNetwork.connect() + await damus_state.nostrNetwork.connect() // TODO: Move this to a better spot. Not sure what is the best signal to listen to for sending initial filters DispatchQueue.main.asyncAfter(deadline: .now() + 0.25, execute: { self.home.send_initial_filters() @@ -764,26 +774,28 @@ struct ContentView: View { } func music_changed(_ state: MusicState) { - guard let damus_state else { return } - switch state { - case .playback_state: - break - case .song(let song): - guard let song, let kp = damus_state.keypair.to_full() else { return } - - let pdata = damus_state.profiles.profile_data(damus_state.pubkey) - - let desc = "\(song.title ?? "Unknown") - \(song.artist ?? "Unknown")" - let encodedDesc = desc.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) - let url = encodedDesc.flatMap { enc in - URL(string: "spotify:search:\(enc)") + Task { + guard let damus_state else { return } + switch state { + case .playback_state: + break + case .song(let song): + guard let song, let kp = damus_state.keypair.to_full() else { return } + + let pdata = damus_state.profiles.profile_data(damus_state.pubkey) + + let desc = "\(song.title ?? "Unknown") - \(song.artist ?? "Unknown")" + let encodedDesc = desc.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) + let url = encodedDesc.flatMap { enc in + URL(string: "spotify:search:\(enc)") + } + let music = UserStatus(type: .music, expires_at: Date.now.addingTimeInterval(song.playbackDuration), content: desc, created_at: UInt32(Date.now.timeIntervalSince1970), url: url) + + pdata.status.music = music + + guard let ev = music.to_note(keypair: kp) else { return } + await damus_state.nostrNetwork.postbox.send(ev) } - let music = UserStatus(type: .music, expires_at: Date.now.addingTimeInterval(song.playbackDuration), content: desc, created_at: UInt32(Date.now.timeIntervalSince1970), url: url) - - pdata.status.music = music - - guard let ev = music.to_note(keypair: kp) else { return } - damus_state.nostrNetwork.postbox.send(ev) } } @@ -935,7 +947,7 @@ func update_filters_with_since(last_of_kind: [UInt32: NostrEvent], filters: [Nos } } - +@MainActor func setup_notifications() { this_app.registerForRemoteNotifications() let center = UNUserNotificationCenter.current() @@ -992,14 +1004,14 @@ func timeline_name(_ timeline: Timeline?) -> String { } @discardableResult -func handle_unfollow(state: DamusState, unfollow: FollowRef) -> Bool { +func handle_unfollow(state: DamusState, unfollow: FollowRef) async -> Bool { guard let keypair = state.keypair.to_full() else { return false } let old_contacts = state.contacts.event - guard let ev = unfollow_reference(postbox: state.nostrNetwork.postbox, our_contacts: old_contacts, keypair: keypair, unfollow: unfollow) + guard let ev = await unfollow_reference(postbox: state.nostrNetwork.postbox, our_contacts: old_contacts, keypair: keypair, unfollow: unfollow) else { return false } @@ -1020,12 +1032,12 @@ func handle_unfollow(state: DamusState, unfollow: FollowRef) -> Bool { } @discardableResult -func handle_follow(state: DamusState, follow: FollowRef) -> Bool { +func handle_follow(state: DamusState, follow: FollowRef) async -> Bool { guard let keypair = state.keypair.to_full() else { return false } - guard let ev = follow_reference(box: state.nostrNetwork.postbox, our_contacts: state.contacts.event, keypair: keypair, follow: follow) + guard let ev = await follow_reference(box: state.nostrNetwork.postbox, our_contacts: state.contacts.event, keypair: keypair, follow: follow) else { return false } @@ -1045,7 +1057,7 @@ func handle_follow(state: DamusState, follow: FollowRef) -> Bool { } @discardableResult -func handle_follow_notif(state: DamusState, target: FollowTarget) -> Bool { +func handle_follow_notif(state: DamusState, target: FollowTarget) async -> Bool { switch target { case .pubkey(let pk): state.contacts.add_friend_pubkey(pk) @@ -1053,10 +1065,10 @@ func handle_follow_notif(state: DamusState, target: FollowTarget) -> Bool { state.contacts.add_friend_contact(ev) } - return handle_follow(state: state, follow: target.follow_ref) + return await handle_follow(state: state, follow: target.follow_ref) } -func handle_post_notification(keypair: FullKeypair, postbox: PostBox, events: EventCache, post: NostrPostResult) -> Bool { +func handle_post_notification(keypair: FullKeypair, postbox: PostBox, events: EventCache, post: NostrPostResult) async -> Bool { switch post { case .post(let post): //let post = tup.0 @@ -1065,17 +1077,17 @@ func handle_post_notification(keypair: FullKeypair, postbox: PostBox, events: Ev guard let new_ev = post.to_event(keypair: keypair) else { return false } - postbox.send(new_ev) + await postbox.send(new_ev) for eref in new_ev.referenced_ids.prefix(3) { // also broadcast at most 3 referenced events if let ev = events.lookup(eref) { - postbox.send(ev) + await postbox.send(ev) } } for qref in new_ev.referenced_quote_ids.prefix(3) { // also broadcast at most 3 referenced quoted events if let ev = events.lookup(qref.note_id) { - postbox.send(ev) + await postbox.send(ev) } } return true diff --git a/damus/Core/Networking/NostrNetworkManager/NostrNetworkManager.swift b/damus/Core/Networking/NostrNetworkManager/NostrNetworkManager.swift index c9b3a393..287693a2 100644 --- a/damus/Core/Networking/NostrNetworkManager/NostrNetworkManager.swift +++ b/damus/Core/Networking/NostrNetworkManager/NostrNetworkManager.swift @@ -50,18 +50,18 @@ class NostrNetworkManager { // MARK: - Control and lifecycle functions /// Connects the app to the Nostr network - func connect() { - self.userRelayList.connect() // Will load the user's list, apply it, and get RelayPool to connect to it. - Task { await self.profilesManager.load() } + func connect() async { + await self.userRelayList.connect() // Will load the user's list, apply it, and get RelayPool to connect to it. + await self.profilesManager.load() } - func disconnectRelays() { - self.pool.disconnect() + func disconnectRelays() async { + await self.pool.disconnect() } func handleAppBackgroundRequest() async { await self.reader.cancelAllTasks() - self.pool.cleanQueuedRequestForSessionEnd() + await self.pool.cleanQueuedRequestForSessionEnd() } func close() async { @@ -75,18 +75,19 @@ class NostrNetworkManager { } // But await on each one to prevent race conditions for await value in group { continue } - pool.close() + await pool.close() } } - func ping() { - self.pool.ping() + func ping() async { + await self.pool.ping() } - func relaysForEvent(event: NostrEvent) -> [RelayURL] { + @MainActor + func relaysForEvent(event: NostrEvent) async -> [RelayURL] { // TODO(tyiu) Ideally this list would be sorted by the event author's outbox relay preferences // and reliability of relays to maximize chances of others finding this event. - if let relays = pool.seen[event.id] { + if let relays = await pool.seen[event.id] { return Array(relays) } @@ -103,30 +104,35 @@ class NostrNetworkManager { /// - This is also to help us migrate to the relay model. // TODO: Define a better interface. This is a temporary scaffold to replace direct relay pool access. After that is done, we can refactor this interface to be cleaner and reduce non-sense. - func sendToNostrDB(event: NostrEvent) { - self.pool.send_raw_to_local_ndb(.typical(.event(event))) + func sendToNostrDB(event: NostrEvent) async { + await self.pool.send_raw_to_local_ndb(.typical(.event(event))) } - func send(event: NostrEvent, to targetRelays: [RelayURL]? = nil, skipEphemeralRelays: Bool = true) { - self.pool.send(.event(event), to: targetRelays, skip_ephemeral: skipEphemeralRelays) + func send(event: NostrEvent, to targetRelays: [RelayURL]? = nil, skipEphemeralRelays: Bool = true) async { + await self.pool.send(.event(event), to: targetRelays, skip_ephemeral: skipEphemeralRelays) } + @MainActor func getRelay(_ id: RelayURL) -> RelayPool.Relay? { pool.get_relay(id) } + @MainActor var connectedRelays: [RelayPool.Relay] { self.pool.relays } + @MainActor var ourRelayDescriptors: [RelayPool.RelayDescriptor] { self.pool.our_descriptors } - func relayURLsThatSawNote(id: NoteId) -> Set? { - return self.pool.seen[id] + @MainActor + func relayURLsThatSawNote(id: NoteId) async -> Set? { + return await self.pool.seen[id] } + @MainActor func determineToRelays(filters: RelayFilters) -> [RelayURL] { return self.pool.our_descriptors .map { $0.url } @@ -137,8 +143,8 @@ class NostrNetworkManager { // TODO: Move this to NWCManager @discardableResult - func nwcPay(url: WalletConnectURL, post: PostBox, invoice: String, delay: TimeInterval? = 5.0, on_flush: OnFlush? = nil, zap_request: NostrEvent? = nil) -> NostrEvent? { - WalletConnect.pay(url: url, pool: self.pool, post: post, invoice: invoice, zap_request: nil) + func nwcPay(url: WalletConnectURL, post: PostBox, invoice: String, delay: TimeInterval? = 5.0, on_flush: OnFlush? = nil, zap_request: NostrEvent? = nil) async -> NostrEvent? { + await WalletConnect.pay(url: url, pool: self.pool, post: post, invoice: invoice, zap_request: nil) } /// Send a donation zap to the Damus team @@ -154,7 +160,7 @@ class NostrNetworkManager { } print("damus-donation donating...") - WalletConnect.pay(url: nwc, pool: self.pool, post: self.postbox, invoice: invoice, zap_request: nil, delay: nil) + await WalletConnect.pay(url: nwc, pool: self.pool, post: self.postbox, invoice: invoice, zap_request: nil, delay: nil) } } diff --git a/damus/Core/Networking/NostrNetworkManager/SubscriptionManager.swift b/damus/Core/Networking/NostrNetworkManager/SubscriptionManager.swift index 7ea4cfb6..d08de04f 100644 --- a/damus/Core/Networking/NostrNetworkManager/SubscriptionManager.swift +++ b/damus/Core/Networking/NostrNetworkManager/SubscriptionManager.swift @@ -192,14 +192,14 @@ extension NostrNetworkManager { Self.logger.debug("Network subscription \(id.uuidString, privacy: .public): Started") let streamTask = Task { - while !self.pool.open { + while await !self.pool.open { Self.logger.info("\(id.uuidString, privacy: .public): RelayPool closed. Sleeping for 1 second before resuming.") try await Task.sleep(nanoseconds: 1_000_000_000) continue } do { - for await item in self.pool.subscribe(filters: filters, to: desiredRelays, id: id) { + for await item in await self.pool.subscribe(filters: filters, to: desiredRelays, id: id) { // NO-OP. Notes will be automatically ingested by NostrDB // TODO: Improve efficiency of subscriptions? try Task.checkCancellation() @@ -333,7 +333,7 @@ extension NostrNetworkManager { } // 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) { + outerLoop: for await item in await self.pool.subscribe(filters: [NostrFilter(ids: [noteId], limit: 1)], to: targetRelays, eoseTimeout: timeout) { switch item { case .event(let event): return NdbNoteLender(ownedNdbNote: event) diff --git a/damus/Core/Networking/NostrNetworkManager/UserRelayListManager.swift b/damus/Core/Networking/NostrNetworkManager/UserRelayListManager.swift index 104b2a92..83fc96ca 100644 --- a/damus/Core/Networking/NostrNetworkManager/UserRelayListManager.swift +++ b/damus/Core/Networking/NostrNetworkManager/UserRelayListManager.swift @@ -122,68 +122,68 @@ extension NostrNetworkManager { // MARK: - Listening to and handling relay updates from the network - func connect() { - self.load() + func connect() async { + await self.load() self.relayListObserverTask?.cancel() self.relayListObserverTask = Task { await self.listenAndHandleRelayUpdates() } self.walletUpdatesObserverTask?.cancel() - self.walletUpdatesObserverTask = handle_notify(.attached_wallet).sink { _ in self.load() } + self.walletUpdatesObserverTask = handle_notify(.attached_wallet).sink { _ in Task { await self.load() } } } func listenAndHandleRelayUpdates() async { let filter = NostrFilter(kinds: [.relay_list], authors: [delegate.keypair.pubkey]) for await noteLender in self.reader.streamIndefinitely(filters: [filter]) { let currentRelayListCreationDate = self.getUserCurrentRelayListCreationDate() - try? noteLender.borrow({ note in + try? await noteLender.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 + try? await self.set(userRelayList: relayList) // Set the validated list }) } } // MARK: - Editing the user's relay list - func upsert(relay: NIP65.RelayList.RelayItem, force: Bool = false, overwriteExisting: Bool = false) throws(UpdateError) { + func upsert(relay: NIP65.RelayList.RelayItem, force: Bool = false, overwriteExisting: Bool = false) async throws(UpdateError) { guard let currentUserRelayList = force ? self.getBestEffortRelayList() : self.getUserCurrentRelayList() else { throw .noInitialRelayList } guard !currentUserRelayList.relays.keys.contains(relay.url) || overwriteExisting else { throw .relayAlreadyExists } var newList = currentUserRelayList.relays newList[relay.url] = relay - try self.set(userRelayList: NIP65.RelayList(relays: Array(newList.values))) + try await self.set(userRelayList: NIP65.RelayList(relays: Array(newList.values))) } - func insert(relay: NIP65.RelayList.RelayItem, force: Bool = false) throws(UpdateError) { + func insert(relay: NIP65.RelayList.RelayItem, force: Bool = false) async throws(UpdateError) { guard let currentUserRelayList = force ? self.getBestEffortRelayList() : self.getUserCurrentRelayList() else { throw .noInitialRelayList } guard currentUserRelayList.relays[relay.url] == nil else { throw .relayAlreadyExists } - try self.upsert(relay: relay, force: force) + try await self.upsert(relay: relay, force: force) } - func remove(relayURL: RelayURL, force: Bool = false) throws(UpdateError) { + func remove(relayURL: RelayURL, force: Bool = false) async throws(UpdateError) { guard let currentUserRelayList = force ? self.getBestEffortRelayList() : self.getUserCurrentRelayList() else { throw .noInitialRelayList } guard currentUserRelayList.relays.keys.contains(relayURL) || force else { throw .noSuchRelay } var newList = currentUserRelayList.relays newList[relayURL] = nil - try self.set(userRelayList: NIP65.RelayList(relays: Array(newList.values))) + try await self.set(userRelayList: NIP65.RelayList(relays: Array(newList.values))) } - func set(userRelayList: NIP65.RelayList) throws(UpdateError) { + func set(userRelayList: NIP65.RelayList) async throws(UpdateError) { guard let fullKeypair = delegate.keypair.to_full() else { throw .notAuthorizedToChangeRelayList } guard let relayListEvent = userRelayList.toNostrEvent(keypair: fullKeypair) else { throw .cannotFormRelayListEvent } - self.apply(newRelayList: self.computeRelaysToConnectTo(with: userRelayList)) + await self.apply(newRelayList: self.computeRelaysToConnectTo(with: userRelayList)) - self.pool.send(.event(relayListEvent)) // This will send to NostrDB as well, which will locally save that NIP-65 event + await self.pool.send(.event(relayListEvent)) // This will send to NostrDB as well, which will locally save that NIP-65 event self.delegate.latestRelayListEventIdHex = relayListEvent.id.hex() // Make sure we are able to recall this event from NostrDB } // MARK: - Syncing our saved user relay list with the active `RelayPool` /// Loads the current user relay list - func load() { - self.apply(newRelayList: self.relaysToConnectTo()) + func load() async { + await self.apply(newRelayList: self.relaysToConnectTo()) } /// Loads a new relay list into the active relay pool, making sure it matches the specified relay list. @@ -197,7 +197,8 @@ extension NostrNetworkManager { /// /// - This is `private` because syncing the user's saved relay list with the relay pool is `NostrNetworkManager`'s responsibility, /// so we do not want other classes to forcibly load this. - private func apply(newRelayList: [RelayPool.RelayDescriptor]) { + @MainActor + private func apply(newRelayList: [RelayPool.RelayDescriptor]) async { let currentRelayList = self.pool.relays.map({ $0.descriptor }) var changed = false @@ -217,31 +218,37 @@ extension NostrNetworkManager { let relaysToRemove = currentRelayURLs.subtracting(newRelayURLs) let relaysToAdd = newRelayURLs.subtracting(currentRelayURLs) - // Remove relays not in the new list - relaysToRemove.forEach { url in - pool.remove_relay(url) - changed = true - } + await withTaskGroup { taskGroup in + // Remove relays not in the new list + relaysToRemove.forEach { url in + taskGroup.addTask(operation: { await self.pool.remove_relay(url) }) + changed = true + } - // Add new relays from the new list - relaysToAdd.forEach { url in - guard let descriptor = newRelayList.first(where: { $0.url == url }) else { return } - add_new_relay( - model_cache: delegate.relayModelCache, - relay_filters: delegate.relayFilters, - pool: pool, - descriptor: descriptor, - new_relay_filters: new_relay_filters, - logging_enabled: delegate.developerMode - ) - changed = true + // Add new relays from the new list + relaysToAdd.forEach { url in + guard let descriptor = newRelayList.first(where: { $0.url == url }) else { return } + taskGroup.addTask(operation: { + await add_new_relay( + model_cache: self.delegate.relayModelCache, + relay_filters: self.delegate.relayFilters, + pool: self.pool, + descriptor: descriptor, + new_relay_filters: new_relay_filters, + logging_enabled: self.delegate.developerMode + ) + }) + changed = true + } + + for await value in taskGroup { continue } } // Always tell RelayPool to connect whether or not we are already connected. // This is because: // 1. Internally it won't redo the connection because of internal checks // 2. Even if the relay list has not changed, relays may have been disconnected from app lifecycle or other events - pool.connect() + await pool.connect() if changed { notify(.relays_changed) @@ -281,8 +288,8 @@ fileprivate extension NIP65.RelayList { /// - descriptor: The description of the relay being added /// - new_relay_filters: Whether to insert new relay filters /// - logging_enabled: Whether logging is enabled -fileprivate func add_new_relay(model_cache: RelayModelCache, relay_filters: RelayFilters, pool: RelayPool, descriptor: RelayPool.RelayDescriptor, new_relay_filters: Bool, logging_enabled: Bool) { - try? pool.add_relay(descriptor) +fileprivate func add_new_relay(model_cache: RelayModelCache, relay_filters: RelayFilters, pool: RelayPool, descriptor: RelayPool.RelayDescriptor, new_relay_filters: Bool, logging_enabled: Bool) async { + try? await pool.add_relay(descriptor) let url = descriptor.url let relay_id = url @@ -300,7 +307,7 @@ fileprivate func add_new_relay(model_cache: RelayModelCache, relay_filters: Rela model_cache.insert(model: model) if logging_enabled { - pool.setLog(model.log, for: relay_id) + Task { await pool.setLog(model.log, for: relay_id) } } // if this is the first time adding filters, we should filter non-paid relays diff --git a/damus/Core/Nostr/RelayConnection.swift b/damus/Core/Nostr/RelayConnection.swift index 987e1bd5..608c9ea7 100644 --- a/damus/Core/Nostr/RelayConnection.swift +++ b/damus/Core/Nostr/RelayConnection.swift @@ -48,13 +48,13 @@ final class RelayConnection: ObservableObject { private lazy var socket = WebSocket(relay_url.url) private var subscriptionToken: AnyCancellable? - private var handleEvent: (NostrConnectionEvent) -> () + private var handleEvent: (NostrConnectionEvent) async -> () private var processEvent: (WebSocketEvent) -> () private let relay_url: RelayURL var log: RelayLog? init(url: RelayURL, - handleEvent: @escaping (NostrConnectionEvent) -> (), + handleEvent: @escaping (NostrConnectionEvent) async -> (), processUnverifiedWSEvent: @escaping (WebSocketEvent) -> ()) { self.relay_url = url @@ -95,12 +95,12 @@ final class RelayConnection: ObservableObject { .sink { [weak self] completion in switch completion { case .failure(let error): - self?.receive(event: .error(error)) + Task { await self?.receive(event: .error(error)) } case .finished: - self?.receive(event: .disconnected(.normalClosure, nil)) + Task { await self?.receive(event: .disconnected(.normalClosure, nil)) } } } receiveValue: { [weak self] event in - self?.receive(event: event) + Task { await self?.receive(event: event) } } socket.connect() @@ -138,7 +138,7 @@ final class RelayConnection: ObservableObject { } } - private func receive(event: WebSocketEvent) { + private func receive(event: WebSocketEvent) async { assert(!Thread.isMainThread, "This code must not be executed on the main thread") processEvent(event) switch event { @@ -149,7 +149,7 @@ final class RelayConnection: ObservableObject { self.isConnecting = false } case .message(let message): - self.receive(message: message) + await self.receive(message: message) case .disconnected(let closeCode, let reason): if closeCode != .normalClosure { Log.error("โš ๏ธ Warning: RelayConnection (%d) closed with code: %s", for: .networking, String(describing: closeCode), String(describing: reason)) @@ -176,10 +176,8 @@ final class RelayConnection: ObservableObject { self.reconnect_with_backoff() } } - DispatchQueue.main.async { - guard let ws_connection_event = NostrConnectionEvent.WSConnectionEvent.from(full_ws_event: event) else { return } - self.handleEvent(.ws_connection_event(ws_connection_event)) - } + guard let ws_connection_event = NostrConnectionEvent.WSConnectionEvent.from(full_ws_event: event) else { return } + await self.handleEvent(.ws_connection_event(ws_connection_event)) if let description = event.description { log?.add(description) @@ -213,21 +211,19 @@ final class RelayConnection: ObservableObject { } } - private func receive(message: URLSessionWebSocketTask.Message) { + private func receive(message: URLSessionWebSocketTask.Message) async { switch message { case .string(let messageString): // NOTE: Once we switch to the local relay model, // we will not need to verify nostr events at this point. if let ev = decode_and_verify_nostr_response(txt: messageString) { - DispatchQueue.main.async { - self.handleEvent(.nostr_event(ev)) - } + await self.handleEvent(.nostr_event(ev)) return } print("failed to decode event \(messageString)") case .data(let messageData): if let messageString = String(data: messageData, encoding: .utf8) { - receive(message: .string(messageString)) + await receive(message: .string(messageString)) } @unknown default: print("An unexpected URLSessionWebSocketTask.Message was received.") diff --git a/damus/Core/Nostr/RelayPool.swift b/damus/Core/Nostr/RelayPool.swift index 86705744..8cf8e434 100644 --- a/damus/Core/Nostr/RelayPool.swift +++ b/damus/Core/Nostr/RelayPool.swift @@ -12,7 +12,7 @@ struct RelayHandler { let sub_id: String let filters: [NostrFilter]? let to: [RelayURL]? - var callback: (RelayURL, NostrConnectionEvent) -> () + var handler: AsyncStream<(RelayURL, NostrConnectionEvent)>.Continuation } struct QueuedRequest { @@ -27,7 +27,8 @@ struct SeenEvent: Hashable { } /// Establishes and manages connections and subscriptions to a list of relays. -class RelayPool { +actor RelayPool { + @MainActor private(set) var relays: [Relay] = [] var open: Bool = false var handlers: [RelayHandler] = [] @@ -50,65 +51,86 @@ class RelayPool { /// This is to avoid error states and undefined behaviour related to hitting subscription limits on the relays, by letting those wait instead โ€” with the principle that although slower is not ideal, it is better than completely broken. static let MAX_CONCURRENT_SUBSCRIPTION_LIMIT = 14 // This number is only an educated guess based on some local experiments. - func close() { - disconnect() - relays = [] + func close() async { + await disconnect() + await clearRelays() open = false handlers = [] request_queue = [] - seen.removeAll() + await clearSeen() counts = [:] keypair = nil } + + @MainActor + private func clearRelays() { + relays = [] + } + + private func clearSeen() { + seen.removeAll() + } init(ndb: Ndb, keypair: Keypair? = nil) { self.ndb = ndb self.keypair = keypair network_monitor.pathUpdateHandler = { [weak self] path in - if (path.status == .satisfied || path.status == .requiresConnection) && self?.last_network_status != path.status { - DispatchQueue.main.async { - self?.connect_to_disconnected() - } - } - - if let self, path.status != self.last_network_status { - for relay in self.relays { - relay.connection.log?.add("Network state: \(path.status)") - } - } - - self?.last_network_status = path.status + Task { await self?.pathUpdateHandler(path: path) } } network_monitor.start(queue: network_monitor_queue) } + private func pathUpdateHandler(path: NWPath) async { + if (path.status == .satisfied || path.status == .requiresConnection) && self.last_network_status != path.status { + await self.connect_to_disconnected() + } + + if path.status != self.last_network_status { + for relay in await self.relays { + relay.connection.log?.add("Network state: \(path.status)") + } + } + + self.last_network_status = path.status + } + + @MainActor var our_descriptors: [RelayDescriptor] { return all_descriptors.filter { d in !d.ephemeral } } + @MainActor var all_descriptors: [RelayDescriptor] { relays.map { r in r.descriptor } } + @MainActor var num_connected: Int { return relays.reduce(0) { n, r in n + (r.connection.isConnected ? 1 : 0) } } func remove_handler(sub_id: String) { - self.handlers = handlers.filter { $0.sub_id != sub_id } + self.handlers = handlers.filter { + if $0.sub_id != sub_id { + return true + } + else { + $0.handler.finish() + return false + } + } Log.debug("Removing %s handler, current: %d", for: .networking, sub_id, handlers.count) } - func ping() { - Log.info("Pinging %d relays", for: .networking, relays.count) - for relay in relays { + func ping() async { + Log.info("Pinging %d relays", for: .networking, await relays.count) + for relay in await relays { relay.connection.ping() } } - @MainActor - func register_handler(sub_id: String, filters: [NostrFilter]?, to relays: [RelayURL]? = nil, handler: @escaping (RelayURL, NostrConnectionEvent) -> ()) async { + func register_handler(sub_id: String, filters: [NostrFilter]?, to relays: [RelayURL]? = nil, handler: AsyncStream<(RelayURL, NostrConnectionEvent)>.Continuation) async { while handlers.count > Self.MAX_CONCURRENT_SUBSCRIPTION_LIMIT { Log.debug("%s: Too many subscriptions, waiting for subscription pool to clear", for: .networking, sub_id) try? await Task.sleep(for: .seconds(1)) @@ -117,20 +139,22 @@ class RelayPool { handlers = handlers.filter({ handler in if handler.sub_id == sub_id { Log.error("Duplicate handler detected for the same subscription ID. Overriding.", for: .networking) + handler.handler.finish() return false } else { return true } }) - self.handlers.append(RelayHandler(sub_id: sub_id, filters: filters, to: relays, callback: handler)) + self.handlers.append(RelayHandler(sub_id: sub_id, filters: filters, to: relays, handler: handler)) Log.debug("Registering %s handler, current: %d", for: .networking, sub_id, self.handlers.count) } - func remove_relay(_ relay_id: RelayURL) { + @MainActor + func remove_relay(_ relay_id: RelayURL) async { var i: Int = 0 - self.disconnect(to: [relay_id]) + await self.disconnect(to: [relay_id]) for relay in relays { if relay.id == relay_id { @@ -143,13 +167,13 @@ class RelayPool { } } - func add_relay(_ desc: RelayDescriptor) throws(RelayError) { + func add_relay(_ desc: RelayDescriptor) async throws(RelayError) { let relay_id = desc.url - if get_relay(relay_id) != nil { + if await get_relay(relay_id) != nil { throw RelayError.RelayAlreadyExists } let conn = RelayConnection(url: desc.url, handleEvent: { event in - self.handle_event(relay_id: relay_id, event: event) + await self.handle_event(relay_id: relay_id, event: event) }, processUnverifiedWSEvent: { wsev in guard case .message(let msg) = wsev, case .string(let str) = msg @@ -159,19 +183,24 @@ class RelayPool { self.message_received_function?((str, desc)) }) let relay = Relay(descriptor: desc, connection: conn) + await self.appendRelayToList(relay: relay) + } + + @MainActor + private func appendRelayToList(relay: Relay) { self.relays.append(relay) } - func setLog(_ log: RelayLog, for relay_id: RelayURL) { + func setLog(_ log: RelayLog, for relay_id: RelayURL) async { // add the current network state to the log log.add("Network state: \(network_monitor.currentPath.status)") - get_relay(relay_id)?.connection.log = log + await get_relay(relay_id)?.connection.log = log } /// This is used to retry dead connections - func connect_to_disconnected() { - for relay in relays { + func connect_to_disconnected() async { + for relay in await relays { let c = relay.connection let is_connecting = c.isConnecting @@ -188,16 +217,16 @@ class RelayPool { } } - func reconnect(to: [RelayURL]? = nil) { - let relays = to.map{ get_relays($0) } ?? self.relays + func reconnect(to targetRelays: [RelayURL]? = nil) async { + let relays = await getRelays(targetRelays: targetRelays) for relay in relays { // don't try to reconnect to broken relays relay.connection.reconnect() } } - func connect(to: [RelayURL]? = nil) { - let relays = to.map{ get_relays($0) } ?? self.relays + func connect(to targetRelays: [RelayURL]? = nil) async { + let relays = await getRelays(targetRelays: targetRelays) for relay in relays { relay.connection.connect() } @@ -205,15 +234,20 @@ class RelayPool { open = true } - func disconnect(to: [RelayURL]? = nil) { + func disconnect(to targetRelays: [RelayURL]? = nil) async { // Mark as closed first, to prevent other classes from pulling data while the relays are being disconnected open = false - let relays = to.map{ get_relays($0) } ?? self.relays + let relays = await getRelays(targetRelays: targetRelays) for relay in relays { relay.connection.disconnect() } } + @MainActor + func getRelays(targetRelays: [RelayURL]? = nil) -> [Relay] { + targetRelays.map{ get_relays($0) } ?? self.relays + } + /// Deletes queued up requests that should not persist between app sessions (i.e. when the app goes to background then back to foreground) func cleanQueuedRequestForSessionEnd() { request_queue = request_queue.filter { request in @@ -231,14 +265,14 @@ class RelayPool { } } - func unsubscribe(sub_id: String, to: [RelayURL]? = nil) { + func unsubscribe(sub_id: String, to: [RelayURL]? = nil) async { if to == nil { self.remove_handler(sub_id: sub_id) } - self.send(.unsubscribe(sub_id), to: to) + await self.send(.unsubscribe(sub_id), to: to) } - func subscribe(sub_id: String, filters: [NostrFilter], handler: @escaping (RelayURL, NostrConnectionEvent) -> (), to: [RelayURL]? = nil) { + func subscribe(sub_id: String, filters: [NostrFilter], handler: AsyncStream<(RelayURL, NostrConnectionEvent)>.Continuation, to: [RelayURL]? = nil) { Task { await register_handler(sub_id: sub_id, filters: filters, to: to, handler: handler) @@ -246,7 +280,7 @@ class RelayPool { // When the caller specifies specific relays, do not skip ephemeral relays to respect the exact list given by the caller. let shouldSkipEphemeralRelays = to == nil ? true : false - send(.subscribe(.init(filters: filters, sub_id: sub_id)), to: to, skip_ephemeral: shouldSkipEphemeralRelays) + await send(.subscribe(.init(filters: filters, sub_id: sub_id)), to: to, skip_ephemeral: shouldSkipEphemeralRelays) } } @@ -257,9 +291,9 @@ class RelayPool { /// - 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 /// - Returns: Returns an async stream that callers can easily consume via a for-loop - func subscribe(filters: [NostrFilter], to desiredRelays: [RelayURL]? = nil, eoseTimeout: Duration? = nil, id: UUID? = nil) -> AsyncStream { + func subscribe(filters: [NostrFilter], to desiredRelays: [RelayURL]? = nil, eoseTimeout: Duration? = nil, id: UUID? = nil) async -> AsyncStream { let eoseTimeout = eoseTimeout ?? .seconds(5) - let desiredRelays = desiredRelays ?? self.relays.map({ $0.descriptor.url }) + let desiredRelays = await getRelays(targetRelays: desiredRelays) let startTime = CFAbsoluteTimeGetCurrent() return AsyncStream { continuation in let id = id ?? UUID() @@ -267,34 +301,40 @@ class RelayPool { var seenEvents: Set = [] var relaysWhoFinishedInitialResults: Set = [] var eoseSent = false - self.subscribe(sub_id: sub_id, filters: filters, handler: { (relayUrl, connectionEvent) in - switch connectionEvent { - case .ws_connection_event(let ev): - // Websocket events such as connect/disconnect/error are already handled in `RelayConnection`. Do not perform any handling here. - // For the future, perhaps we should abstract away `.ws_connection_event` in `RelayPool`? Seems like something to be handled on the `RelayConnection` layer. - break - case .nostr_event(let nostrResponse): - guard nostrResponse.subid == sub_id else { return } // Do not stream items that do not belong in this subscription - switch nostrResponse { - case .event(_, let nostrEvent): - if seenEvents.contains(nostrEvent.id) { break } // Don't send two of the same events. - continuation.yield(with: .success(.event(nostrEvent))) - seenEvents.insert(nostrEvent.id) - case .notice(let note): - break // We do not support handling these yet - case .eose(_): - relaysWhoFinishedInitialResults.insert(relayUrl) - let desiredAndConnectedRelays = desiredRelays ?? self.relays.filter({ $0.connection.isConnected }).map({ $0.descriptor.url }) - Log.debug("RelayPool subscription %s: EOSE from %s. EOSE count: %d/%d. Elapsed: %.2f seconds.", for: .networking, id.uuidString, relayUrl.absoluteString, relaysWhoFinishedInitialResults.count, Set(desiredAndConnectedRelays).count, CFAbsoluteTimeGetCurrent() - startTime) - if relaysWhoFinishedInitialResults == Set(desiredAndConnectedRelays) { - continuation.yield(with: .success(.eose)) - eoseSent = true + let upstreamStream = AsyncStream<(RelayURL, NostrConnectionEvent)> { upstreamContinuation in + self.subscribe(sub_id: sub_id, filters: filters, handler: upstreamContinuation, to: desiredRelays.map({ $0.descriptor.url })) + } + let upstreamStreamingTask = Task { + for await (relayUrl, connectionEvent) in upstreamStream { + try Task.checkCancellation() + switch connectionEvent { + case .ws_connection_event(let ev): + // Websocket events such as connect/disconnect/error are already handled in `RelayConnection`. Do not perform any handling here. + // For the future, perhaps we should abstract away `.ws_connection_event` in `RelayPool`? Seems like something to be handled on the `RelayConnection` layer. + break + case .nostr_event(let nostrResponse): + guard nostrResponse.subid == sub_id else { return } // Do not stream items that do not belong in this subscription + switch nostrResponse { + case .event(_, let nostrEvent): + if seenEvents.contains(nostrEvent.id) { break } // Don't send two of the same events. + continuation.yield(with: .success(.event(nostrEvent))) + seenEvents.insert(nostrEvent.id) + case .notice(let note): + break // We do not support handling these yet + case .eose(_): + relaysWhoFinishedInitialResults.insert(relayUrl) + let desiredAndConnectedRelays = desiredRelays.filter({ $0.connection.isConnected }).map({ $0.descriptor.url }) + Log.debug("RelayPool subscription %s: EOSE from %s. EOSE count: %d/%d. Elapsed: %.2f seconds.", for: .networking, id.uuidString, relayUrl.absoluteString, relaysWhoFinishedInitialResults.count, Set(desiredAndConnectedRelays).count, CFAbsoluteTimeGetCurrent() - startTime) + if relaysWhoFinishedInitialResults == Set(desiredAndConnectedRelays) { + continuation.yield(with: .success(.eose)) + eoseSent = true + } + case .ok(_): break // No need to handle this, we are not sending an event to the relay + case .auth(_): break // Handled in a separate function in RelayPool } - case .ok(_): break // No need to handle this, we are not sending an event to the relay - case .auth(_): break // Handled in a separate function in RelayPool } } - }, to: desiredRelays) + } let timeoutTask = Task { try? await Task.sleep(for: eoseTimeout) if !eoseSent { continuation.yield(with: .success(.eose)) } @@ -308,9 +348,12 @@ class RelayPool { @unknown default: break } - self.unsubscribe(sub_id: sub_id, to: desiredRelays) - self.remove_handler(sub_id: sub_id) + Task { + await self.unsubscribe(sub_id: sub_id, to: desiredRelays.map({ $0.descriptor.url })) + await self.remove_handler(sub_id: sub_id) + } timeoutTask.cancel() + upstreamStreamingTask.cancel() } } } @@ -322,11 +365,11 @@ class RelayPool { case eose } - func subscribe_to(sub_id: String, filters: [NostrFilter], to: [RelayURL]?, handler: @escaping (RelayURL, NostrConnectionEvent) -> ()) { + func subscribe_to(sub_id: String, filters: [NostrFilter], to: [RelayURL]?, handler: AsyncStream<(RelayURL, NostrConnectionEvent)>.Continuation) { Task { await register_handler(sub_id: sub_id, filters: filters, to: to, handler: handler) - send(.subscribe(.init(filters: filters, sub_id: sub_id)), to: to) + await send(.subscribe(.init(filters: filters, sub_id: sub_id)), to: to) } } @@ -341,7 +384,6 @@ class RelayPool { return c } - @MainActor func queue_req(r: NostrRequestType, relay: RelayURL, skip_ephemeral: Bool) { let count = count_queued(relay: relay) guard count <= 10 else { @@ -365,8 +407,8 @@ class RelayPool { } } - func send_raw(_ req: NostrRequestType, to: [RelayURL]? = nil, skip_ephemeral: Bool = true) { - let relays = to.map{ get_relays($0) } ?? self.relays + func send_raw(_ req: NostrRequestType, to: [RelayURL]? = nil, skip_ephemeral: Bool = true) async { + let relays = await getRelays(targetRelays: to) self.send_raw_to_local_ndb(req) // Always send Nostr events and data to NostrDB for a local copy @@ -394,15 +436,17 @@ class RelayPool { } } - func send(_ req: NostrRequest, to: [RelayURL]? = nil, skip_ephemeral: Bool = true) { - send_raw(.typical(req), to: to, skip_ephemeral: skip_ephemeral) + func send(_ req: NostrRequest, to: [RelayURL]? = nil, skip_ephemeral: Bool = true) async { + await send_raw(.typical(req), to: to, skip_ephemeral: skip_ephemeral) } + @MainActor func get_relays(_ ids: [RelayURL]) -> [Relay] { // don't include ephemeral relays in the default list to query relays.filter { ids.contains($0.id) } } + @MainActor func get_relay(_ id: RelayURL) -> Relay? { relays.first(where: { $0.id == id }) } @@ -415,7 +459,7 @@ class RelayPool { } print("running queueing request: \(req.req) for \(relay_id)") - self.send_raw(req.req, to: [relay_id], skip_ephemeral: false) + Task { await self.send_raw(req.req, to: [relay_id], skip_ephemeral: false) } } } @@ -432,7 +476,7 @@ class RelayPool { } } - func resubscribeAll(relayId: RelayURL) { + func resubscribeAll(relayId: RelayURL) async { for handler in self.handlers { guard let filters = handler.filters else { continue } // When the caller specifies no relays, it is implied that the user wants to use the ones in the user relay list. Skip ephemeral relays in that case. @@ -446,11 +490,11 @@ class RelayPool { } Log.debug("%s: Sending resubscribe request to %s", for: .networking, handler.sub_id, relayId.absoluteString) - send(.subscribe(.init(filters: filters, sub_id: handler.sub_id)), to: [relayId], skip_ephemeral: shouldSkipEphemeralRelays) + await send(.subscribe(.init(filters: filters, sub_id: handler.sub_id)), to: [relayId], skip_ephemeral: shouldSkipEphemeralRelays) } } - func handle_event(relay_id: RelayURL, event: NostrConnectionEvent) { + func handle_event(relay_id: RelayURL, event: NostrConnectionEvent) async { record_seen(relay_id: relay_id, event: event) // When we reconnect, do two things @@ -459,20 +503,20 @@ class RelayPool { if case .ws_connection_event(let ws) = event { if case .connected = ws { run_queue(relay_id) - self.resubscribeAll(relayId: relay_id) + await self.resubscribeAll(relayId: relay_id) } } // Handle auth if case let .nostr_event(nostrResponse) = event, case let .auth(challenge_string) = nostrResponse { - if let relay = get_relay(relay_id) { + if let relay = await get_relay(relay_id) { print("received auth request from \(relay.descriptor.url.id)") relay.authentication_state = .pending if let keypair { if let fullKeypair = keypair.to_full() { if let authRequest = make_auth_request(keypair: fullKeypair, challenge_string: challenge_string, relay: relay) { - send(.auth(authRequest), to: [relay_id], skip_ephemeral: false) + await send(.auth(authRequest), to: [relay_id], skip_ephemeral: false) relay.authentication_state = .verified } else { print("failed to make auth request") @@ -491,13 +535,13 @@ class RelayPool { } for handler in handlers { - handler.callback(relay_id, event) + handler.handler.yield((relay_id, event)) } } } -func add_rw_relay(_ pool: RelayPool, _ url: RelayURL) { - try? pool.add_relay(RelayPool.RelayDescriptor(url: url, info: .readWrite)) +func add_rw_relay(_ pool: RelayPool, _ url: RelayURL) async { + try? await pool.add_relay(RelayPool.RelayDescriptor(url: url, info: .readWrite)) } diff --git a/damus/Features/Actions/ActionBar/Models/ActionBarModel.swift b/damus/Features/Actions/ActionBar/Models/ActionBarModel.swift index 7c9b06d9..a6c4cfc7 100644 --- a/damus/Features/Actions/ActionBar/Models/ActionBarModel.swift +++ b/damus/Features/Actions/ActionBar/Models/ActionBarModel.swift @@ -46,7 +46,8 @@ class ActionBarModel: ObservableObject { self.relays = relays } - func update(damus: DamusState, evid: NoteId) { + @MainActor + func update(damus: DamusState, evid: NoteId) async { self.likes = damus.likes.counts[evid] ?? 0 self.boosts = damus.boosts.counts[evid] ?? 0 self.zaps = damus.zaps.event_counts[evid] ?? 0 @@ -58,7 +59,7 @@ class ActionBarModel: ObservableObject { self.our_zap = damus.zaps.our_zaps[evid]?.first self.our_reply = damus.replies.our_reply(evid) self.our_quote_repost = damus.quote_reposts.our_events[evid] - self.relays = (damus.nostrNetwork.relayURLsThatSawNote(id: evid) ?? []).count + self.relays = (await damus.nostrNetwork.relayURLsThatSawNote(id: evid) ?? []).count self.objectWillChange.send() } diff --git a/damus/Features/Actions/ActionBar/Views/EventActionBar.swift b/damus/Features/Actions/ActionBar/Views/EventActionBar.swift index 7973b67d..7bbe2a6a 100644 --- a/damus/Features/Actions/ActionBar/Views/EventActionBar.swift +++ b/damus/Features/Actions/ActionBar/Views/EventActionBar.swift @@ -89,8 +89,10 @@ struct EventActionBar: View { var like_swipe_button: some View { SwipeAction(image: "shaka", backgroundColor: DamusColors.adaptableGrey) { - send_like(emoji: damus_state.settings.default_emoji_reaction) - self.swipe_context?.state.wrappedValue = .closed + Task { + await send_like(emoji: damus_state.settings.default_emoji_reaction) + self.swipe_context?.state.wrappedValue = .closed + } } .swipeButtonStyle() .accessibilityLabel(NSLocalizedString("React with default reaction emoji", comment: "Accessibility label for react button")) @@ -138,7 +140,7 @@ struct EventActionBar: View { if bar.liked { //notify(.delete, bar.our_like) } else { - send_like(emoji: emoji) + Task { await send_like(emoji: emoji) } } } @@ -225,8 +227,15 @@ struct EventActionBar: View { } } - var event_relay_url_strings: [RelayURL] { - let relays = damus_state.nostrNetwork.relaysForEvent(event: event) + @State var event_relay_url_strings: [RelayURL] = [] + + func updateEventRelayURLStrings() async { + let newValue = await fetchEventRelayURLStrings() + self.event_relay_url_strings = newValue + } + + func fetchEventRelayURLStrings() async -> [RelayURL] { + let relays = await damus_state.nostrNetwork.relaysForEvent(event: event) if !relays.isEmpty { return relays.prefix(Constants.MAX_SHARE_RELAYS).map { $0 } } @@ -237,9 +246,10 @@ struct EventActionBar: View { var body: some View { self.content .onAppear { - self.bar.update(damus: damus_state, evid: self.event.id) Task.detached(priority: .background, operation: { + await self.bar.update(damus: damus_state, evid: self.event.id) self.fetchLNURL() + await self.updateEventRelayURLStrings() }) } .sheet(isPresented: $show_share_action, onDismiss: { self.show_share_action = false }) { @@ -268,7 +278,10 @@ struct EventActionBar: View { } .onReceive(handle_notify(.update_stats)) { target in guard target == self.event.id else { return } - self.bar.update(damus: self.damus_state, evid: target) + Task { + await self.bar.update(damus: self.damus_state, evid: target) + await self.updateEventRelayURLStrings() + } } .onReceive(handle_notify(.liked)) { liked in if liked.id != event.id { @@ -281,9 +294,9 @@ struct EventActionBar: View { } } - func send_like(emoji: String) { + func send_like(emoji: String) async { guard let keypair = damus_state.keypair.to_full(), - let like_ev = make_like_event(keypair: keypair, liked: event, content: emoji, relayURL: damus_state.nostrNetwork.relaysForEvent(event: event).first) else { + let like_ev = await make_like_event(keypair: keypair, liked: event, content: emoji, relayURL: damus_state.nostrNetwork.relaysForEvent(event: event).first) else { return } @@ -291,7 +304,7 @@ struct EventActionBar: View { generator.impactOccurred() - damus_state.nostrNetwork.postbox.send(like_ev) + await damus_state.nostrNetwork.postbox.send(like_ev) } // MARK: Helper structures diff --git a/damus/Features/Actions/ActionBar/Views/EventDetailBar.swift b/damus/Features/Actions/ActionBar/Views/EventDetailBar.swift index 604c4e30..f3f5a3af 100644 --- a/damus/Features/Actions/ActionBar/Views/EventDetailBar.swift +++ b/damus/Features/Actions/ActionBar/Views/EventDetailBar.swift @@ -13,6 +13,7 @@ struct EventDetailBar: View { let target_pk: Pubkey @ObservedObject var bar: ActionBarModel + @State var relays: [RelayURL] = [] init(state: DamusState, target: NoteId, target_pk: Pubkey) { self.state = state @@ -61,7 +62,6 @@ struct EventDetailBar: View { } if bar.relays > 0 { - let relays = Array(state.nostrNetwork.relayURLsThatSawNote(id: target) ?? []) NavigationLink(value: Route.UserRelays(relays: relays)) { let nounString = pluralizedString(key: "relays_count", count: bar.relays) let noun = Text(nounString).foregroundColor(.gray) @@ -70,6 +70,18 @@ struct EventDetailBar: View { .buttonStyle(PlainButtonStyle()) } } + .onAppear { + Task { await self.updateSeenRelays() } + } + .onReceive(handle_notify(.update_stats)) { noteId in + guard noteId == target else { return } + Task { await self.updateSeenRelays() } + } + } + + func updateSeenRelays() async { + let relays = await Array(state.nostrNetwork.relayURLsThatSawNote(id: target) ?? []) + self.relays = relays } } diff --git a/damus/Features/Actions/ActionBar/Views/ShareAction.swift b/damus/Features/Actions/ActionBar/Views/ShareAction.swift index c3a1309a..2e040469 100644 --- a/damus/Features/Actions/ActionBar/Views/ShareAction.swift +++ b/damus/Features/Actions/ActionBar/Views/ShareAction.swift @@ -27,8 +27,15 @@ struct ShareAction: View { self._show_share = show_share } - var event_relay_url_strings: [RelayURL] { - let relays = userProfile.damus.nostrNetwork.relaysForEvent(event: event) + @State var event_relay_url_strings: [RelayURL] = [] + + func updateEventRelayURLStrings() async { + let newValue = await fetchEventRelayURLStrings() + self.event_relay_url_strings = newValue + } + + func fetchEventRelayURLStrings() async -> [RelayURL] { + let relays = await userProfile.damus.nostrNetwork.relaysForEvent(event: event) if !relays.isEmpty { return relays.prefix(Constants.MAX_SHARE_RELAYS).map { $0 } } @@ -80,8 +87,13 @@ struct ShareAction: View { } } } + .onReceive(handle_notify(.update_stats), perform: { noteId in + guard noteId == event.id else { return } + Task { await self.updateEventRelayURLStrings() } + }) .onAppear() { userProfile.subscribeToFindRelays() + Task { await self.updateEventRelayURLStrings() } } .onDisappear() { userProfile.unsubscribeFindRelays() diff --git a/damus/Features/Actions/Reports/Views/ReportView.swift b/damus/Features/Actions/Reports/Views/ReportView.swift index 6d5fcc78..33c622ec 100644 --- a/damus/Features/Actions/Reports/Views/ReportView.swift +++ b/damus/Features/Actions/Reports/Views/ReportView.swift @@ -57,13 +57,13 @@ struct ReportView: View { .padding() } - func do_send_report() { + func do_send_report() async { guard let selected_report_type, let ev = NostrEvent(content: report_message, keypair: keypair.to_keypair(), kind: 1984, tags: target.reportTags(type: selected_report_type)) else { return } - postbox.send(ev) + await postbox.send(ev) report_sent = true report_id = bech32_note_id(ev.id) @@ -116,7 +116,7 @@ struct ReportView: View { Section(content: { Button(send_report_button_text) { - do_send_report() + Task { await do_send_report() } } .disabled(selected_report_type == nil) }, footer: { diff --git a/damus/Features/Actions/Reposts/Views/RepostAction.swift b/damus/Features/Actions/Reposts/Views/RepostAction.swift index 4f7ac4dc..8669ce2e 100644 --- a/damus/Features/Actions/Reposts/Views/RepostAction.swift +++ b/damus/Features/Actions/Reposts/Views/RepostAction.swift @@ -19,13 +19,15 @@ struct RepostAction: View { Button { dismiss() - - guard let keypair = self.damus_state.keypair.to_full(), - let boost = make_boost_event(keypair: keypair, boosted: self.event, relayURL: damus_state.nostrNetwork.relaysForEvent(event: self.event).first) else { - return + + Task { + guard let keypair = self.damus_state.keypair.to_full(), + let boost = await make_boost_event(keypair: keypair, boosted: self.event, relayURL: damus_state.nostrNetwork.relaysForEvent(event: self.event).first) else { + return + } + + await damus_state.nostrNetwork.postbox.send(boost) } - - damus_state.nostrNetwork.postbox.send(boost) } label: { Label(NSLocalizedString("Repost", comment: "Button to repost a note"), image: "repost") .frame(maxWidth: .infinity, minHeight: 50, maxHeight: 50, alignment: .leading) diff --git a/damus/Features/Chat/ChatEventView.swift b/damus/Features/Chat/ChatEventView.swift index 530e1b6c..c7df2fbf 100644 --- a/damus/Features/Chat/ChatEventView.swift +++ b/damus/Features/Chat/ChatEventView.swift @@ -197,8 +197,10 @@ struct ChatEventView: View { } .onChange(of: selected_emoji) { newSelectedEmoji in if let newSelectedEmoji { - send_like(emoji: newSelectedEmoji.value) - popover_state = .closed + Task { + await send_like(emoji: newSelectedEmoji.value) + popover_state = .closed + } } } } @@ -233,9 +235,9 @@ struct ChatEventView: View { ) } - func send_like(emoji: String) { + func send_like(emoji: String) async { guard let keypair = damus_state.keypair.to_full(), - let like_ev = make_like_event(keypair: keypair, liked: event, content: emoji, relayURL: damus_state.nostrNetwork.relaysForEvent(event: event).first) else { + let like_ev = make_like_event(keypair: keypair, liked: event, content: emoji, relayURL: await damus_state.nostrNetwork.relaysForEvent(event: event).first) else { return } @@ -244,7 +246,7 @@ struct ChatEventView: View { let generator = UIImpactFeedbackGenerator(style: .medium) generator.impactOccurred() - damus_state.nostrNetwork.postbox.send(like_ev) + await damus_state.nostrNetwork.postbox.send(like_ev) } var action_bar: some View { diff --git a/damus/Features/DMs/Views/DMChatView.swift b/damus/Features/DMs/Views/DMChatView.swift index 39a74936..50d13ccc 100644 --- a/damus/Features/DMs/Views/DMChatView.swift +++ b/damus/Features/DMs/Views/DMChatView.swift @@ -108,7 +108,7 @@ struct DMChatView: View, KeyboardReadable { Button( role: .none, action: { - send_message() + Task { await send_message() } } ) { Label("", image: "send") @@ -124,7 +124,7 @@ struct DMChatView: View, KeyboardReadable { */ } - func send_message() { + func send_message() async { let tags = [["p", pubkey.hex()]] guard let post_blocks = parse_post_blocks(content: dms.draft)?.blocks else { return @@ -138,7 +138,7 @@ struct DMChatView: View, KeyboardReadable { dms.draft = "" - damus_state.nostrNetwork.postbox.send(dm) + await damus_state.nostrNetwork.postbox.send(dm) handle_incoming_dm(ev: dm, our_pubkey: damus_state.pubkey, dms: damus_state.dms, prev_events: NewEventsBits()) diff --git a/damus/Features/Events/EventMenu.swift b/damus/Features/Events/EventMenu.swift index 28691fdd..2e2bd6b8 100644 --- a/damus/Features/Events/EventMenu.swift +++ b/damus/Features/Events/EventMenu.swift @@ -64,8 +64,8 @@ struct MenuItems: View { self.profileModel = profileModel } - var event_relay_url_strings: [RelayURL] { - let relays = damus_state.nostrNetwork.relaysForEvent(event: event) + func event_relay_url_strings() async -> [RelayURL] { + let relays = await damus_state.nostrNetwork.relaysForEvent(event: event) if !relays.isEmpty { return relays.prefix(Constants.MAX_SHARE_RELAYS).map { $0 } } @@ -88,7 +88,7 @@ struct MenuItems: View { } Button { - UIPasteboard.general.string = Bech32Object.encode(.nevent(NEvent(event: event, relays: event_relay_url_strings))) + Task { UIPasteboard.general.string = Bech32Object.encode(.nevent(NEvent(event: event, relays: await event_relay_url_strings()))) } } label: { Label(NSLocalizedString("Copy note ID", comment: "Context menu option for copying the ID of the note."), image: "note-book") } @@ -122,7 +122,7 @@ struct MenuItems: View { if let full_keypair = self.damus_state.keypair.to_full(), let new_mutelist_ev = toggle_from_mutelist(keypair: full_keypair, prev: damus_state.mutelist_manager.event, to_toggle: .thread(event.thread_id(), duration?.date_from_now)) { damus_state.mutelist_manager.set_mutelist(new_mutelist_ev) - damus_state.nostrNetwork.postbox.send(new_mutelist_ev) + Task { await damus_state.nostrNetwork.postbox.send(new_mutelist_ev) } } let muted = damus_state.mutelist_manager.is_event_muted(event) isMutedThread = muted diff --git a/damus/Features/Events/EventView.swift b/damus/Features/Events/EventView.swift index cb4d8727..9885fe60 100644 --- a/damus/Features/Events/EventView.swift +++ b/damus/Features/Events/EventView.swift @@ -106,7 +106,7 @@ func format_date(date: Date, time_style: DateFormatter.Style = .short) -> String func make_actionbar_model(ev: NoteId, damus: DamusState) -> ActionBarModel { let model = ActionBarModel.empty() - model.update(damus: damus, evid: ev) + Task { await model.update(damus: damus, evid: ev) } return model } diff --git a/damus/Features/Events/SelectedEventView.swift b/damus/Features/Events/SelectedEventView.swift index b107f103..5cff475d 100644 --- a/damus/Features/Events/SelectedEventView.swift +++ b/damus/Features/Events/SelectedEventView.swift @@ -74,7 +74,7 @@ struct SelectedEventView: View { } .onReceive(handle_notify(.update_stats)) { target in guard target == self.event.id else { return } - self.bar.update(damus: self.damus, evid: target) + Task { await self.bar.update(damus: self.damus, evid: target) } } .compositingGroup() } diff --git a/damus/Features/FollowPack/Models/FollowPackModel.swift b/damus/Features/FollowPack/Models/FollowPackModel.swift index a66e669b..87c6bf90 100644 --- a/damus/Features/FollowPack/Models/FollowPackModel.swift +++ b/damus/Features/FollowPack/Models/FollowPackModel.swift @@ -37,7 +37,7 @@ class FollowPackModel: ObservableObject { } func listenForUpdates(follow_pack_users: [Pubkey]) async { - let to_relays = damus_state.nostrNetwork.determineToRelays(filters: damus_state.relay_filters) + let to_relays = await damus_state.nostrNetwork.determineToRelays(filters: damus_state.relay_filters) var filter = NostrFilter(kinds: [.text, .chat]) filter.until = UInt32(Date.now.timeIntervalSince1970) filter.authors = follow_pack_users diff --git a/damus/Features/Follows/Models/Contacts+.swift b/damus/Features/Follows/Models/Contacts+.swift index 81dd9803..e52e24c7 100644 --- a/damus/Features/Follows/Models/Contacts+.swift +++ b/damus/Features/Follows/Models/Contacts+.swift @@ -9,17 +9,17 @@ import Foundation -func follow_reference(box: PostBox, our_contacts: NostrEvent?, keypair: FullKeypair, follow: FollowRef) -> NostrEvent? { +func follow_reference(box: PostBox, our_contacts: NostrEvent?, keypair: FullKeypair, follow: FollowRef) async -> NostrEvent? { guard let ev = follow_user_event(our_contacts: our_contacts, keypair: keypair, follow: follow) else { return nil } - box.send(ev) + await box.send(ev) return ev } -func unfollow_reference(postbox: PostBox, our_contacts: NostrEvent?, keypair: FullKeypair, unfollow: FollowRef) -> NostrEvent? { +func unfollow_reference(postbox: PostBox, our_contacts: NostrEvent?, keypair: FullKeypair, unfollow: FollowRef) async -> NostrEvent? { guard let cs = our_contacts else { return nil } @@ -28,7 +28,7 @@ func unfollow_reference(postbox: PostBox, our_contacts: NostrEvent?, keypair: Fu return nil } - postbox.send(ev) + await postbox.send(ev) return ev } diff --git a/damus/Features/Muting/Models/MutedThreadsManager.swift b/damus/Features/Muting/Models/MutedThreadsManager.swift index 7b463971..7f766a22 100644 --- a/damus/Features/Muting/Models/MutedThreadsManager.swift +++ b/damus/Features/Muting/Models/MutedThreadsManager.swift @@ -34,7 +34,7 @@ func migrate_old_muted_threads_to_new_mutelist(keypair: Keypair, damus_state: Da let previous_mute_list_event = damus_state.mutelist_manager.event guard let new_mutelist_event = create_or_update_mutelist(keypair: fullKeypair, mprev: previous_mute_list_event, to_add: Set(mutedThreads.map { MuteItem.thread($0, nil) })) else { return } damus_state.mutelist_manager.set_mutelist(new_mutelist_event) - damus_state.nostrNetwork.postbox.send(new_mutelist_event) + Task { await damus_state.nostrNetwork.postbox.send(new_mutelist_event) } // Set existing muted threads to an empty array UserDefaults.standard.set([], forKey: getMutedThreadsKey(pubkey: keypair.pubkey)) } diff --git a/damus/Features/Muting/Views/AddMuteItemView.swift b/damus/Features/Muting/Views/AddMuteItemView.swift index 4a464395..bc5fb150 100644 --- a/damus/Features/Muting/Views/AddMuteItemView.swift +++ b/damus/Features/Muting/Views/AddMuteItemView.swift @@ -87,7 +87,7 @@ struct AddMuteItemView: View { } state.mutelist_manager.set_mutelist(mutelist) - state.nostrNetwork.postbox.send(mutelist) + Task { await state.nostrNetwork.postbox.send(mutelist) } } new_text = "" diff --git a/damus/Features/Muting/Views/MutelistView.swift b/damus/Features/Muting/Views/MutelistView.swift index dcc25500..d152dd0c 100644 --- a/damus/Features/Muting/Views/MutelistView.swift +++ b/damus/Features/Muting/Views/MutelistView.swift @@ -30,8 +30,10 @@ struct MutelistView: View { } damus_state.mutelist_manager.set_mutelist(new_ev) - damus_state.nostrNetwork.postbox.send(new_ev) - updateMuteItems() + Task { + await damus_state.nostrNetwork.postbox.send(new_ev) + updateMuteItems() + } } label: { Label(NSLocalizedString("Delete", comment: "Button to remove a user from their mutelist."), image: "delete") } diff --git a/damus/Features/Onboarding/Views/OnboardingSuggestionsView.swift b/damus/Features/Onboarding/Views/OnboardingSuggestionsView.swift index 264ecff4..dbfdcbba 100644 --- a/damus/Features/Onboarding/Views/OnboardingSuggestionsView.swift +++ b/damus/Features/Onboarding/Views/OnboardingSuggestionsView.swift @@ -56,7 +56,7 @@ struct OnboardingSuggestionsView: View { // - We don't have other mechanisms to allow the user to edit this yet // // Therefore, it is better to just save it locally, and retrieve this once we build out https://github.com/damus-io/damus/issues/3042 - model.damus_state.nostrNetwork.sendToNostrDB(event: event) + Task { await model.damus_state.nostrNetwork.sendToNostrDB(event: event) } } var body: some View { diff --git a/damus/Features/Onboarding/Views/SaveKeysView.swift b/damus/Features/Onboarding/Views/SaveKeysView.swift index 9939d778..6ec5dc3d 100644 --- a/damus/Features/Onboarding/Views/SaveKeysView.swift +++ b/damus/Features/Onboarding/Views/SaveKeysView.swift @@ -75,7 +75,7 @@ struct SaveKeysView: View { .foregroundColor(.red) Button(action: { - complete_account_creation(account) + Task { await complete_account_creation(account) } }) { HStack { Text("Retry", comment: "Button to retry completing account creation after an error occurred.") @@ -89,7 +89,7 @@ struct SaveKeysView: View { Button(action: { save_key(account) - complete_account_creation(account) + Task { await complete_account_creation(account) } }) { HStack { Text("Save", comment: "Button to save key, complete account creation, and start using the app.") @@ -101,7 +101,7 @@ struct SaveKeysView: View { .padding(.top, 20) Button(action: { - complete_account_creation(account) + Task { await complete_account_creation(account) } }) { HStack { Text("Not now", comment: "Button to not save key, complete account creation, and start using the app.") @@ -125,7 +125,7 @@ struct SaveKeysView: View { credential_handler.save_credential(pubkey: account.pubkey, privkey: account.privkey) } - func complete_account_creation(_ account: CreateAccountModel) { + func complete_account_creation(_ account: CreateAccountModel) async { guard let first_contact_event else { error = NSLocalizedString("Could not create your initial contact list event. This is a software bug, please contact Damus support via support@damus.io or through our Nostr account for help.", comment: "Error message to the user indicating that the initial contact list failed to be created.") return @@ -139,14 +139,21 @@ struct SaveKeysView: View { let bootstrap_relays = load_bootstrap_relays(pubkey: account.pubkey) for relay in bootstrap_relays { - add_rw_relay(self.pool, relay) + await add_rw_relay(self.pool, relay) + } + + Task { + let stream = AsyncStream<(RelayURL, NostrConnectionEvent)> { streamContinuation in + Task { await self.pool.register_handler(sub_id: "signup", filters: nil, handler: streamContinuation) } + } + for await (relayUrl, connectionEvent) in stream { + await handle_event(relay: relayUrl, ev: connectionEvent) + } } - - Task { await self.pool.register_handler(sub_id: "signup", filters: nil, handler: handle_event) } self.loading = true - self.pool.connect() + await self.pool.connect() } func save_to_storage(first_contact_event: NdbNote, first_relay_list_event: NdbNote, for account: CreateAccountModel) { @@ -160,7 +167,7 @@ struct SaveKeysView: View { settings.latestRelayListEventIdHex = first_relay_list_event.id.hex() } - func handle_event(relay: RelayURL, ev: NostrConnectionEvent) { + func handle_event(relay: RelayURL, ev: NostrConnectionEvent) async { switch ev { case .ws_connection_event(let wsev): switch wsev { @@ -169,15 +176,15 @@ struct SaveKeysView: View { if let keypair = account.keypair.to_full(), let metadata_ev = make_metadata_event(keypair: keypair, metadata: metadata) { - self.pool.send(.event(metadata_ev)) + await self.pool.send(.event(metadata_ev)) } if let first_contact_event { - self.pool.send(.event(first_contact_event)) + await self.pool.send(.event(first_contact_event)) } if let first_relay_list_event { - self.pool.send(.event(first_relay_list_event)) + await self.pool.send(.event(first_relay_list_event)) } do { diff --git a/damus/Features/Posting/Models/DraftsModel.swift b/damus/Features/Posting/Models/DraftsModel.swift index 882e7b5e..de00ac1b 100644 --- a/damus/Features/Posting/Models/DraftsModel.swift +++ b/damus/Features/Posting/Models/DraftsModel.swift @@ -64,9 +64,9 @@ class DraftArtifacts: Equatable { /// - damus_state: The damus state, needed for encrypting, fetching Nostr data depedencies, and forming the NIP-37 draft /// - references: references in the post? /// - Returns: The NIP-37 draft packaged in a way that can be easily wrapped/unwrapped. - func to_nip37_draft(action: PostAction, damus_state: DamusState) throws -> NIP37Draft? { + func to_nip37_draft(action: PostAction, damus_state: DamusState) async throws -> NIP37Draft? { guard let keypair = damus_state.keypair.to_full() else { return nil } - let post = build_post(state: damus_state, action: action, draft: self) + let post = await build_post(state: damus_state, action: action, draft: self) guard let note = post.to_event(keypair: keypair) else { return nil } return try NIP37Draft(unwrapped_note: note, draft_id: self.id, keypair: keypair) } @@ -227,24 +227,24 @@ class Drafts: ObservableObject { func save(damus_state: DamusState) async { var draft_events: [NdbNote] = [] post_artifact_block: if let post_artifacts = self.post { - let nip37_draft = try? post_artifacts.to_nip37_draft(action: .posting(.user(damus_state.pubkey)), damus_state: damus_state) + let nip37_draft = try? await post_artifacts.to_nip37_draft(action: .posting(.user(damus_state.pubkey)), damus_state: damus_state) guard let wrapped_note = nip37_draft?.wrapped_note else { break post_artifact_block } draft_events.append(wrapped_note) } for (replied_to_note_id, reply_artifacts) in self.replies { guard let replied_to_note = damus_state.ndb.lookup_note(replied_to_note_id)?.unsafeUnownedValue?.to_owned() else { continue } - let nip37_draft = try? reply_artifacts.to_nip37_draft(action: .replying_to(replied_to_note), damus_state: damus_state) + let nip37_draft = try? await reply_artifacts.to_nip37_draft(action: .replying_to(replied_to_note), damus_state: damus_state) guard let wrapped_note = nip37_draft?.wrapped_note else { continue } draft_events.append(wrapped_note) } for (quoted_note_id, quote_note_artifacts) in self.quotes { guard let quoted_note = damus_state.ndb.lookup_note(quoted_note_id)?.unsafeUnownedValue?.to_owned() else { continue } - let nip37_draft = try? quote_note_artifacts.to_nip37_draft(action: .quoting(quoted_note), damus_state: damus_state) + let nip37_draft = try? await quote_note_artifacts.to_nip37_draft(action: .quoting(quoted_note), damus_state: damus_state) guard let wrapped_note = nip37_draft?.wrapped_note else { continue } draft_events.append(wrapped_note) } for (highlight, highlight_note_artifacts) in self.highlights { - let nip37_draft = try? highlight_note_artifacts.to_nip37_draft(action: .highlighting(highlight), damus_state: damus_state) + let nip37_draft = try? await highlight_note_artifacts.to_nip37_draft(action: .highlighting(highlight), damus_state: damus_state) guard let wrapped_note = nip37_draft?.wrapped_note else { continue } draft_events.append(wrapped_note) } @@ -254,7 +254,7 @@ class Drafts: ObservableObject { // TODO: Once it is time to implement draft syncing with relays, please consider the following: // - Privacy: Sending drafts to the network leaks metadata about app activity, and may break user expectations // - Down-sync conflict resolution: Consider how to solve conflicts for different draft versions holding the same ID (e.g. edited in Damus, then another client, then Damus again) - damus_state.nostrNetwork.sendToNostrDB(event: draft_event) + await damus_state.nostrNetwork.sendToNostrDB(event: draft_event) } DispatchQueue.main.async { diff --git a/damus/Features/Posting/Models/PostBox.swift b/damus/Features/Posting/Models/PostBox.swift index db5bb3b8..b34e7b60 100644 --- a/damus/Features/Posting/Models/PostBox.swift +++ b/damus/Features/Posting/Models/PostBox.swift @@ -60,7 +60,14 @@ class PostBox { init(pool: RelayPool) { self.pool = pool self.events = [:] - Task { await pool.register_handler(sub_id: "postbox", filters: nil, to: nil, handler: handle_event) } + Task { + let stream = AsyncStream<(RelayURL, NostrConnectionEvent)> { streamContinuation in + Task { await self.pool.register_handler(sub_id: "postbox", filters: nil, to: nil, handler: streamContinuation) } + } + for await (relayUrl, connectionEvent) in stream { + handle_event(relay_id: relayUrl, connectionEvent) + } + } } // only works reliably on delay-sent events @@ -81,7 +88,7 @@ class PostBox { return nil } - func try_flushing_events() { + func try_flushing_events() async { let now = Int64(Date().timeIntervalSince1970) for kv in events { let event = kv.value @@ -95,7 +102,7 @@ class PostBox { if relayer.last_attempt == nil || (now >= (relayer.last_attempt! + Int64(relayer.retry_after))) { print("attempt #\(relayer.attempts) to flush event '\(event.event.content)' to \(relayer.relay) after \(relayer.retry_after) seconds") - flush_event(event, to_relay: relayer) + await flush_event(event, to_relay: relayer) } } } @@ -140,7 +147,7 @@ class PostBox { return prev_count != after_count } - private func flush_event(_ event: PostedEvent, to_relay: Relayer? = nil) { + private func flush_event(_ event: PostedEvent, to_relay: Relayer? = nil) async { var relayers = event.remaining if let to_relay { relayers = [to_relay] @@ -150,29 +157,35 @@ class PostBox { relayer.attempts += 1 relayer.last_attempt = Int64(Date().timeIntervalSince1970) relayer.retry_after *= 1.5 - if pool.get_relay(relayer.relay) != nil { + if await pool.get_relay(relayer.relay) != nil { print("flushing event \(event.event.id) to \(relayer.relay)") } else { print("could not find relay when flushing: \(relayer.relay)") } - pool.send(.event(event.event), to: [relayer.relay], skip_ephemeral: event.skip_ephemeral) + await pool.send(.event(event.event), to: [relayer.relay], skip_ephemeral: event.skip_ephemeral) } } - func send(_ event: NostrEvent, to: [RelayURL]? = nil, skip_ephemeral: Bool = true, delay: TimeInterval? = nil, on_flush: OnFlush? = nil) { + func send(_ event: NostrEvent, to: [RelayURL]? = nil, skip_ephemeral: Bool = true, delay: TimeInterval? = nil, on_flush: OnFlush? = nil) async { // Don't add event if we already have it if events[event.id] != nil { return } - let remaining = to ?? pool.our_descriptors.map { $0.url } + let remaining: [RelayURL] + if let to { + remaining = to + } + else { + remaining = await pool.our_descriptors.map { $0.url } + } let after = delay.map { d in Date.now.addingTimeInterval(d) } let posted_ev = PostedEvent(event: event, remaining: remaining, skip_ephemeral: skip_ephemeral, flush_after: after, on_flush: on_flush) events[event.id] = posted_ev if after == nil { - flush_event(posted_ev) + await flush_event(posted_ev) } } } diff --git a/damus/Features/Posting/Views/PostView.swift b/damus/Features/Posting/Views/PostView.swift index 1f9672a4..c9c501bc 100644 --- a/damus/Features/Posting/Views/PostView.swift +++ b/damus/Features/Posting/Views/PostView.swift @@ -121,8 +121,8 @@ struct PostView: View { uploadTasks.removeAll() } - func send_post() { - let new_post = build_post(state: self.damus_state, post: self.post, action: action, uploadedMedias: uploadedMedias, references: self.references, filtered_pubkeys: filtered_pubkeys) + func send_post() async { + let new_post = await build_post(state: self.damus_state, post: self.post, action: action, uploadedMedias: uploadedMedias, references: self.references, filtered_pubkeys: filtered_pubkeys) notify(.post(.post(new_post))) @@ -190,7 +190,7 @@ struct PostView: View { var PostButton: some View { Button(NSLocalizedString("Post", comment: "Button to post a note.")) { - self.send_post() + Task { await self.send_post() } } .disabled(posting_disabled) .opacity(posting_disabled ? 0.5 : 1.0) @@ -829,8 +829,8 @@ func nip10_reply_tags(replying_to: NostrEvent, keypair: Keypair, relayURL: Relay return tags } -func build_post(state: DamusState, action: PostAction, draft: DraftArtifacts) -> NostrPost { - return build_post( +func build_post(state: DamusState, action: PostAction, draft: DraftArtifacts) async -> NostrPost { + return await build_post( state: state, post: draft.content, action: action, @@ -840,7 +840,7 @@ func build_post(state: DamusState, action: PostAction, draft: DraftArtifacts) -> ) } -func build_post(state: DamusState, post: NSAttributedString, action: PostAction, uploadedMedias: [UploadedMedia], references: [RefId], filtered_pubkeys: Set) -> NostrPost { +func build_post(state: DamusState, post: NSAttributedString, action: PostAction, uploadedMedias: [UploadedMedia], references: [RefId], filtered_pubkeys: Set) async -> NostrPost { // don't add duplicate pubkeys but retain order var pkset = Set() @@ -858,7 +858,7 @@ func build_post(state: DamusState, post: NSAttributedString, action: PostAction, acc.append(pk) } - return build_post(state: state, post: post, action: action, uploadedMedias: uploadedMedias, pubkeys: pks) + return await build_post(state: state, post: post, action: action, uploadedMedias: uploadedMedias, pubkeys: pks) } /// This builds a Nostr post from draft data from `PostView` or other draft-related classes @@ -874,7 +874,7 @@ func build_post(state: DamusState, post: NSAttributedString, action: PostAction, /// - uploadedMedias: The medias attached to this post /// - pubkeys: The referenced pubkeys /// - Returns: A NostrPost, which can then be signed into an event. -func build_post(state: DamusState, post: NSAttributedString, action: PostAction, uploadedMedias: [UploadedMedia], pubkeys: [Pubkey]) -> NostrPost { +func build_post(state: DamusState, post: NSAttributedString, action: PostAction, uploadedMedias: [UploadedMedia], pubkeys: [Pubkey]) async -> NostrPost { let post = NSMutableAttributedString(attributedString: post) post.enumerateAttributes(in: NSRange(location: 0, length: post.length), options: []) { attributes, range, stop in let linkValue = attributes[.link] @@ -916,10 +916,10 @@ func build_post(state: DamusState, post: NSAttributedString, action: PostAction, switch action { case .replying_to(let replying_to): // start off with the reply tags - tags = nip10_reply_tags(replying_to: replying_to, keypair: state.keypair, relayURL: state.nostrNetwork.relaysForEvent(event: replying_to).first) + tags = nip10_reply_tags(replying_to: replying_to, keypair: state.keypair, relayURL: await state.nostrNetwork.relaysForEvent(event: replying_to).first) case .quoting(let ev): - let relay_urls = state.nostrNetwork.relaysForEvent(event: ev) + let relay_urls = await state.nostrNetwork.relaysForEvent(event: ev) let nevent = Bech32Object.encode(.nevent(NEvent(event: ev, relays: relay_urls.prefix(4).map { $0 }))) content.append("\n\nnostr:\(nevent)") diff --git a/damus/Features/Profile/Views/EditMetadataView.swift b/damus/Features/Profile/Views/EditMetadataView.swift index 99c4214d..6dc65b5b 100644 --- a/damus/Features/Profile/Views/EditMetadataView.swift +++ b/damus/Features/Profile/Views/EditMetadataView.swift @@ -58,7 +58,7 @@ struct EditMetadataView: View { return profile } - func save() { + func save() async { let profile = to_profile() guard let keypair = damus_state.keypair.to_full(), let metadata_ev = make_metadata_event(keypair: keypair, metadata: profile) @@ -66,7 +66,7 @@ struct EditMetadataView: View { return } - damus_state.nostrNetwork.postbox.send(metadata_ev) + await damus_state.nostrNetwork.postbox.send(metadata_ev) } func is_ln_valid(ln: String) -> Bool { @@ -211,8 +211,10 @@ struct EditMetadataView: View { if !ln.isEmpty && !is_ln_valid(ln: ln) { confirm_ln_address = true } else { - save() - dismiss() + Task { + await save() + dismiss() + } } }, label: { Text(NSLocalizedString("Save", comment: "Button for saving profile.")) diff --git a/damus/Features/Profile/Views/ProfileView.swift b/damus/Features/Profile/Views/ProfileView.swift index 5e1d247b..b9350ab6 100644 --- a/damus/Features/Profile/Views/ProfileView.swift +++ b/damus/Features/Profile/Views/ProfileView.swift @@ -219,7 +219,7 @@ struct ProfileView: View { } damus_state.mutelist_manager.set_mutelist(new_ev) - damus_state.nostrNetwork.postbox.send(new_ev) + Task { await damus_state.nostrNetwork.postbox.send(new_ev) } } } else { Button(NSLocalizedString("Mute", comment: "Button to mute a profile"), role: .destructive) { diff --git a/damus/Features/Relays/Views/AddRelayView.swift b/damus/Features/Relays/Views/AddRelayView.swift index f4e4b7f6..dc11d8d0 100644 --- a/damus/Features/Relays/Views/AddRelayView.swift +++ b/damus/Features/Relays/Views/AddRelayView.swift @@ -80,30 +80,32 @@ struct AddRelayView: View { } Button(action: { - if new_relay.starts(with: "wss://") == false && new_relay.starts(with: "ws://") == false { - new_relay = "wss://" + new_relay + Task { + if new_relay.starts(with: "wss://") == false && new_relay.starts(with: "ws://") == false { + new_relay = "wss://" + new_relay + } + + guard let url = RelayURL(new_relay) else { + relayAddErrorTitle = NSLocalizedString("Invalid relay address", comment: "Heading for an error when adding a relay") + relayAddErrorMessage = NSLocalizedString("Please check the address and try again", comment: "Tip for an error where the relay address being added is invalid") + return + } + + do { + try await state.nostrNetwork.userRelayList.insert(relay: NIP65.RelayList.RelayItem(url: url, rwConfiguration: .readWrite)) + relayAddErrorTitle = nil // Clear error title + relayAddErrorMessage = nil // Clear error message + } + catch { + present_sheet(.error(self.humanReadableError(for: error))) + } + + new_relay = "" + + this_app.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil) + + dismiss() } - - guard let url = RelayURL(new_relay) else { - relayAddErrorTitle = NSLocalizedString("Invalid relay address", comment: "Heading for an error when adding a relay") - relayAddErrorMessage = NSLocalizedString("Please check the address and try again", comment: "Tip for an error where the relay address being added is invalid") - return - } - - do { - try state.nostrNetwork.userRelayList.insert(relay: NIP65.RelayList.RelayItem(url: url, rwConfiguration: .readWrite)) - relayAddErrorTitle = nil // Clear error title - relayAddErrorMessage = nil // Clear error message - } - catch { - present_sheet(.error(self.humanReadableError(for: error))) - } - - new_relay = "" - - this_app.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil) - - dismiss() }) { HStack { Text("Add relay", comment: "Button to add a relay.") diff --git a/damus/Features/Search/Models/SearchHomeModel.swift b/damus/Features/Search/Models/SearchHomeModel.swift index 4c99fbca..4d34eba3 100644 --- a/damus/Features/Search/Models/SearchHomeModel.swift +++ b/damus/Features/Search/Models/SearchHomeModel.swift @@ -55,7 +55,7 @@ class SearchHomeModel: ObservableObject { DispatchQueue.main.async { self.loading = true } - let to_relays = damus_state.nostrNetwork.ourRelayDescriptors + let to_relays = await damus_state.nostrNetwork.ourRelayDescriptors .map { $0.url } .filter { !damus_state.relay_filters.is_filtered(timeline: .search, relay_id: $0) } diff --git a/damus/Features/Search/Views/SearchHeaderView.swift b/damus/Features/Search/Views/SearchHeaderView.swift index 7f9449dc..7b19a62f 100644 --- a/damus/Features/Search/Views/SearchHeaderView.swift +++ b/damus/Features/Search/Views/SearchHeaderView.swift @@ -125,7 +125,7 @@ struct HashtagUnfollowButton: View { func unfollow(_ hashtag: String) { is_following = false - handle_unfollow(state: damus_state, unfollow: FollowRef.hashtag(hashtag)) + Task { await handle_unfollow(state: damus_state, unfollow: FollowRef.hashtag(hashtag)) } } } @@ -144,7 +144,7 @@ struct HashtagFollowButton: View { func follow(_ hashtag: String) { is_following = true - handle_follow(state: damus_state, follow: .hashtag(hashtag)) + Task { await handle_follow(state: damus_state, follow: .hashtag(hashtag)) } } } diff --git a/damus/Features/Search/Views/SearchView.swift b/damus/Features/Search/Views/SearchView.swift index dd0e233a..b43b5149 100644 --- a/damus/Features/Search/Views/SearchView.swift +++ b/damus/Features/Search/Views/SearchView.swift @@ -69,7 +69,7 @@ struct SearchView: View { } appstate.mutelist_manager.set_mutelist(mutelist) - appstate.nostrNetwork.postbox.send(mutelist) + Task { await appstate.nostrNetwork.postbox.send(mutelist) } } label: { Text("Unmute Hashtag", comment: "Label represnting a button that the user can tap to unmute a given hashtag so they start seeing it in their feed again.") } @@ -104,7 +104,7 @@ struct SearchView: View { } appstate.mutelist_manager.set_mutelist(mutelist) - appstate.nostrNetwork.postbox.send(mutelist) + Task { await appstate.nostrNetwork.postbox.send(mutelist) } } var described_search: DescribedSearch { diff --git a/damus/Features/Settings/Views/ConfigView.swift b/damus/Features/Settings/Views/ConfigView.swift index b16ef68d..d3f5b5e8 100644 --- a/damus/Features/Settings/Views/ConfigView.swift +++ b/damus/Features/Settings/Views/ConfigView.swift @@ -182,8 +182,10 @@ struct ConfigView: View { let ev = created_deleted_account_profile(keypair: keypair) else { return } - state.nostrNetwork.postbox.send(ev) - logout(state) + Task { + await state.nostrNetwork.postbox.send(ev) + logout(state) + } } } .alert(NSLocalizedString("Logout", comment: "Alert for logging out the user."), isPresented: $confirm_logout) { diff --git a/damus/Features/Settings/Views/FirstAidSettingsView.swift b/damus/Features/Settings/Views/FirstAidSettingsView.swift index 84fbe48e..354b94e6 100644 --- a/damus/Features/Settings/Views/FirstAidSettingsView.swift +++ b/damus/Features/Settings/Views/FirstAidSettingsView.swift @@ -68,13 +68,13 @@ struct FirstAidSettingsView: View { guard let new_contact_list_event = make_first_contact_event(keypair: damus_state.keypair) else { throw FirstAidError.cannotMakeFirstContactEvent } - damus_state.nostrNetwork.send(event: new_contact_list_event) + await damus_state.nostrNetwork.send(event: new_contact_list_event) damus_state.settings.latest_contact_event_id_hex = new_contact_list_event.id.hex() } func resetRelayList() async throws { let bestEffortRelayList = damus_state.nostrNetwork.userRelayList.getBestEffortRelayList() - try damus_state.nostrNetwork.userRelayList.set(userRelayList: bestEffortRelayList) + try await damus_state.nostrNetwork.userRelayList.set(userRelayList: bestEffortRelayList) } enum FirstAidError: Error { diff --git a/damus/Features/Status/Views/UserStatusSheet.swift b/damus/Features/Status/Views/UserStatusSheet.swift index dbff37bf..0ce18d41 100644 --- a/damus/Features/Status/Views/UserStatusSheet.swift +++ b/damus/Features/Status/Views/UserStatusSheet.swift @@ -109,16 +109,18 @@ struct UserStatusSheet: View { Spacer() Button(action: { - guard let status = self.status.general, - let kp = keypair.to_full(), - let ev = make_user_status_note(status: status, keypair: kp, expiry: duration.expiration) - else { - return + Task { + guard let status = self.status.general, + let kp = keypair.to_full(), + let ev = make_user_status_note(status: status, keypair: kp, expiry: duration.expiration) + else { + return + } + + await postbox.send(ev) + + dismiss() } - - postbox.send(ev) - - dismiss() }, label: { Text("Share", comment: "Save button text for saving profile status settings.") }) diff --git a/damus/Features/Timeline/Models/HomeModel.swift b/damus/Features/Timeline/Models/HomeModel.swift index 913cd8a6..0280ab57 100644 --- a/damus/Features/Timeline/Models/HomeModel.swift +++ b/damus/Features/Timeline/Models/HomeModel.swift @@ -812,13 +812,15 @@ class HomeModel: ContactsDelegate, ObservableObject { } -func update_signal_from_pool(signal: SignalModel, pool: RelayPool) { - if signal.max_signal != pool.relays.count { - signal.max_signal = pool.relays.count +func update_signal_from_pool(signal: SignalModel, pool: RelayPool) async { + let relayCount = await pool.relays.count + if signal.max_signal != relayCount { + signal.max_signal = relayCount } - if signal.signal != pool.num_connected { - signal.signal = pool.num_connected + let numberOfConnectedRelays = await pool.num_connected + if signal.signal != numberOfConnectedRelays { + signal.signal = numberOfConnectedRelays } } diff --git a/damus/Features/Wallet/Models/WalletConnect/WalletConnect+.swift b/damus/Features/Wallet/Models/WalletConnect/WalletConnect+.swift index b7f3a2c1..530395e4 100644 --- a/damus/Features/Wallet/Models/WalletConnect/WalletConnect+.swift +++ b/damus/Features/Wallet/Models/WalletConnect/WalletConnect+.swift @@ -17,14 +17,14 @@ extension WalletConnect { /// - Parameters: /// - url: The Nostr Wallet Connect URL containing connection info to the NWC wallet /// - pool: The RelayPool to send the subscription request through - static func subscribe(url: WalletConnectURL, pool: RelayPool) { + static func subscribe(url: WalletConnectURL, pool: RelayPool) async { var filter = NostrFilter(kinds: [.nwc_response]) filter.authors = [url.pubkey] filter.pubkeys = [url.keypair.pubkey] filter.limit = 0 let sub = NostrSubscribe(filters: [filter], sub_id: "nwc") - pool.send(.subscribe(sub), to: [url.relay], skip_ephemeral: false) + await pool.send(.subscribe(sub), to: [url.relay], skip_ephemeral: false) } /// Sends out a request to pay an invoice to the NWC relay, and ensures that: @@ -41,16 +41,16 @@ extension WalletConnect { /// - on_flush: A callback to call after the event has been flushed to the network /// - Returns: The Nostr Event that was sent to the network, representing the request that was made @discardableResult - static func pay(url: WalletConnectURL, pool: RelayPool, post: PostBox, invoice: String, zap_request: NostrEvent?, delay: TimeInterval? = 5.0, on_flush: OnFlush? = nil) -> NostrEvent? { + static func pay(url: WalletConnectURL, pool: RelayPool, post: PostBox, invoice: String, zap_request: NostrEvent?, delay: TimeInterval? = 5.0, on_flush: OnFlush? = nil) async -> NostrEvent? { let req = WalletConnect.Request.payZapRequest(invoice: invoice, zapRequest: zap_request) guard let ev = req.to_nostr_event(to_pk: url.pubkey, keypair: url.keypair) else { return nil } - try? pool.add_relay(.nwc(url: url.relay)) // Ensure the NWC relay is connected - WalletConnect.subscribe(url: url, pool: pool) // Ensure we are listening to NWC updates from the relay - post.send(ev, to: [url.relay], skip_ephemeral: false, delay: delay, on_flush: on_flush) + try? await pool.add_relay(.nwc(url: url.relay)) // Ensure the NWC relay is connected + await WalletConnect.subscribe(url: url, pool: pool) // Ensure we are listening to NWC updates from the relay + await post.send(ev, to: [url.relay], skip_ephemeral: false, delay: delay, on_flush: on_flush) return ev } diff --git a/damus/Features/Wallet/Models/WalletModel.swift b/damus/Features/Wallet/Models/WalletModel.swift index ecde5121..240b8bcc 100644 --- a/damus/Features/Wallet/Models/WalletModel.swift +++ b/damus/Features/Wallet/Models/WalletModel.swift @@ -181,7 +181,7 @@ class WalletModel: ObservableObject { ) ] - nostrNetwork.send(event: requestEvent, to: [currentNwcUrl.relay], skipEphemeralRelays: false) + await nostrNetwork.send(event: requestEvent, to: [currentNwcUrl.relay], skipEphemeralRelays: false) for await event in nostrNetwork.reader.timedStream(filters: responseFilters, to: [currentNwcUrl.relay], timeout: timeout) { guard let responseEvent = try? event.getCopy() else { throw .internalError } diff --git a/damus/Features/Wallet/Views/NWCSettings.swift b/damus/Features/Wallet/Views/NWCSettings.swift index c0240ae5..c72d8762 100644 --- a/damus/Features/Wallet/Views/NWCSettings.swift +++ b/damus/Features/Wallet/Views/NWCSettings.swift @@ -268,7 +268,7 @@ struct NWCSettings: View { guard let meta = make_metadata_event(keypair: keypair, metadata: prof) else { return } - damus_state.nostrNetwork.postbox.send(meta) + Task { await damus_state.nostrNetwork.postbox.send(meta) } } } diff --git a/damus/Features/Wallet/Views/SendPaymentView.swift b/damus/Features/Wallet/Views/SendPaymentView.swift index 19f3d93a..9d180485 100644 --- a/damus/Features/Wallet/Views/SendPaymentView.swift +++ b/damus/Features/Wallet/Views/SendPaymentView.swift @@ -182,18 +182,18 @@ struct SendPaymentView: View { .buttonStyle(NeutralButtonStyle()) Button(action: { - sendState = .processing - - // Process payment - guard let payRequestEv = damus_state.nostrNetwork.nwcPay(url: nwc, post: damus_state.nostrNetwork.postbox, invoice: invoice.string, zap_request: nil) else { - sendState = .failed(error: .init( - user_visible_description: NSLocalizedString("The payment request could not be made to your wallet provider.", comment: "A human-readable error message"), - tip: NSLocalizedString("Check if your wallet looks configured correctly and try again. If the error persists, please contact support.", comment: "A human-readable tip for an error when a payment request cannot be made to a wallet."), - technical_info: "Cannot form Nostr Event to send to the NWC provider when calling `pay` from the \"send payment\" feature. Wallet provider relay: \"\(nwc.relay)\"" - )) - return - } Task { + sendState = .processing + + // Process payment + guard let payRequestEv = await damus_state.nostrNetwork.nwcPay(url: nwc, post: damus_state.nostrNetwork.postbox, invoice: invoice.string, zap_request: nil) else { + sendState = .failed(error: .init( + user_visible_description: NSLocalizedString("The payment request could not be made to your wallet provider.", comment: "A human-readable error message"), + tip: NSLocalizedString("Check if your wallet looks configured correctly and try again. If the error persists, please contact support.", comment: "A human-readable tip for an error when a payment request cannot be made to a wallet."), + technical_info: "Cannot form Nostr Event to send to the NWC provider when calling `pay` from the \"send payment\" feature. Wallet provider relay: \"\(nwc.relay)\"" + )) + return + } do { let result = try await model.waitForResponse(for: payRequestEv.id, timeout: SEND_PAYMENT_TIMEOUT) guard case .pay_invoice(_) = result else { diff --git a/damus/Features/Zaps/Models/Zaps.swift b/damus/Features/Zaps/Models/Zaps.swift index c16e0d0e..3c370e41 100644 --- a/damus/Features/Zaps/Models/Zaps.swift +++ b/damus/Features/Zaps/Models/Zaps.swift @@ -95,7 +95,7 @@ class Zaps { event_counts[note_id] = event_counts[note_id]! + 1 event_totals[note_id] = event_totals[note_id]! + zap.amount - notify(.update_stats(note_id: note_id)) + Task { await notify(.update_stats(note_id: note_id)) } } } } diff --git a/damus/Features/Zaps/Views/NoteZapButton.swift b/damus/Features/Zaps/Views/NoteZapButton.swift index d7a1c9ef..fe15f396 100644 --- a/damus/Features/Zaps/Views/NoteZapButton.swift +++ b/damus/Features/Zaps/Views/NoteZapButton.swift @@ -179,7 +179,7 @@ func send_zap(damus_state: DamusState, target: ZapTarget, lnurl: String, is_cust } // Only take the first 10 because reasons - let relays = Array(damus_state.nostrNetwork.ourRelayDescriptors.prefix(10)) + let relays = Array(await damus_state.nostrNetwork.ourRelayDescriptors.prefix(10)) let content = comment ?? "" guard let mzapreq = make_zap_request_event(keypair: keypair, content: content, relays: relays, target: target, zap_type: zap_type) else { @@ -240,7 +240,7 @@ func send_zap(damus_state: DamusState, target: ZapTarget, lnurl: String, is_cust // we don't have a delay on one-tap nozaps (since this will be from customize zap view) let delay = damus_state.settings.nozaps ? nil : 5.0 - let nwc_req = damus_state.nostrNetwork.nwcPay(url: nwc_state.url, post: damus_state.nostrNetwork.postbox, invoice: inv, delay: delay, on_flush: flusher) + let nwc_req = await damus_state.nostrNetwork.nwcPay(url: nwc_state.url, post: damus_state.nostrNetwork.postbox, invoice: inv, delay: delay, on_flush: flusher) guard let nwc_req, case .nwc(let pzap_state) = pending_zap_state else { print("nwc: failed to send nwc request for zapreq \(reqid.reqid)") diff --git a/damus/Notify/Notify.swift b/damus/Notify/Notify.swift index 0557c2d8..bcc01d96 100644 --- a/damus/Notify/Notify.swift +++ b/damus/Notify/Notify.swift @@ -33,7 +33,9 @@ struct NotifyHandler { } func notify(_ notify: Notifications) { let notify = notify.notify - NotificationCenter.default.post(name: T.name, object: notify.payload) + DispatchQueue.main.async { + NotificationCenter.default.post(name: T.name, object: notify.payload) + } } func handle_notify(_ handler: NotifyHandler) -> AnyPublisher { diff --git a/damus/Notify/PresentFullScreenItemNotify.swift b/damus/Notify/PresentFullScreenItemNotify.swift index afc88b97..0cd5d990 100644 --- a/damus/Notify/PresentFullScreenItemNotify.swift +++ b/damus/Notify/PresentFullScreenItemNotify.swift @@ -37,6 +37,6 @@ extension Notifications { /// The requests from this function will be received and handled at the top level app view (`ContentView`), which contains a `.damus_full_screen_cover`. /// func present(full_screen_item: FullScreenItem) { - notify(.present_full_screen_item(full_screen_item)) + Task { await notify(.present_full_screen_item(full_screen_item)) } } diff --git a/highlighter action extension/ActionViewController.swift b/highlighter action extension/ActionViewController.swift index 0ee8bbc3..d886cbf4 100644 --- a/highlighter action extension/ActionViewController.swift +++ b/highlighter action extension/ActionViewController.swift @@ -135,7 +135,7 @@ struct ShareExtensionView: View { return } self.state = DamusState(keypair: keypair) - self.state?.nostrNetwork.connect() + Task { await self.state?.nostrNetwork.connect() } }) .onChange(of: self.highlighter_state) { if case .cancelled = highlighter_state { @@ -144,10 +144,10 @@ struct ShareExtensionView: View { } .onReceive(handle_notify(.post)) { post_notification in switch post_notification { - case .post(let post): - self.post(post) - case .cancel: - self.highlighter_state = .cancelled + case .post(let post): + Task { await self.post(post) } + case .cancel: + self.highlighter_state = .cancelled } } .onChange(of: scenePhase) { (phase: ScenePhase) in @@ -164,7 +164,7 @@ struct ShareExtensionView: View { break case .active: print("txn: ๐Ÿ“™ HIGHLIGHTER ACTIVE") - state.nostrNetwork.ping() + Task { await state.nostrNetwork.ping() } @unknown default: break } @@ -225,7 +225,7 @@ struct ShareExtensionView: View { } } - func post(_ post: NostrPost) { + func post(_ post: NostrPost) async { self.highlighter_state = .posting guard let state else { self.highlighter_state = .failed(error: "Damus state not initialized") @@ -239,7 +239,7 @@ struct ShareExtensionView: View { self.highlighter_state = .failed(error: "Cannot convert post data into a nostr event") return } - state.nostrNetwork.postbox.send(posted_event, on_flush: .once({ flushed_event in + await state.nostrNetwork.postbox.send(posted_event, on_flush: .once({ flushed_event in if flushed_event.event.id == posted_event.id { DispatchQueue.main.asyncAfter(deadline: .now() + 1, execute: { // Offset labor perception bias self.highlighter_state = .posted(event: flushed_event.event) diff --git a/nostrdb/UnownedNdbNote.swift b/nostrdb/UnownedNdbNote.swift index 79ef237b..2c971659 100644 --- a/nostrdb/UnownedNdbNote.swift +++ b/nostrdb/UnownedNdbNote.swift @@ -64,7 +64,19 @@ enum NdbNoteLender: Sendable { case .owned(let note): return try lendingFunction(UnownedNdbNote(note)) } - + } + + /// Borrows the note temporarily (asynchronously) + func borrow(_ lendingFunction: (_: borrowing UnownedNdbNote) async throws -> T) async 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 await lendingFunction(unownedNote) + case .owned(let note): + return try await lendingFunction(UnownedNdbNote(note)) + } } /// Gets an owned copy of the note diff --git a/nostrscript/NostrScript.swift b/nostrscript/NostrScript.swift index 34f0e102..917ccb78 100644 --- a/nostrscript/NostrScript.swift +++ b/nostrscript/NostrScript.swift @@ -310,7 +310,10 @@ public func nscript_nostr_cmd(interp: UnsafeMutablePointer?, cmd: I func nscript_add_relay(script: NostrScript, relay: String) -> Bool { guard let url = RelayURL(relay) else { return false } let desc = RelayPool.RelayDescriptor(url: url, info: .readWrite, variant: .ephemeral) - return (try? script.pool.add_relay(desc)) != nil + // Interacting with RelayPool needs to be done asynchronously, thus we cannot return the answer synchronously + // return (try? await script.pool.add_relay(desc)) != nil + Task { try await script.pool.add_relay(desc) } + return true } @@ -344,9 +347,7 @@ public func nscript_pool_send_to(interp: UnsafeMutablePointer?, pre return 0 } - DispatchQueue.main.async { - script.pool.send_raw(.custom(req_str), to: [to_relay_url], skip_ephemeral: false) - } + Task { await script.pool.send_raw(.custom(req_str), to: [to_relay_url], skip_ephemeral: false) } return 1; } @@ -354,9 +355,7 @@ public func nscript_pool_send_to(interp: UnsafeMutablePointer?, pre func nscript_pool_send(script: NostrScript, req req_str: String) -> Int32 { //script.test("pool_send: '\(req_str)'") - DispatchQueue.main.sync { - script.pool.send_raw(.custom(req_str), skip_ephemeral: false) - } + Task { await script.pool.send_raw(.custom(req_str), skip_ephemeral: false) } return 1; } diff --git a/share extension/ShareViewController.swift b/share extension/ShareViewController.swift index 67c38f76..6b17d894 100644 --- a/share extension/ShareViewController.swift +++ b/share extension/ShareViewController.swift @@ -173,7 +173,7 @@ struct ShareExtensionView: View { .onReceive(handle_notify(.post)) { post_notification in switch post_notification { case .post(let post): - self.post(post) + Task { await self.post(post) } case .cancel: self.share_state = .cancelled dismissParent?() @@ -193,7 +193,7 @@ struct ShareExtensionView: View { break case .active: print("txn: ๐Ÿ“™ SHARE ACTIVE") - state.nostrNetwork.ping() + Task { await state.nostrNetwork.ping() } @unknown default: break } @@ -216,7 +216,7 @@ struct ShareExtensionView: View { } } - func post(_ post: NostrPost) { + func post(_ post: NostrPost) async { self.share_state = .posting guard let state else { self.share_state = .failed(error: "Damus state not initialized") @@ -230,7 +230,7 @@ struct ShareExtensionView: View { self.share_state = .failed(error: "Cannot convert post data into a nostr event") return } - state.nostrNetwork.postbox.send(posted_event, on_flush: .once({ flushed_event in + await state.nostrNetwork.postbox.send(posted_event, on_flush: .once({ flushed_event in if flushed_event.event.id == posted_event.id { DispatchQueue.main.asyncAfter(deadline: .now() + 1, execute: { // Offset labor perception bias self.share_state = .posted(event: flushed_event.event) @@ -250,7 +250,7 @@ struct ShareExtensionView: View { return false } state = DamusState(keypair: keypair) - state?.nostrNetwork.connect() + Task { await state?.nostrNetwork.connect() } return true }