damus

nostr ios client
git clone git://jb55.com/damus
Log | Files | Refs | README | LICENSE

commit 3a0acfaba1a3953e9421bae6406ed93a8eda916a
parent 0ec2b050706ec508b355a134fd2fdd859f802ce7
Author: Daniel D’Aquino <daniel@daquino.me>
Date:   Wed, 26 Mar 2025 15:23:59 -0300

Implement NostrNetworkManager and UserRelayListManager

This commit implements a new layer called NostrNetworkManager,
responsible for managing interactions with the Nostr network, and
providing a higher level API that is easier and more secure to use for
the layer above it.

It also integrates it with the rest of the app, by moving RelayPool and PostBox
into NostrNetworkManager, along with all their usages.

Changelog-Added: Added NIP-65 relay list support
Changelog-Changed: Improved robustness of relay list handling
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>

Diffstat:
Mdamus.xcodeproj/project.pbxproj | 40++++++++++++++++++++++++++++++++++++++++
Mdamus/Components/NoteZapButton.swift | 8++++----
Mdamus/Components/Status/UserStatusSheet.swift | 2+-
Mdamus/ContentView.swift | 69+++++++++++++++++++++++++--------------------------------------------
Mdamus/Models/Contacts+.swift | 54------------------------------------------------------
Mdamus/Models/CreateAccountModel.swift | 4++++
Mdamus/Models/DamusState.swift | 59++++++++++++++++++++++++++++++++---------------------------
Mdamus/Models/DraftsModel.swift | 2+-
Mdamus/Models/EventsModel.swift | 4++--
Mdamus/Models/FollowersModel.swift | 8++++----
Mdamus/Models/FollowingModel.swift | 4++--
Mdamus/Models/HomeModel.swift | 82+++++--------------------------------------------------------------------------
Mdamus/Models/MutedThreadsManager.swift | 2+-
Adamus/Models/NostrNetworkManager/NostrNetworkManager.swift | 95+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Adamus/Models/NostrNetworkManager/SubscriptionManager.swift | 70++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Adamus/Models/NostrNetworkManager/UserRelayListErrors.swift | 85+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Adamus/Models/NostrNetworkManager/UserRelayListManager.swift | 311+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mdamus/Models/ProfileModel.swift | 16++++++++--------
Mdamus/Models/SearchHomeModel.swift | 10+++++-----
Mdamus/Models/SearchModel.swift | 8++++----
Mdamus/Models/ThreadModel.swift | 18+++++++++---------
Mdamus/Models/ZapsModel.swift | 4++--
Mdamus/Nostr/Relay.swift | 6+++---
Mdamus/Nostr/RelayPool.swift | 10+++++-----
Mdamus/TestData.swift | 5+----
Mdamus/Util/PostBox.swift | 2+-
Mdamus/Util/Router.swift | 2+-
Mdamus/Views/ActionBar/EventActionBar.swift | 2+-
Mdamus/Views/ActionBar/RepostAction.swift | 2+-
Mdamus/Views/AddRelayView.swift | 42+++++++++++++++++++-----------------------
Mdamus/Views/Chat/ChatEventView.swift | 2+-
Mdamus/Views/ConfigView.swift | 2+-
Mdamus/Views/DMChatView.swift | 2+-
Mdamus/Views/Events/EventLoaderView.swift | 6+++---
Mdamus/Views/Events/EventMenu.swift | 2+-
Mdamus/Views/Muting/AddMuteItemView.swift | 2+-
Mdamus/Views/Muting/MutelistView.swift | 2+-
Mdamus/Views/Onboarding/SuggestedUsersViewModel.swift | 2+-
Mdamus/Views/Profile/EditMetadataView.swift | 2+-
Mdamus/Views/Profile/ProfileView.swift | 2+-
Mdamus/Views/RelayFilterView.swift | 4++--
Mdamus/Views/Relays/RelayConfigView.swift | 6+++---
Mdamus/Views/Relays/RelayDetailView.swift | 60++++++++++++++++++++++++------------------------------------
Mdamus/Views/Relays/RelayStatusView.swift | 2+-
Mdamus/Views/Relays/RelayToggle.swift | 2+-
Mdamus/Views/Relays/RelayView.swift | 48++++++++++++++++--------------------------------
Mdamus/Views/ReportView.swift | 4++--
Mdamus/Views/SaveKeysView.swift | 16++++++++++++++--
Mdamus/Views/SearchView.swift | 4++--
Mdamus/Views/Settings/FirstAidSettingsView.swift | 2+-
Mdamus/Views/UserRelaysView.swift | 6+++---
Mdamus/Views/Wallet/NWCSettings.swift | 2+-
Mdamus/Views/Wallet/WalletView.swift | 4++--
MdamusTests/AuthIntegrationTests.swift | 4++--
MdamusTests/Mocking/MockDamusState.swift | 5+----
MdamusTests/MutingTests.swift | 2+-
MdamusTests/RequestTests.swift | 2+-
Mhighlighter action extension/ActionViewController.swift | 4++--
Mnostrscript/NostrScript.swift | 2+-
Mshare extension/ShareViewController.swift | 4++--
60 files changed, 836 insertions(+), 397 deletions(-)

diff --git a/damus.xcodeproj/project.pbxproj b/damus.xcodeproj/project.pbxproj @@ -1090,6 +1090,9 @@ D72E127A2BEEEED000F4F781 /* NostrFilterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D72E12792BEEEED000F4F781 /* NostrFilterTests.swift */; }; D7315A2A2ACDF3B70036E30A /* DamusCacheManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7315A292ACDF3B70036E30A /* DamusCacheManager.swift */; }; D7315A2C2ACDF4DA0036E30A /* DamusCacheManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7315A2B2ACDF4DA0036E30A /* DamusCacheManagerTests.swift */; }; + D733F9E12D92C1D900317B11 /* SubscriptionManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = D733F9E02D92C1AA00317B11 /* SubscriptionManager.swift */; }; + D733F9E22D92C1D900317B11 /* SubscriptionManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = D733F9E02D92C1AA00317B11 /* SubscriptionManager.swift */; }; + D733F9E32D92C1D900317B11 /* SubscriptionManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = D733F9E02D92C1AA00317B11 /* SubscriptionManager.swift */; }; D733F9E52D92C76100317B11 /* UnownedNdbNote.swift in Sources */ = {isa = PBXBuildFile; fileRef = D733F9E42D92C75C00317B11 /* UnownedNdbNote.swift */; }; D733F9E62D92C76100317B11 /* UnownedNdbNote.swift in Sources */ = {isa = PBXBuildFile; fileRef = D733F9E42D92C75C00317B11 /* UnownedNdbNote.swift */; }; D733F9E72D92C76100317B11 /* UnownedNdbNote.swift in Sources */ = {isa = PBXBuildFile; fileRef = D733F9E42D92C75C00317B11 /* UnownedNdbNote.swift */; }; @@ -1102,6 +1105,15 @@ D73B74E12D8365BA0067BDBC /* ExtraFonts.swift in Sources */ = {isa = PBXBuildFile; fileRef = D73B74E02D8365B40067BDBC /* ExtraFonts.swift */; }; D73B74E22D8365BA0067BDBC /* ExtraFonts.swift in Sources */ = {isa = PBXBuildFile; fileRef = D73B74E02D8365B40067BDBC /* ExtraFonts.swift */; }; D73B74E32D8365BA0067BDBC /* ExtraFonts.swift in Sources */ = {isa = PBXBuildFile; fileRef = D73B74E02D8365B40067BDBC /* ExtraFonts.swift */; }; + D73BDB0D2D6FF5F600D69970 /* NostrNetworkManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = D73BDB0C2D6FF58600D69970 /* NostrNetworkManager.swift */; }; + D73BDB0E2D6FF5F600D69970 /* NostrNetworkManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = D73BDB0C2D6FF58600D69970 /* NostrNetworkManager.swift */; }; + D73BDB102D6FF5F600D69970 /* NostrNetworkManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = D73BDB0C2D6FF58600D69970 /* NostrNetworkManager.swift */; }; + D73BDB142D71216500D69970 /* UserRelayListManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = D73BDB132D71215F00D69970 /* UserRelayListManager.swift */; }; + D73BDB152D71216500D69970 /* UserRelayListManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = D73BDB132D71215F00D69970 /* UserRelayListManager.swift */; }; + D73BDB162D71216500D69970 /* UserRelayListManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = D73BDB132D71215F00D69970 /* UserRelayListManager.swift */; }; + D73BDB182D71311900D69970 /* UserRelayListErrors.swift in Sources */ = {isa = PBXBuildFile; fileRef = D73BDB172D71310C00D69970 /* UserRelayListErrors.swift */; }; + D73BDB192D71311900D69970 /* UserRelayListErrors.swift in Sources */ = {isa = PBXBuildFile; fileRef = D73BDB172D71310C00D69970 /* UserRelayListErrors.swift */; }; + D73BDB1A2D71311900D69970 /* UserRelayListErrors.swift in Sources */ = {isa = PBXBuildFile; fileRef = D73BDB172D71310C00D69970 /* UserRelayListErrors.swift */; }; D73E5E162C6A9619007EB227 /* PostView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C75EFA327FA577B0006080F /* PostView.swift */; }; D73E5E172C6A962A007EB227 /* ImageUploadModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CD348EE29C3659D00497EB2 /* ImageUploadModel.swift */; }; D73E5E182C6A963D007EB227 /* AttachMediaUtility.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9CA876E129A00CE90003B9A3 /* AttachMediaUtility.swift */; }; @@ -2481,12 +2493,16 @@ D72E12792BEEEED000F4F781 /* NostrFilterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NostrFilterTests.swift; sourceTree = "<group>"; }; D7315A292ACDF3B70036E30A /* DamusCacheManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DamusCacheManager.swift; sourceTree = "<group>"; }; D7315A2B2ACDF4DA0036E30A /* DamusCacheManagerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DamusCacheManagerTests.swift; sourceTree = "<group>"; }; + D733F9E02D92C1AA00317B11 /* SubscriptionManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SubscriptionManager.swift; sourceTree = "<group>"; }; D733F9E42D92C75C00317B11 /* UnownedNdbNote.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UnownedNdbNote.swift; sourceTree = "<group>"; }; D734B1442CCC19B1000B5C97 /* DamusFullScreenCover.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DamusFullScreenCover.swift; sourceTree = "<group>"; }; D7373BA52B688EA200F7783D /* DamusPurpleTranslationSetupView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DamusPurpleTranslationSetupView.swift; sourceTree = "<group>"; }; D7373BA72B68974500F7783D /* DamusPurpleNewUserOnboardingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DamusPurpleNewUserOnboardingView.swift; sourceTree = "<group>"; }; D7373BA92B68A65A00F7783D /* PurpleAccountUpdateNotify.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PurpleAccountUpdateNotify.swift; sourceTree = "<group>"; }; D73B74E02D8365B40067BDBC /* ExtraFonts.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExtraFonts.swift; sourceTree = "<group>"; }; + D73BDB0C2D6FF58600D69970 /* NostrNetworkManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NostrNetworkManager.swift; sourceTree = "<group>"; }; + D73BDB132D71215F00D69970 /* UserRelayListManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserRelayListManager.swift; sourceTree = "<group>"; }; + D73BDB172D71310C00D69970 /* UserRelayListErrors.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserRelayListErrors.swift; sourceTree = "<group>"; }; D73E5F7E2C6AA066007EB227 /* DamusAliases.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DamusAliases.swift; sourceTree = "<group>"; }; D73E5F802C6AA07A007EB227 /* HighlighterExtensionAliases.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HighlighterExtensionAliases.swift; sourceTree = "<group>"; }; D74AAFC12B153395006CF0F4 /* HeadlessDamusState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HeadlessDamusState.swift; sourceTree = "<group>"; }; @@ -2750,6 +2766,7 @@ 4C0A3F8D280F63FF000448DE /* Models */ = { isa = PBXGroup; children = ( + D73BDB122D71212600D69970 /* NostrNetworkManager */, D74F43082B23F09300425B75 /* Purple */, BA3759882ABCCDE30018D73B /* Camera */, 4C190F1E2A535FC200027FD5 /* Zaps */, @@ -3959,6 +3976,17 @@ path = Mocking; sourceTree = "<group>"; }; + D73BDB122D71212600D69970 /* NostrNetworkManager */ = { + isa = PBXGroup; + children = ( + D733F9E02D92C1AA00317B11 /* SubscriptionManager.swift */, + D73BDB172D71310C00D69970 /* UserRelayListErrors.swift */, + D73BDB132D71215F00D69970 /* UserRelayListManager.swift */, + D73BDB0C2D6FF58600D69970 /* NostrNetworkManager.swift */, + ); + path = NostrNetworkManager; + sourceTree = "<group>"; + }; D74EA08C2D2E26E6002290DD /* ErrorHandling */ = { isa = PBXGroup; children = ( @@ -4452,6 +4480,7 @@ 4C3DCC762A9FE9EC0091E592 /* NdbTxn.swift in Sources */, 4CEF958D2A9CE650000F901B /* verifier.c in Sources */, 4C32B9342A9AD01A00DC3548 /* NdbProfile.swift in Sources */, + D73BDB0E2D6FF5F600D69970 /* NostrNetworkManager.swift in Sources */, 4C32B9332A99845B00DC3548 /* Ndb.swift in Sources */, D7ADD3E22B538E3500F104C4 /* DamusPurpleVerifyNpubView.swift in Sources */, 4C4793082A993E8900489948 /* refmap.c in Sources */, @@ -4586,6 +4615,7 @@ 4C32B9582A9AD44700DC3548 /* VeriferOptions.swift in Sources */, D74AAFC22B153395006CF0F4 /* HeadlessDamusState.swift in Sources */, 4CA2EFA0280E37AC0044ACD8 /* TimelineView.swift in Sources */, + D73BDB1A2D71311900D69970 /* UserRelayListErrors.swift in Sources */, 4C30AC7629A5770900E2BD5A /* NotificationItemView.swift in Sources */, 4C86F7C42A76C44C00EC0817 /* ZappingNotify.swift in Sources */, 4C363A8428233689006E126D /* Parser.swift in Sources */, @@ -4622,6 +4652,7 @@ D78DB85B2C20FE5000F0AB12 /* VectorMath.swift in Sources */, D7CB5D3E2B116DAD00AD4105 /* NotificationsManager.swift in Sources */, 50A16FFF2AA76A0900DFEC1F /* DamusVideoCoordinator.swift in Sources */, + D733F9E32D92C1D900317B11 /* SubscriptionManager.swift in Sources */, F7908E97298B1FDF00AB113A /* NIPURLBuilder.swift in Sources */, 4C285C8228385570008A31F1 /* CarouselView.swift in Sources */, 3A3040F129A8FF97008A0F29 /* LocalizationUtil.swift in Sources */, @@ -4730,6 +4761,7 @@ 4C2859602A12A2BE004746F7 /* SupporterBadge.swift in Sources */, D73E5F7F2C6AA066007EB227 /* DamusAliases.swift in Sources */, 4C1A9A2A29DDF54400516EAC /* DamusVideoPlayerView.swift in Sources */, + D73BDB152D71216500D69970 /* UserRelayListManager.swift in Sources */, 4CA352A22A76AEC5003BB08B /* LikedNotify.swift in Sources */, 5CC8529F2BD744F60039FFC5 /* HighlightView.swift in Sources */, BA37598D2ABCCE500018D73B /* PhotoCaptureProcessor.swift in Sources */, @@ -5142,6 +5174,7 @@ 82D6FB212CD99F7900C925F4 /* SelectableText.swift in Sources */, 82D6FB222CD99F7900C925F4 /* DamusColors.swift in Sources */, 82D6FB232CD99F7900C925F4 /* ThiccDivider.swift in Sources */, + D733F9E22D92C1D900317B11 /* SubscriptionManager.swift in Sources */, 82D6FB242CD99F7900C925F4 /* IconLabel.swift in Sources */, 82D6FB252CD99F7900C925F4 /* TruncatedText.swift in Sources */, 82D6FB262CD99F7900C925F4 /* SupporterBadge.swift in Sources */, @@ -5156,6 +5189,7 @@ 82D6FB2F2CD99F7900C925F4 /* BlurHashDecode.swift in Sources */, 82D6FB302CD99F7900C925F4 /* PostBox.swift in Sources */, 82D6FB312CD99F7900C925F4 /* KFOptionSetter+.swift in Sources */, + D73BDB162D71216500D69970 /* UserRelayListManager.swift in Sources */, 82D6FB322CD99F7900C925F4 /* FillAndStroke.swift in Sources */, 82D6FB332CD99F7900C925F4 /* Array.swift in Sources */, 82D6FB342CD99F7900C925F4 /* VectorMath.swift in Sources */, @@ -5388,6 +5422,7 @@ 82D6FC132CD99F7900C925F4 /* FriendIcon.swift in Sources */, 82D6FC142CD99F7900C925F4 /* CondensedProfilePicturesView.swift in Sources */, 82D6FC152CD99F7900C925F4 /* ProfileEditButton.swift in Sources */, + D73BDB102D6FF5F600D69970 /* NostrNetworkManager.swift in Sources */, 82D6FC162CD99F7900C925F4 /* RelayPaidDetail.swift in Sources */, 82D6FC172CD99F7900C925F4 /* RelayAuthenticationDetail.swift in Sources */, 82D6FC182CD99F7900C925F4 /* RelaySoftwareDetail.swift in Sources */, @@ -5441,6 +5476,7 @@ 82D6FC472CD99F7900C925F4 /* RepostAction.swift in Sources */, 82D6FC482CD99F7900C925F4 /* ShareActionButton.swift in Sources */, 82D6FC492CD99F7900C925F4 /* BigButton.swift in Sources */, + D73BDB182D71311900D69970 /* UserRelayListErrors.swift in Sources */, 82D6FC4A2CD99F7900C925F4 /* AddRelayView.swift in Sources */, 82D6FC4B2CD99F7900C925F4 /* BlocksView.swift in Sources */, D74EA0912D2E3464002290DD /* URLHandler.swift in Sources */, @@ -5780,6 +5816,7 @@ D73E5F272C6A97F4007EB227 /* TimeDot.swift in Sources */, D73E5F282C6A97F4007EB227 /* EventTop.swift in Sources */, D73E5F292C6A97F4007EB227 /* ReplyDescription.swift in Sources */, + D73BDB0D2D6FF5F600D69970 /* NostrNetworkManager.swift in Sources */, D73E5F2A2C6A97F4007EB227 /* RelativeTime.swift in Sources */, D73E5F732C6A9885007EB227 /* TestData.swift in Sources */, D78F08192D7F7F7500FC6C75 /* NIP04.swift in Sources */, @@ -5818,6 +5855,7 @@ D73E5F472C6A97F5007EB227 /* BookmarksView.swift in Sources */, D73E5F482C6A97F5007EB227 /* CarouselView.swift in Sources */, D73E5F492C6A97F5007EB227 /* ConfigView.swift in Sources */, + D733F9E12D92C1D900317B11 /* SubscriptionManager.swift in Sources */, D73E5F4A2C6A97F5007EB227 /* CreateAccountView.swift in Sources */, D73E5F7A2C6A9C55007EB227 /* NotificationFormatter.swift in Sources */, D73E5F4B2C6A97F5007EB227 /* DirectMessagesView.swift in Sources */, @@ -5853,6 +5891,7 @@ D73E5F6A2C6A97F5007EB227 /* ReportView.swift in Sources */, D73E5F6C2C6A97F5007EB227 /* RepostsView.swift in Sources */, D734B1462CCC19B1000B5C97 /* DamusFullScreenCover.swift in Sources */, + D73BDB142D71216500D69970 /* UserRelayListManager.swift in Sources */, D73E5F6D2C6A97F5007EB227 /* Launch.storyboard in Sources */, D73E5F6F2C6A97F5007EB227 /* RelayFilterView.swift in Sources */, D703D78A2C670C8A00A400EA /* LibreTranslateServer.swift in Sources */, @@ -5904,6 +5943,7 @@ D703D7A52C670E3E00A400EA /* mdb.c in Sources */, D703D76B2C670B3100A400EA /* Referenced.swift in Sources */, D703D7952C670DE600A400EA /* hash_u5.c in Sources */, + D73BDB192D71311900D69970 /* UserRelayListErrors.swift in Sources */, D703D7582C670A6000A400EA /* Id.swift in Sources */, 5C05675A2C8FBDE70073F23A /* NDBSearchView.swift in Sources */, D703D76E2C670B4900A400EA /* NdbTagsIterator.swift in Sources */, diff --git a/damus/Components/NoteZapButton.swift b/damus/Components/NoteZapButton.swift @@ -84,7 +84,7 @@ struct NoteZapButton: View { print("cancel_zap: we already have a real zap, can't cancel") break case .pending(let pzap): - guard let res = cancel_zap(zap: pzap, box: damus_state.postbox, zapcache: damus_state.zaps, evcache: damus_state.events) else { + guard let res = cancel_zap(zap: pzap, box: damus_state.nostrNetwork.postbox, zapcache: damus_state.zaps, evcache: damus_state.events) else { UIImpactFeedbackGenerator(style: .soft).impactOccurred() return @@ -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.pool.our_descriptors.prefix(10)) + let relays = Array(damus_state.nostrNetwork.pool.our_descriptors.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 { @@ -232,7 +232,7 @@ func send_zap(damus_state: DamusState, target: ZapTarget, lnurl: String, is_cust flusher = .once({ pe in // send donation zap when the pending zap is flushed, this allows user to cancel and not send a donation Task { @MainActor in - await WalletConnect.send_donation_zap(pool: damus_state.pool, postbox: damus_state.postbox, nwc: nwc_state.url, percent: damus_state.settings.donation_percent, base_msats: amount_msat) + await WalletConnect.send_donation_zap(pool: damus_state.nostrNetwork.pool, postbox: damus_state.nostrNetwork.postbox, nwc: nwc_state.url, percent: damus_state.settings.donation_percent, base_msats: amount_msat) } }) } @@ -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 = WalletConnect.pay(url: nwc_state.url, pool: damus_state.pool, post: damus_state.postbox, invoice: inv, delay: delay, on_flush: flusher) + let nwc_req = WalletConnect.pay(url: nwc_state.url, pool: damus_state.nostrNetwork.pool, 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/Components/Status/UserStatusSheet.swift b/damus/Components/Status/UserStatusSheet.swift @@ -213,6 +213,6 @@ struct UserStatusSheet: View { struct UserStatusSheet_Previews: PreviewProvider { static var previews: some View { - UserStatusSheet(damus_state: test_damus_state, postbox: test_damus_state.postbox, keypair: test_keypair, status: .init()) + UserStatusSheet(damus_state: test_damus_state, postbox: test_damus_state.nostrNetwork.postbox, keypair: test_keypair, status: .init()) } } diff --git a/damus/ContentView.swift b/damus/ContentView.swift @@ -199,7 +199,7 @@ struct ContentView: View { func MaybeReportView(target: ReportTarget) -> some View { Group { if let keypair = damus_state.keypair.to_full() { - ReportView(postbox: damus_state.postbox, target: target, keypair: keypair) + ReportView(postbox: damus_state.nostrNetwork.postbox, target: target, keypair: keypair) } else { EmptyView() } @@ -317,7 +317,7 @@ struct ContentView: View { case .post(let action): PostView(action: action, damus_state: damus_state!) case .user_status: - UserStatusSheet(damus_state: damus_state!, postbox: damus_state!.postbox, keypair: damus_state!.keypair, status: damus_state!.profiles.profile_data(damus_state!.pubkey).status) + UserStatusSheet(damus_state: damus_state!, postbox: damus_state!.nostrNetwork.postbox, keypair: damus_state!.keypair, status: damus_state!.profiles.profile_data(damus_state!.pubkey).status) .presentationDragIndicator(.visible) case .event: EventDetailView() @@ -356,7 +356,7 @@ struct ContentView: View { self.hide_bar = !show } .onReceive(timer) { n in - self.damus_state?.postbox.try_flushing_events() + 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 @@ -367,10 +367,6 @@ struct ContentView: View { self.confirm_mute = true } .onReceive(handle_notify(.attached_wallet)) { nwc in - // Ensure to add NWC relay to the pool and connect it. - try? damus_state.pool.add_relay(.nwc(url: nwc.relay)) - damus_state.pool.connect(to: [nwc.relay]) - // update the lightning address on our profile when we attach a // wallet with an associated guard let ds = self.damus_state, @@ -391,12 +387,12 @@ struct ContentView: View { 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.postbox.send(ev) + ds.nostrNetwork.postbox.send(ev) } .onReceive(handle_notify(.broadcast)) { ev in guard let ds = self.damus_state else { return } - ds.postbox.send(ev) + ds.nostrNetwork.postbox.send(ev) } .onReceive(handle_notify(.unfollow)) { target in guard let state = self.damus_state else { return } @@ -418,7 +414,7 @@ struct ContentView: View { return } - if !handle_post_notification(keypair: keypair, postbox: state.postbox, events: state.events, post: post) { + if !handle_post_notification(keypair: keypair, postbox: state.nostrNetwork.postbox, events: state.events, post: post) { self.active_sheet = nil } } @@ -462,7 +458,7 @@ struct ContentView: View { } } .onReceive(handle_notify(.disconnect_relays)) { () in - damus_state.pool.disconnect() + damus_state.nostrNetwork.pool.disconnect() } .onReceive(NotificationCenter.default.publisher(for: UIApplication.willEnterForegroundNotification)) { obj in print("txn: 📙 DAMUS ACTIVE NOTIFY") @@ -508,7 +504,7 @@ struct ContentView: View { break case .active: print("txn: 📙 DAMUS ACTIVE") - damus_state.pool.ping() + damus_state.nostrNetwork.pool.ping() @unknown default: break } @@ -527,7 +523,7 @@ struct ContentView: View { 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.postbox.send(profile_ev) + 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.")) { @@ -559,7 +555,7 @@ struct ContentView: View { } ds.mutelist_manager.set_mutelist(mutelist) - ds.postbox.send(mutelist) + ds.nostrNetwork.postbox.send(mutelist) confirm_overwrite_mutelist = false confirm_mute = false @@ -591,7 +587,7 @@ struct ContentView: View { } ds.mutelist_manager.set_mutelist(ev) - ds.postbox.send(ev) + ds.nostrNetwork.postbox.send(ev) } } }, message: { @@ -660,28 +656,14 @@ struct ContentView: View { guard let ndb = mndb else { return } - let pool = RelayPool(ndb: ndb, keypair: keypair) let model_cache = RelayModelCache() let relay_filters = RelayFilters(our_pubkey: pubkey) - let bootstrap_relays = load_bootstrap_relays(pubkey: pubkey) let settings = UserSettingsStore.globally_load_for(pubkey: pubkey) let new_relay_filters = load_relay_filters(pubkey) == nil - for relay in bootstrap_relays { - let descriptor = RelayPool.RelayDescriptor(url: relay, info: .rw) - add_new_relay(model_cache: model_cache, relay_filters: relay_filters, pool: pool, descriptor: descriptor, new_relay_filters: new_relay_filters, logging_enabled: settings.developer_mode) - } - - pool.register_handler(sub_id: sub_id, handler: home.handle_event) - - if let nwc_str = settings.nostr_wallet_connect, - let nwc = WalletConnectURL(str: nwc_str) { - try? pool.add_relay(.nwc(url: nwc.relay)) - } - self.damus_state = DamusState(pool: pool, - keypair: keypair, + self.damus_state = DamusState(keypair: keypair, likes: EventCounter(our_pubkey: pubkey), boosts: EventCounter(our_pubkey: pubkey), contacts: Contacts(our_pubkey: pubkey), @@ -697,8 +679,6 @@ struct ContentView: View { drafts: Drafts(), events: EventCache(ndb: ndb), bookmarks: BookmarksManager(pubkey: pubkey), - postbox: PostBox(pool: pool), - bootstrap_relays: bootstrap_relays, replies: ReplyCounter(our_pubkey: pubkey), wallet: WalletModel(settings: settings), nav: self.navigationCoordinator, @@ -722,7 +702,8 @@ struct ContentView: View { // Purple API is an experimental feature. If not enabled, do not connect `StoreObserver` with Purple API to avoid leaking receipts } - pool.connect() + damus_state.nostrNetwork.pool.register_handler(sub_id: sub_id, handler: home.handle_event) + damus_state.nostrNetwork.connect() } func music_changed(_ state: MusicState) { @@ -745,7 +726,7 @@ struct ContentView: View { pdata.status.music = music guard let ev = music.to_note(keypair: kp) else { return } - damus_state.postbox.send(ev) + damus_state.nostrNetwork.postbox.send(ev) } } @@ -994,7 +975,7 @@ func find_event_with_subid(state: DamusState, query query_: FindEvent, subid: St var has_event = false guard let filter else { return } - state.pool.subscribe_to(sub_id: subid, filters: [filter], to: find_from) { relay_id, res in + state.nostrNetwork.pool.subscribe_to(sub_id: subid, filters: [filter], to: find_from) { relay_id, res in guard case .nostr_event(let ev) = res else { return } @@ -1008,7 +989,7 @@ func find_event_with_subid(state: DamusState, query query_: FindEvent, subid: St break case .event(_, let ev): has_event = true - state.pool.unsubscribe(sub_id: subid) + state.nostrNetwork.pool.unsubscribe(sub_id: subid) switch query { case .profile: @@ -1021,11 +1002,11 @@ func find_event_with_subid(state: DamusState, query query_: FindEvent, subid: St case .eose: if !has_event { attempts += 1 - if attempts >= state.pool.our_descriptors.count { + if attempts >= state.nostrNetwork.pool.our_descriptors.count { callback(nil) // If we could not find any events in any of the relays we are connected to, send back nil } } - state.pool.unsubscribe(sub_id: subid, to: [relay_id]) // We are only finding an event once, so close subscription on eose + state.nostrNetwork.pool.unsubscribe(sub_id: subid, to: [relay_id]) // We are only finding an event once, so close subscription on eose case .notice: break case .auth: @@ -1050,9 +1031,9 @@ func naddrLookup(damus_state: DamusState, naddr: NAddr, callback: @escaping (Nos let subid = UUID().description - damus_state.pool.subscribe_to(sub_id: subid, filters: [filter], to: nil) { relay_id, res in + damus_state.nostrNetwork.pool.subscribe_to(sub_id: subid, filters: [filter], to: nil) { relay_id, res in guard case .nostr_event(let ev) = res else { - damus_state.pool.unsubscribe(sub_id: subid, to: [relay_id]) + damus_state.nostrNetwork.pool.unsubscribe(sub_id: subid, to: [relay_id]) return } @@ -1060,14 +1041,14 @@ func naddrLookup(damus_state: DamusState, naddr: NAddr, callback: @escaping (Nos for tag in ev.tags { if(tag.count >= 2 && tag[0].string() == "d"){ if (tag[1].string() == naddr.identifier){ - damus_state.pool.unsubscribe(sub_id: subid, to: [relay_id]) + damus_state.nostrNetwork.pool.unsubscribe(sub_id: subid, to: [relay_id]) callback(ev) return } } } } - damus_state.pool.unsubscribe(sub_id: subid, to: [relay_id]) + damus_state.nostrNetwork.pool.unsubscribe(sub_id: subid, to: [relay_id]) } } @@ -1115,7 +1096,7 @@ func handle_unfollow(state: DamusState, unfollow: FollowRef) -> Bool { let old_contacts = state.contacts.event - guard let ev = unfollow_reference(postbox: state.postbox, our_contacts: old_contacts, keypair: keypair, unfollow: unfollow) + guard let ev = unfollow_reference(postbox: state.nostrNetwork.postbox, our_contacts: old_contacts, keypair: keypair, unfollow: unfollow) else { return false } @@ -1141,7 +1122,7 @@ func handle_follow(state: DamusState, follow: FollowRef) -> Bool { return false } - guard let ev = follow_reference(box: state.postbox, our_contacts: state.contacts.event, keypair: keypair, follow: follow) + guard let ev = follow_reference(box: state.nostrNetwork.postbox, our_contacts: state.contacts.event, keypair: keypair, follow: follow) else { return false } diff --git a/damus/Models/Contacts+.swift b/damus/Models/Contacts+.swift @@ -67,41 +67,6 @@ func decode_json_relays(_ content: String) -> [RelayURL: LegacyKind3RelayRWConfi return decode_json(content) } -func remove_relay(ev: NostrEvent, current_relays: [RelayPool.RelayDescriptor], keypair: FullKeypair, relay: RelayURL) -> NostrEvent?{ - var relays = ensure_relay_info(relays: current_relays, content: ev.content) - - relays.removeValue(forKey: relay) - - guard let content = encode_json(relays) else { - return nil - } - - return NostrEvent(content: content, keypair: keypair.to_keypair(), kind: 3, tags: ev.tags.strings()) -} - -/// Handles the creation of a new `kind:3` contact list based on a previous contact list, with the specified relays -func add_relay(ev: NostrEvent, keypair: FullKeypair, current_relays: [RelayPool.RelayDescriptor], relay: RelayURL, info: LegacyKind3RelayRWConfiguration) -> NostrEvent? { - var relays = ensure_relay_info(relays: current_relays, content: ev.content) - - // If kind:3 content is empty, or if the relay doesn't exist in the list, - // we want to create a kind:3 event with the new relay - guard ev.content.isEmpty || relays.index(forKey: relay) == nil else { - return nil - } - - relays[relay] = info - - guard let content = encode_json(relays) else { - return nil - } - - return NostrEvent(content: content, keypair: keypair.to_keypair(), kind: 3, tags: ev.tags.strings()) -} - -func ensure_relay_info(relays: [RelayPool.RelayDescriptor], content: String) -> [RelayURL: LegacyKind3RelayRWConfiguration] { - return decode_json_relays(content) ?? make_contact_relays(relays) -} - func is_already_following(contacts: NostrEvent, follow: FollowRef) -> Bool { return contacts.references.contains { ref in switch (ref, follow) { @@ -129,22 +94,3 @@ func follow_with_existing_contacts(keypair: FullKeypair, our_contacts: NostrEven return NostrEvent(content: our_contacts.content, keypair: keypair.to_keypair(), kind: kind, tags: tags) } -func make_contact_relays(_ relays: [RelayPool.RelayDescriptor]) -> [RelayURL: LegacyKind3RelayRWConfiguration] { - return relays.reduce(into: [:]) { acc, relay in - acc[relay.url] = relay.info - } -} - -func make_relay_metadata(relays: [RelayPool.RelayDescriptor], keypair: FullKeypair) -> NostrEvent? { - let tags = relays.compactMap { r -> [String]? in - var tag = ["r", r.url.absoluteString] - if (r.info.read ?? true) != (r.info.write ?? true) { - tag += r.info.read == true ? ["read"] : ["write"] - } - if ((r.info.read ?? true) || (r.info.write ?? true)) && r.variant == .regular { - return tag; - } - return nil - } - return NostrEvent(content: "", keypair: keypair.to_keypair(), kind: 10_002, tags: tags) -} diff --git a/damus/Models/CreateAccountModel.swift b/damus/Models/CreateAccountModel.swift @@ -27,6 +27,10 @@ class CreateAccountModel: ObservableObject { return Keypair(pubkey: self.pubkey, privkey: self.privkey) } + var full_keypair: FullKeypair { + return FullKeypair(pubkey: self.pubkey, privkey: self.privkey) + } + init(display_name: String = "", name: String = "", about: String = "") { let keypair = generate_new_keypair() self.pubkey = keypair.pubkey diff --git a/damus/Models/DamusState.swift b/damus/Models/DamusState.swift @@ -10,7 +10,6 @@ import LinkPresentation import EmojiPicker class DamusState: HeadlessDamusState { - let pool: RelayPool let keypair: Keypair let likes: EventCounter let boosts: EventCounter @@ -28,8 +27,6 @@ class DamusState: HeadlessDamusState { let drafts: Drafts let events: EventCache let bookmarks: BookmarksManager - let postbox: PostBox - let bootstrap_relays: [RelayURL] let replies: ReplyCounter let wallet: WalletModel let nav: NavigationCoordinator @@ -39,9 +36,9 @@ class DamusState: HeadlessDamusState { var purple: DamusPurple var push_notification_client: PushNotificationClient let emoji_provider: EmojiProvider + private(set) var nostrNetwork: NostrNetworkManager - init(pool: RelayPool, keypair: Keypair, likes: EventCounter, boosts: EventCounter, contacts: Contacts, mutelist_manager: MutelistManager, profiles: Profiles, dms: DirectMessagesModel, previews: PreviewCache, zaps: Zaps, lnurls: LNUrls, settings: UserSettingsStore, relay_filters: RelayFilters, relay_model_cache: RelayModelCache, drafts: Drafts, events: EventCache, bookmarks: BookmarksManager, postbox: PostBox, bootstrap_relays: [RelayURL], replies: ReplyCounter, wallet: WalletModel, nav: NavigationCoordinator, music: MusicController?, video: DamusVideoCoordinator, ndb: Ndb, purple: DamusPurple? = nil, quote_reposts: EventCounter, emoji_provider: EmojiProvider) { - self.pool = pool + init(keypair: Keypair, likes: EventCounter, boosts: EventCounter, contacts: Contacts, mutelist_manager: MutelistManager, profiles: Profiles, dms: DirectMessagesModel, previews: PreviewCache, zaps: Zaps, lnurls: LNUrls, settings: UserSettingsStore, relay_filters: RelayFilters, relay_model_cache: RelayModelCache, drafts: Drafts, events: EventCache, bookmarks: BookmarksManager, replies: ReplyCounter, wallet: WalletModel, nav: NavigationCoordinator, music: MusicController?, video: DamusVideoCoordinator, ndb: Ndb, purple: DamusPurple? = nil, quote_reposts: EventCounter, emoji_provider: EmojiProvider) { self.keypair = keypair self.likes = likes self.boosts = boosts @@ -58,8 +55,6 @@ class DamusState: HeadlessDamusState { self.drafts = drafts self.events = events self.bookmarks = bookmarks - self.postbox = postbox - self.bootstrap_relays = bootstrap_relays self.replies = replies self.wallet = wallet self.nav = nav @@ -73,6 +68,9 @@ class DamusState: HeadlessDamusState { self.quote_reposts = quote_reposts self.push_notification_client = PushNotificationClient(keypair: keypair, settings: settings) self.emoji_provider = emoji_provider + + let networkManagerDelegate = NostrNetworkManagerDelegate(settings: settings, contacts: contacts, ndb: ndb, keypair: keypair, relayModelCache: relay_model_cache, relayFilters: relay_filters) + self.nostrNetwork = NostrNetworkManager(delegate: networkManagerDelegate) } @MainActor @@ -98,27 +96,13 @@ class DamusState: HeadlessDamusState { guard let ndb = mndb else { return nil } let pubkey = keypair.pubkey - let pool = RelayPool(ndb: ndb, keypair: keypair) let model_cache = RelayModelCache() let relay_filters = RelayFilters(our_pubkey: pubkey) let bootstrap_relays = load_bootstrap_relays(pubkey: pubkey) let settings = UserSettingsStore.globally_load_for(pubkey: pubkey) - let new_relay_filters = load_relay_filters(pubkey) == nil - for relay in bootstrap_relays { - let descriptor = RelayPool.RelayDescriptor(url: relay, info: .rw) - add_new_relay(model_cache: model_cache, relay_filters: relay_filters, pool: pool, descriptor: descriptor, new_relay_filters: new_relay_filters, logging_enabled: settings.developer_mode) - } - - pool.register_handler(sub_id: sub_id, handler: home.handle_event) - - if let nwc_str = settings.nostr_wallet_connect, - let nwc = WalletConnectURL(str: nwc_str) { - try? pool.add_relay(.nwc(url: nwc.relay)) - } self.init( - pool: pool, keypair: keypair, likes: EventCounter(our_pubkey: pubkey), boosts: EventCounter(our_pubkey: pubkey), @@ -135,8 +119,6 @@ class DamusState: HeadlessDamusState { drafts: Drafts(), events: EventCache(ndb: ndb), bookmarks: BookmarksManager(pubkey: pubkey), - postbox: PostBox(pool: pool), - bootstrap_relays: bootstrap_relays, replies: ReplyCounter(our_pubkey: pubkey), wallet: WalletModel(settings: settings), nav: navigationCoordinator, @@ -179,7 +161,7 @@ class DamusState: HeadlessDamusState { try await self.push_notification_client.revoke_token() } wallet.disconnect() - pool.close() + nostrNetwork.pool.close() ndb.close() } @@ -189,7 +171,6 @@ class DamusState: HeadlessDamusState { let kp = Keypair(pubkey: empty_pub, privkey: nil) return DamusState.init( - pool: RelayPool(ndb: .empty), keypair: Keypair(pubkey: empty_pub, privkey: empty_sec), likes: EventCounter(our_pubkey: empty_pub), boosts: EventCounter(our_pubkey: empty_pub), @@ -206,8 +187,6 @@ class DamusState: HeadlessDamusState { drafts: Drafts(), events: EventCache(ndb: .empty), bookmarks: BookmarksManager(pubkey: empty_pub), - postbox: PostBox(pool: RelayPool(ndb: .empty)), - bootstrap_relays: [], replies: ReplyCounter(our_pubkey: empty_pub), wallet: WalletModel(settings: UserSettingsStore()), nav: NavigationCoordinator(), @@ -219,3 +198,29 @@ class DamusState: HeadlessDamusState { ) } } + +fileprivate extension DamusState { + struct NostrNetworkManagerDelegate: NostrNetworkManager.Delegate { + let settings: UserSettingsStore + let contacts: Contacts + + var ndb: Ndb + var keypair: Keypair + + var latestRelayListEventIdHex: String? { + get { self.settings.latestRelayListEventIdHex } + set { self.settings.latestRelayListEventIdHex = newValue } + } + + var latestContactListEvent: NostrEvent? { self.contacts.event } + var bootstrapRelays: [RelayURL] { get_default_bootstrap_relays() } + var developerMode: Bool { self.settings.developer_mode } + var relayModelCache: RelayModelCache + var relayFilters: RelayFilters + + var nwcWallet: WalletConnectURL? { + guard let nwcString = self.settings.nostr_wallet_connect else { return nil } + return WalletConnectURL(str: nwcString) + } + } +} diff --git a/damus/Models/DraftsModel.swift b/damus/Models/DraftsModel.swift @@ -251,7 +251,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.pool.send_raw_to_local_ndb(.typical(.event(draft_event))) + damus_state.nostrNetwork.pool.send_raw_to_local_ndb(.typical(.event(draft_event))) } damus_state.settings.draft_event_ids = draft_events.map({ $0.id.hex() }) diff --git a/damus/Models/EventsModel.swift b/damus/Models/EventsModel.swift @@ -68,13 +68,13 @@ class EventsModel: ObservableObject { } func subscribe() { - state.pool.subscribe(sub_id: sub_id, + state.nostrNetwork.pool.subscribe(sub_id: sub_id, filters: [get_filter()], handler: handle_nostr_event) } func unsubscribe() { - state.pool.unsubscribe(sub_id: sub_id) + state.nostrNetwork.pool.unsubscribe(sub_id: sub_id) } private func handle_event(relay_id: RelayURL, ev: NostrEvent) { diff --git a/damus/Models/FollowersModel.swift b/damus/Models/FollowersModel.swift @@ -37,11 +37,11 @@ class FollowersModel: ObservableObject { let filter = get_filter() let filters = [filter] //print_filters(relay_id: "following", filters: [filters]) - self.damus_state.pool.subscribe(sub_id: sub_id, filters: filters, handler: handle_event) + self.damus_state.nostrNetwork.pool.subscribe(sub_id: sub_id, filters: filters, handler: handle_event) } func unsubscribe() { - self.damus_state.pool.unsubscribe(sub_id: sub_id) + self.damus_state.nostrNetwork.pool.unsubscribe(sub_id: sub_id) } func handle_contact_event(_ ev: NostrEvent) { @@ -61,7 +61,7 @@ class FollowersModel: ObservableObject { let filter = NostrFilter(kinds: [.metadata], authors: authors) - damus_state.pool.subscribe_to(sub_id: profiles_id, filters: [filter], to: [relay_id], handler: handle_event) + damus_state.nostrNetwork.pool.subscribe_to(sub_id: profiles_id, filters: [filter], to: [relay_id], handler: handle_event) } func handle_event(relay_id: RelayURL, ev: NostrConnectionEvent) { @@ -86,7 +86,7 @@ class FollowersModel: ObservableObject { guard let txn = NdbTxn(ndb: self.damus_state.ndb) else { return } load_profiles(relay_id: relay_id, txn: txn) } else if sub_id == self.profiles_id { - damus_state.pool.unsubscribe(sub_id: profiles_id, to: [relay_id]) + damus_state.nostrNetwork.pool.unsubscribe(sub_id: profiles_id, to: [relay_id]) } case .ok: diff --git a/damus/Models/FollowingModel.swift b/damus/Models/FollowingModel.swift @@ -42,7 +42,7 @@ class FollowingModel { } let filters = [filter] //print_filters(relay_id: "following", filters: [filters]) - self.damus_state.pool.subscribe(sub_id: sub_id, filters: filters, handler: handle_event) + self.damus_state.nostrNetwork.pool.subscribe(sub_id: sub_id, filters: filters, handler: handle_event) } func unsubscribe() { @@ -50,7 +50,7 @@ class FollowingModel { return } print("unsubscribing from following \(sub_id)") - self.damus_state.pool.unsubscribe(sub_id: sub_id) + self.damus_state.nostrNetwork.pool.unsubscribe(sub_id: sub_id) } func handle_event(relay_id: RelayURL, ev: NostrConnectionEvent) { diff --git a/damus/Models/HomeModel.swift b/damus/Models/HomeModel.swift @@ -95,13 +95,13 @@ class HomeModel: ContactsDelegate { } var pool: RelayPool { - return damus_state.pool + self.damus_state.nostrNetwork.pool } var dms: DirectMessagesModel { return damus_state.dms } - + func has_sub_id_event(sub_id: String, ev_id: NoteId) -> Bool { if !has_event.keys.contains(sub_id) { has_event[sub_id] = Set() @@ -268,7 +268,7 @@ class HomeModel: ContactsDelegate { // since command results are not returned for ephemeral events, // remove the request from the postbox which is likely failing over and over - if damus_state.postbox.remove_relayer(relay_id: nwc.relay, event_id: resp.req_id) { + if damus_state.nostrNetwork.postbox.remove_relayer(relay_id: nwc.relay, event_id: resp.req_id) { print("nwc: got response, removed \(resp.req_id) from the postbox [\(relay)]") } else { print("nwc: \(resp.req_id) not found in the postbox, nothing to remove [\(relay)]") @@ -480,7 +480,7 @@ class HomeModel: ContactsDelegate { break } - update_signal_from_pool(signal: self.signal, pool: damus_state.pool) + update_signal_from_pool(signal: self.signal, pool: damus_state.nostrNetwork.pool) case .nostr_event(let ev): switch ev { case .event(let sub_id, let ev): @@ -950,7 +950,6 @@ func load_our_stuff(state: DamusState, ev: NostrEvent) { state.contacts.event = ev load_our_contacts(state: state, m_old_ev: m_old_ev, ev: ev) - load_our_relays(state: state, m_old_ev: m_old_ev, ev: ev) } func process_contact_event(state: DamusState, ev: NostrEvent) { @@ -958,78 +957,6 @@ func process_contact_event(state: DamusState, ev: NostrEvent) { add_contact_if_friend(contacts: state.contacts, ev: ev) } -func load_our_relays(state: DamusState, m_old_ev: NostrEvent?, ev: NostrEvent) { - let bootstrap_dict: [RelayURL: LegacyKind3RelayRWConfiguration] = [:] - let old_decoded = m_old_ev.flatMap { decode_json_relays($0.content) } ?? state.bootstrap_relays.reduce(into: bootstrap_dict) { (d, r) in - d[r] = .rw - } - - guard let decoded: [RelayURL: LegacyKind3RelayRWConfiguration] = decode_json_relays(ev.content) else { - return - } - - var changed = false - - var new = Set<RelayURL>() - for key in decoded.keys { - new.insert(key) - } - - var old = Set<RelayURL>() - for key in old_decoded.keys { - old.insert(key) - } - - let diff = old.symmetricDifference(new) - - let new_relay_filters = load_relay_filters(state.pubkey) == nil - for d in diff { - changed = true - if new.contains(d) { - let descriptor = RelayPool.RelayDescriptor(url: d, info: decoded[d] ?? .rw) - add_new_relay(model_cache: state.relay_model_cache, relay_filters: state.relay_filters, pool: state.pool, descriptor: descriptor, new_relay_filters: new_relay_filters, logging_enabled: state.settings.developer_mode) - } else { - state.pool.remove_relay(d) - } - } - - if changed { - save_bootstrap_relays(pubkey: state.pubkey, relays: Array(new)) - state.pool.connect() - notify(.relays_changed) - } -} - -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) - let url = descriptor.url - - let relay_id = url - guard model_cache.model(withURL: url) == nil else { - return - } - - Task.detached(priority: .background) { - guard let meta = try? await fetch_relay_metadata(relay_id: relay_id) else { - return - } - - await MainActor.run { - let model = RelayModel(url, metadata: meta) - model_cache.insert(model: model) - - if logging_enabled { - pool.setLog(model.log, for: relay_id) - } - - // if this is the first time adding filters, we should filter non-paid relays - if new_relay_filters && !meta.is_paid { - relay_filters.insert(timeline: .search, relay_id: relay_id) - } - } - } -} - func fetch_relay_metadata(relay_id: RelayURL) async throws -> RelayMetadata? { var urlString = relay_id.absoluteString.replacingOccurrences(of: "wss://", with: "https://") urlString = urlString.replacingOccurrences(of: "ws://", with: "http://") @@ -1252,3 +1179,4 @@ func create_in_app_event_zap_notification(profiles: Profiles, zap: Zap, locale: } } } + diff --git a/damus/Models/MutedThreadsManager.swift b/damus/Models/MutedThreadsManager.swift @@ -33,7 +33,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.postbox.send(new_mutelist_event) + 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/Models/NostrNetworkManager/NostrNetworkManager.swift b/damus/Models/NostrNetworkManager/NostrNetworkManager.swift @@ -0,0 +1,95 @@ +// +// NostrNetworkManager.swift +// damus +// +// Created by Daniel D’Aquino on 2025-02-26. +// +import Foundation + +/// Manages interactions with the Nostr Network. +/// +/// This delineates a layer that is responsible for doing mid-level management of interactions with the Nostr network, controlling lower-level classes that perform more network/DB specific code, and providing an easier to use and more semantic interfaces for the rest of the app. +/// +/// This is responsible for: +/// - Managing the user's relay list +/// - Establishing a `RelayPool` and maintaining it in sync with the user's relay list as it changes +/// - Abstracting away complexities of interacting with the nostr network, providing an easier-to-use interface to fetch and send content related to the Nostr network +/// +/// This is **NOT** responsible for: +/// - Doing actual storage of relay list (delegated via the delegate +/// - Handling low-level relay logic (this will be delegated to lower level classes used in RelayPool/RelayConnection) +class NostrNetworkManager { + /// The relay pool that we manage + /// + /// ## Implementation notes + /// + /// - This will be marked `private` in the future to prevent other code from accessing the relay pool directly. Code outside this layer should use a higher level interface + let pool: RelayPool // TODO: Make this private and make higher level interface for classes outside the NostrNetworkManager + /// A delegate that allows us to interact with the rest of app without introducing hard or circular dependencies + private var delegate: Delegate + /// Manages the user's relay list, controls RelayPool's connected relays + let userRelayList: UserRelayListManager + /// Handles sending out notes to the network + let postbox: PostBox + /// Handles subscriptions and functions to read or consume data from the Nostr network + let reader: SubscriptionManager + + init(delegate: Delegate) { + self.delegate = delegate + let pool = RelayPool(ndb: delegate.ndb, keypair: delegate.keypair) + self.pool = pool + let reader = SubscriptionManager(pool: pool, ndb: delegate.ndb) + let userRelayList = UserRelayListManager(delegate: delegate, pool: pool, reader: reader) + self.reader = reader + self.userRelayList = userRelayList + self.postbox = PostBox(pool: pool) + } + + // MARK: - Control functions + + /// Connects the app to the Nostr network + func connect() { + self.userRelayList.connect() + } +} + + +// MARK: - Helper types + +extension NostrNetworkManager { + /// The delegate that provides information and structure for the `NostrNetworkManager` to function. + /// + /// ## Implementation notes + /// + /// This is needed to prevent a circular reference between `DamusState` and `NostrNetworkManager`, and reduce coupling. + protocol Delegate: Sendable { + /// NostrDB instance, used with `RelayPool` to send events for ingestion. + var ndb: Ndb { get } + + /// The keypair to use for relay authentication and updating relay lists + var keypair: Keypair { get } + + /// The latest relay list event id hex + var latestRelayListEventIdHex: String? { get set } // TODO: Update this once we have full NostrDB query support + + /// The latest contact list `NostrEvent` + /// + /// Note: Read-only access, because `NostrNetworkManager` does not manage contact lists. + var latestContactListEvent: NostrEvent? { get } + + /// Default bootstrap relays to start with when a user relay list is not present + var bootstrapRelays: [RelayURL] { get } + + /// Whether the app is in developer mode + var developerMode: Bool { get } + + /// The cache of relay model information + var relayModelCache: RelayModelCache { get } + + /// Relay filters + var relayFilters: RelayFilters { get } + + /// The user's connected NWC wallet + var nwcWallet: WalletConnectURL? { get } + } +} diff --git a/damus/Models/NostrNetworkManager/SubscriptionManager.swift b/damus/Models/NostrNetworkManager/SubscriptionManager.swift @@ -0,0 +1,70 @@ +// +// SubscriptionManager.swift +// damus +// +// Created by Daniel D’Aquino on 2025-03-25. +// + +extension NostrNetworkManager { + /// Reads or fetches information from RelayPool and NostrDB, and provides an easier and unified higher-level interface. + /// + /// ## Implementation notes + /// + /// - This class will be a key part of the local relay model migration. Most higher-level code should fetch content from this class, which will properly setup the correct relay pool subscriptions, and provide a stream from NostrDB for higher performance and reliability. + class SubscriptionManager { + private let pool: RelayPool + private var ndb: Ndb + + init(pool: RelayPool, ndb: Ndb) { + self.pool = pool + self.ndb = ndb + } + + // MARK: - Reading data from Nostr + + /// Subscribes to data from the user's relays + /// + /// ## Implementation notes + /// + /// - When we migrate to the local relay model, we should modify this function to stream directly from NostrDB + /// + /// - Parameter filters: The nostr filters to specify what kind of data to subscribe to + /// - Returns: An async stream of nostr data + func subscribe(filters: [NostrFilter]) -> AsyncStream<StreamItem> { + return AsyncStream<StreamItem> { continuation in + let streamTask = Task { + for await item in self.pool.subscribe(filters: filters) { + switch item { + case .eose: continuation.yield(.eose) + case .event(let nostrEvent): + // At this point of the pipeline, if the note is valid it should have been processed and verified by NostrDB, + // in which case we should pull the note from NostrDB to ensure validity. + // However, NdbNotes are unowned, so we return a function where our callers can temporarily borrow the NostrDB note + let noteId = nostrEvent.id + let lender: NdbNoteLender = { lend in + guard let ndbNoteTxn = self.ndb.lookup_note(noteId) else { + throw NdbNoteLenderError.errorLoadingNote + } + guard let unownedNote = UnownedNdbNote(ndbNoteTxn) else { + throw NdbNoteLenderError.errorLoadingNote + } + lend(unownedNote) + } + continuation.yield(.event(borrow: lender)) + } + } + } + continuation.onTermination = { @Sendable _ in + streamTask.cancel() // Close the RelayPool stream when caller stops streaming + } + } + } + } + + enum StreamItem { + /// An event which can be borrowed from NostrDB + case event(borrow: NdbNoteLender) + /// The end of stored events + case eose + } +} diff --git a/damus/Models/NostrNetworkManager/UserRelayListErrors.swift b/damus/Models/NostrNetworkManager/UserRelayListErrors.swift @@ -0,0 +1,85 @@ +// +// UserRelayListErrors.swift +// damus +// +// Created by Daniel D’Aquino on 2025-02-27. +// + +import Foundation + +extension NostrNetworkManager.UserRelayListManager { + /// Models an error that may occur when performing operations that change the user's relay list. + /// + /// Callers to functions that throw this error SHOULD handle them in order to provide a better user experience. + enum UpdateError: Error { + /// The user is not authorized to change relay list, usually because the private key is missing. + case notAuthorizedToChangeRelayList + /// An error occurred when forming the relay list Nostr event. + case cannotFormRelayListEvent + /// Cannot add item to the relay list because the relay is already present in the list. + case relayAlreadyExists + /// Cannot update the relay list because we do not have the user's previous relay list. + /// + /// Implementers must be careful not to overwrite the user's existing relay list if it exists somewhere else. + case noInitialRelayList + /// Cannot remove or update a specific relay because it is not on the relay list + case noSuchRelay + + /// Convert `RelayPool.RelayError` into `UserRelayListUpdateError` + static func from(_ relayPoolError: RelayPool.RelayError) -> Self { + switch relayPoolError { + case .RelayAlreadyExists: return .relayAlreadyExists + } + } + + var humanReadableError: ErrorView.UserPresentableError { + switch self { + case .notAuthorizedToChangeRelayList: + ErrorView.UserPresentableError( + user_visible_description: NSLocalizedString("You do not have permission to alter this relay list.", comment: "Human readable error description"), + tip: NSLocalizedString("Please make sure you have logged-in with your private key.", comment: "Human readable tip for error"), + technical_info: nil + ) + case .cannotFormRelayListEvent: + ErrorView.UserPresentableError( + user_visible_description: NSLocalizedString("There was a problem creating the relay list event.", comment: "Human readable error description"), + tip: NSLocalizedString("Please try again later or contact support if the issue persists.", comment: "Human readable tip for error"), + technical_info: "Failed forming Nostr event for the relay list update." + ) + case .relayAlreadyExists: + ErrorView.UserPresentableError( + user_visible_description: NSLocalizedString("This relay is already in your list.", comment: "Human readable tip for error"), + tip: NSLocalizedString("Check the address and/or the relay list.", comment: "Human readable tip for error"), + technical_info: nil + ) + case .noInitialRelayList: + ErrorView.UserPresentableError( + user_visible_description: NSLocalizedString("No initial relay list available to update.", comment: "Human readable error description"), + tip: NSLocalizedString("Please go to Settings > First Aid > Repair relay list, or contact support.", comment: "Human readable tip for error"), + technical_info: "Missing initial relay list data for reference during update." + ) + case .noSuchRelay: + ErrorView.UserPresentableError( + user_visible_description: NSLocalizedString("The specified relay that you are trying to udpate was not found in your relay list.", comment: "Human readable error description"), + tip: NSLocalizedString("This is an unexpected error, please contact support.", comment: "Human readable tip for error"), + technical_info: nil + ) + } + } + } + + enum LoadingError: Error { + case relayListParseError + + var humanReadableError: ErrorView.UserPresentableError { + switch self { + case .relayListParseError: + return ErrorView.UserPresentableError( + user_visible_description: NSLocalizedString("Your relay list appears to be broken, so we cannot connect you to your Nostr network.", comment: "Human readable error description for a failure to parse the relay list due to a bad relay list"), + tip: NSLocalizedString("Please contact support for further help.", comment: "Human readable tips for what to do for a failure to find the relay list"), + technical_info: "Relay list could not be parsed." + ) + } + } + } +} diff --git a/damus/Models/NostrNetworkManager/UserRelayListManager.swift b/damus/Models/NostrNetworkManager/UserRelayListManager.swift @@ -0,0 +1,311 @@ +// +// UserRelayListManager.swift +// damus +// +// Created by Daniel D’Aquino on 2025-02-27. +// + +import Foundation +import Combine + +extension NostrNetworkManager { + /// Manages the user's relay list + /// + /// - It can compute the user's current relay list + /// - It can compute the best relay list to connect to + /// - It can edit the user's relay list + class UserRelayListManager { + private var delegate: Delegate + private let pool: RelayPool + private let reader: SubscriptionManager + + private var relayListObserverTask: Task<Void, Never>? = nil + private var walletUpdatesObserverTask: AnyCancellable? = nil + + init(delegate: Delegate, pool: RelayPool, reader: SubscriptionManager) { + self.delegate = delegate + self.pool = pool + self.reader = reader + } + + // MARK: - Computing the relays to connect to + + private func relaysToConnectTo() -> [RelayPool.RelayDescriptor] { + return self.computeRelaysToConnectTo(with: self.getBestEffortRelayList()) + } + + private func computeRelaysToConnectTo(with relayList: NIP65.RelayList) -> [RelayPool.RelayDescriptor] { + let regularRelayDescriptorList = relayList.toRelayDescriptors() + if let nwcWallet = delegate.nwcWallet { + return regularRelayDescriptorList + [.nwc(url: nwcWallet.relay)] + } + return regularRelayDescriptorList + } + + // MARK: - Getting the user's relay list + + /// Gets the "best effort" relay list. + /// + /// It attempts to get a relay list from the user. If one is not available, it uses the default bootstrap list. + /// + /// This is always guaranteed to return a relay list. + func getBestEffortRelayList() -> NIP65.RelayList { + guard let userCurrentRelayList = self.getUserCurrentRelayList() else { + return NIP65.RelayList(relays: delegate.bootstrapRelays) + } + return userCurrentRelayList + } + + /// Gets the user's current relay list. + /// + /// It attempts to get a NIP-65 relay list from the local database, or falls back to a legacy list. + func getUserCurrentRelayList() -> NIP65.RelayList? { + if let latestRelayListEvent = try? self.getLatestNIP65RelayList() { return latestRelayListEvent } + if let latestRelayListEvent = try? self.getLatestKind3RelayList() { return latestRelayListEvent } + if let latestRelayListEvent = try? self.getLatestUserDefaultsRelayList() { return latestRelayListEvent } + return nil + } + + /// Gets the latest NIP-65 relay list from NostrDB. + /// + /// This is `private` because it is part of internal logic. Callers should use the higher level functions. + /// + /// - Returns: The latest NIP-65 relay list object + private func getLatestNIP65RelayList() throws(LoadingError) -> NIP65.RelayList? { + guard let latestRelayListEvent = self.getLatestNIP65RelayListEvent() else { return nil } + guard let list = try? NIP65.RelayList(event: latestRelayListEvent) else { throw .relayListParseError } + return list + } + + /// Gets the latest NIP-65 relay list event from NostrDB. + /// + /// This is `private` because it is part of internal logic. Callers should use the higher level functions. + /// + /// It is recommended to use this function only if the NostrEvent metadata is needed. For cases where only the relay list info is needed, use `getLatestNIP65RelayList` instead. + /// + /// - Returns: The latest NIP-65 relay list NdbNote + private func getLatestNIP65RelayListEvent() -> NdbNote? { + guard let latestRelayListEventId = delegate.latestRelayListEventIdHex else { return nil } + guard let latestRelayListEventId = NoteId(hex: latestRelayListEventId) else { return nil } + return delegate.ndb.lookup_note(latestRelayListEventId)?.unsafeUnownedValue?.to_owned() + } + + /// Gets the latest `kind:3` relay list from NostrDB. + /// + /// This is `private` because it is part of internal logic. Callers should use the higher level functions. + private func getLatestKind3RelayList() throws(LoadingError) -> NIP65.RelayList? { + guard let latestContactListEvent = delegate.latestContactListEvent else { return nil } + guard let legacyContactList = try? NIP65.RelayList.fromLegacyContactList(latestContactListEvent) else { throw .relayListParseError } + return legacyContactList + } + + /// Gets the latest relay list from `UserDefaults` + /// + /// This is `private` because it is part of internal logic. Callers should use the higher level functions. + private func getLatestUserDefaultsRelayList() throws(LoadingError) -> NIP65.RelayList? { + let key = bootstrap_relays_setting_key(pubkey: delegate.keypair.pubkey) + guard let relays = UserDefaults.standard.stringArray(forKey: key) else { return nil } + let relayUrls = relays.compactMap({ RelayURL($0) }) + if relayUrls.count == 0 { return nil } + return NIP65.RelayList(relays: relayUrls) + } + + // MARK: - Getting metadata from the user's relay list + + /// Gets the creation date of the user's current relay list, with preference to NIP-65 relay lists + /// - Returns: The current relay list's creation date + private func getUserCurrentRelayListCreationDate() -> UInt32? { + if let latestNIP65RelayListEvent = self.getLatestNIP65RelayListEvent() { return latestNIP65RelayListEvent.created_at } + if let latestKind3RelayListEvent = delegate.latestContactListEvent { return latestKind3RelayListEvent.created_at } + return nil + } + + // MARK: - Listening to and handling relay updates from the network + + func connect() { + self.load() + + self.relayListObserverTask?.cancel() + self.relayListObserverTask = Task { await self.listenAndHandleRelayUpdates() } + self.walletUpdatesObserverTask?.cancel() + self.walletUpdatesObserverTask = handle_notify(.attached_wallet).sink { _ in self.load() } + } + + func listenAndHandleRelayUpdates() async { + let filter = NostrFilter(kinds: [.relay_list], authors: [delegate.keypair.pubkey]) + for await item in self.reader.subscribe(filters: [filter]) { + switch item { + case .event(borrow: let borrow): // Signature validity already ensured at this point + let currentRelayListCreationDate = self.getUserCurrentRelayListCreationDate() + try? borrow { note in + guard note.pubkey == self.delegate.keypair.pubkey else { return } // Ensure this new list was ours + guard note.createdAt > (currentRelayListCreationDate ?? 0) else { return } // Ensure this is a newer list + guard let relayList = try? NIP65.RelayList(event: note) else { return } // Ensure it is a valid NIP-65 list + + try? self.set(userRelayList: relayList) // Set the validated list + } + case .eose: continue + } + } + } + + // MARK: - Editing the user's relay list + + func upsert(relay: NIP65.RelayList.RelayItem, force: Bool = false, overwriteExisting: Bool = false) 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))) + } + + func insert(relay: NIP65.RelayList.RelayItem, force: Bool = false) 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) + } + + func remove(relayURL: RelayURL, force: Bool = false) 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))) + } + + func set(userRelayList: NIP65.RelayList) 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)) + + 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()) + } + + /// Loads a new relay list into the active relay pool, making sure it matches the specified relay list. + /// + /// - Parameters: + /// - state: The state of the app + /// - newRelayList: The new relay list to be applied + /// + /// + /// ## Implementation notes + /// + /// - 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]) { + let currentRelayList = self.pool.relays.map({ $0.descriptor }) + + var changed = false + let new_relay_filters = load_relay_filters(delegate.keypair.pubkey) == nil + + for index in self.pool.relays.indices { + guard let newDescriptor = newRelayList.first(where: { $0.url == self.pool.relays[index].descriptor.url }) else { continue } + self.pool.relays[index].descriptor.info = newDescriptor.info + // Relay read-write configuration change does not need reconnection to the relay, so we do not set the `changed` flag. + } + + // Working with URL Sets for difference analysis + let currentRelayURLs = Set(currentRelayList.map { $0.url }) + let newRelayURLs = Set(newRelayList.map { $0.url }) + + // Analyzing which relays to add or remove + 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 + } + + // 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 + } + + if changed { + pool.connect() + notify(.relays_changed) + } + } + } +} + +// MARK: - Helper extensions + +fileprivate extension NIP65.RelayList.RelayItem { + func toRelayDescriptor() -> RelayPool.RelayDescriptor { + return RelayPool.RelayDescriptor(url: self.url, info: self.rwConfiguration, variant: .regular) // NIP-65 relays are regular by definition. + } +} + +fileprivate extension NIP65.RelayList { + func toRelayDescriptors() -> [RelayPool.RelayDescriptor] { + return self.relays.values.map({ $0.toRelayDescriptor() }) + } +} + +// MARK: - Helper functions + + +/// Adds a new relay, taking care of other tangential concerns, such as updating the relay model cache, configuring logging, etc +/// +/// ## Implementation notes +/// +/// 1. This function used to be in `HomeModel.swift` and moved here when `UserRelayListManager` was first implemented +/// 2. This is `fileprivate` because only `UserRelayListManager` should be able to manage the user's relay list and apply them to the `RelayPool` +/// +/// - Parameters: +/// - model_cache: The relay model cache, that keeps metadata cached +/// - relay_filters: Relay filters +/// - pool: The relay pool to add this in +/// - 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) + let url = descriptor.url + + let relay_id = url + guard model_cache.model(withURL: url) == nil else { + return + } + + Task.detached(priority: .background) { + guard let meta = try? await fetch_relay_metadata(relay_id: relay_id) else { + return + } + + await MainActor.run { + let model = RelayModel(url, metadata: meta) + model_cache.insert(model: model) + + if logging_enabled { + pool.setLog(model.log, for: relay_id) + } + + // if this is the first time adding filters, we should filter non-paid relays + if new_relay_filters && !meta.is_paid { + relay_filters.insert(timeline: .search, relay_id: relay_id) + } + } + } +} diff --git a/damus/Models/ProfileModel.swift b/damus/Models/ProfileModel.swift @@ -59,10 +59,10 @@ class ProfileModel: ObservableObject, Equatable { func unsubscribe() { print("unsubscribing from profile \(pubkey) with sub_id \(sub_id)") - damus.pool.unsubscribe(sub_id: sub_id) - damus.pool.unsubscribe(sub_id: prof_subid) + damus.nostrNetwork.pool.unsubscribe(sub_id: sub_id) + damus.nostrNetwork.pool.unsubscribe(sub_id: prof_subid) if pubkey != damus.pubkey { - damus.pool.unsubscribe(sub_id: conversations_subid) + damus.nostrNetwork.pool.unsubscribe(sub_id: conversations_subid) } } @@ -77,8 +77,8 @@ class ProfileModel: ObservableObject, Equatable { print("subscribing to textlike events from profile \(pubkey) with sub_id \(sub_id)") //print_filters(relay_id: "profile", filters: [[text_filter], [profile_filter]]) - damus.pool.subscribe(sub_id: sub_id, filters: [text_filter], handler: handle_event) - damus.pool.subscribe(sub_id: prof_subid, filters: [profile_filter], handler: handle_event) + damus.nostrNetwork.pool.subscribe(sub_id: sub_id, filters: [text_filter], handler: handle_event) + damus.nostrNetwork.pool.subscribe(sub_id: prof_subid, filters: [profile_filter], handler: handle_event) subscribe_to_conversations() } @@ -94,7 +94,7 @@ class ProfileModel: ObservableObject, Equatable { let conversations_filter_them = NostrFilter(kinds: conversation_kinds, pubkeys: [damus.pubkey], limit: limit, authors: [pubkey]) let conversations_filter_us = NostrFilter(kinds: conversation_kinds, pubkeys: [pubkey], limit: limit, authors: [damus.pubkey]) print("subscribing to conversation events from and to profile \(pubkey) with sub_id \(conversations_subid)") - damus.pool.subscribe(sub_id: conversations_subid, filters: [conversations_filter_them, conversations_filter_us], handler: handle_event) + damus.nostrNetwork.pool.subscribe(sub_id: conversations_subid, filters: [conversations_filter_them, conversations_filter_us], handler: handle_event) } func handle_profile_contact_event(_ ev: NostrEvent) { @@ -200,11 +200,11 @@ class ProfileModel: ObservableObject, Equatable { var profile_filter = NostrFilter(kinds: [.contacts]) profile_filter.authors = [pubkey] - damus.pool.subscribe(sub_id: findRelay_subid, filters: [profile_filter], handler: findRelaysHandler) + damus.nostrNetwork.pool.subscribe(sub_id: findRelay_subid, filters: [profile_filter], handler: findRelaysHandler) } func unsubscribeFindRelays() { - damus.pool.unsubscribe(sub_id: findRelay_subid) + damus.nostrNetwork.pool.unsubscribe(sub_id: findRelay_subid) } func getCappedRelayStrings() -> [String] { diff --git a/damus/Models/SearchHomeModel.swift b/damus/Models/SearchHomeModel.swift @@ -41,13 +41,13 @@ class SearchHomeModel: ObservableObject { func subscribe() { loading = true - let to_relays = determine_to_relays(pool: damus_state.pool, filters: damus_state.relay_filters) - damus_state.pool.subscribe(sub_id: base_subid, filters: [get_base_filter()], handler: handle_event, to: to_relays) + let to_relays = determine_to_relays(pool: damus_state.nostrNetwork.pool, filters: damus_state.relay_filters) + damus_state.nostrNetwork.pool.subscribe(sub_id: base_subid, filters: [get_base_filter()], handler: handle_event, to: to_relays) } func unsubscribe(to: RelayURL? = nil) { loading = false - damus_state.pool.unsubscribe(sub_id: base_subid, to: to.map { [$0] }) + damus_state.nostrNetwork.pool.unsubscribe(sub_id: base_subid, to: to.map { [$0] }) } func handle_event(relay_id: RelayURL, conn_ev: NostrConnectionEvent) { @@ -140,7 +140,7 @@ func load_profiles<Y>(context: String, profiles_subid: String, relay_id: RelayUR let filter = NostrFilter(kinds: [.metadata], authors: authors) - damus_state.pool.subscribe_to(sub_id: profiles_subid, filters: [filter], to: [relay_id]) { rid, conn_ev in + damus_state.nostrNetwork.pool.subscribe_to(sub_id: profiles_subid, filters: [filter], to: [relay_id]) { rid, conn_ev in let now = UInt64(Date.now.timeIntervalSince1970) switch conn_ev { @@ -156,7 +156,7 @@ func load_profiles<Y>(context: String, profiles_subid: String, relay_id: RelayUR } case .eose: print("load_profiles[\(context)]: done loading \(authors.count) profiles from \(relay_id)") - damus_state.pool.unsubscribe(sub_id: profiles_subid, to: [relay_id]) + damus_state.nostrNetwork.pool.unsubscribe(sub_id: profiles_subid, to: [relay_id]) case .ok: break case .notice: diff --git a/damus/Models/SearchModel.swift b/damus/Models/SearchModel.swift @@ -41,13 +41,13 @@ class SearchModel: ObservableObject { //likes_filter.ids = ref_events.referenced_ids! print("subscribing to search '\(search)' with sub_id \(sub_id)") - state.pool.register_handler(sub_id: sub_id, handler: handle_event) + state.nostrNetwork.pool.register_handler(sub_id: sub_id, handler: handle_event) loading = true - state.pool.send(.subscribe(.init(filters: [search], sub_id: sub_id))) + state.nostrNetwork.pool.send(.subscribe(.init(filters: [search], sub_id: sub_id))) } func unsubscribe() { - state.pool.unsubscribe(sub_id: sub_id) + state.nostrNetwork.pool.unsubscribe(sub_id: sub_id) loading = false print("unsubscribing from search '\(search)' with sub_id \(sub_id)") } @@ -67,7 +67,7 @@ class SearchModel: ObservableObject { } func handle_event(relay_id: RelayURL, ev: NostrConnectionEvent) { - let (sub_id, done) = handle_subid_event(pool: state.pool, relay_id: relay_id, ev: ev) { sub_id, ev in + let (sub_id, done) = handle_subid_event(pool: state.nostrNetwork.pool, relay_id: relay_id, ev: ev) { sub_id, ev in if ev.is_textlike && ev.should_show_event { self.add_event(ev) } diff --git a/damus/Models/ThreadModel.swift b/damus/Models/ThreadModel.swift @@ -88,12 +88,12 @@ class ThreadModel: ObservableObject { /// Unsubscribe from events in the relay pool. Call this when unloading the view func unsubscribe() { - self.damus_state.pool.remove_handler(sub_id: base_subid) - self.damus_state.pool.remove_handler(sub_id: meta_subid) - self.damus_state.pool.remove_handler(sub_id: profiles_subid) - self.damus_state.pool.unsubscribe(sub_id: base_subid) - self.damus_state.pool.unsubscribe(sub_id: meta_subid) - self.damus_state.pool.unsubscribe(sub_id: profiles_subid) + self.damus_state.nostrNetwork.pool.remove_handler(sub_id: base_subid) + self.damus_state.nostrNetwork.pool.remove_handler(sub_id: meta_subid) + self.damus_state.nostrNetwork.pool.remove_handler(sub_id: profiles_subid) + self.damus_state.nostrNetwork.pool.unsubscribe(sub_id: base_subid) + self.damus_state.nostrNetwork.pool.unsubscribe(sub_id: meta_subid) + self.damus_state.nostrNetwork.pool.unsubscribe(sub_id: profiles_subid) Log.info("unsubscribing to thread %s with sub_id %s", for: .render, original_event.id.hex(), base_subid) } @@ -129,8 +129,8 @@ class ThreadModel: ObservableObject { let meta_filters = [meta_events, quote_events] Log.info("subscribing to thread %s with sub_id %s", for: .render, original_event.id.hex(), base_subid) - damus_state.pool.subscribe(sub_id: base_subid, filters: base_filters, handler: handle_event) - damus_state.pool.subscribe(sub_id: meta_subid, filters: meta_filters, handler: handle_event) + damus_state.nostrNetwork.pool.subscribe(sub_id: base_subid, filters: base_filters, handler: handle_event) + damus_state.nostrNetwork.pool.subscribe(sub_id: meta_subid, filters: meta_filters, handler: handle_event) } /// Adds an event to this thread. @@ -176,7 +176,7 @@ class ThreadModel: ObservableObject { /// Marked as private because it is this class' responsibility to load events, not the view's. Simplify the interface @MainActor private func handle_event(relay_id: RelayURL, ev: NostrConnectionEvent) { - let (sub_id, done) = handle_subid_event(pool: damus_state.pool, relay_id: relay_id, ev: ev) { sid, ev in + let (sub_id, done) = handle_subid_event(pool: damus_state.nostrNetwork.pool, relay_id: relay_id, ev: ev) { sid, ev in guard subids.contains(sid) else { return } diff --git a/damus/Models/ZapsModel.swift b/damus/Models/ZapsModel.swift @@ -31,11 +31,11 @@ class ZapsModel: ObservableObject { case .note(let note_target): filter.referenced_ids = [note_target.note_id] } - state.pool.subscribe(sub_id: zaps_subid, filters: [filter], handler: handle_event) + state.nostrNetwork.pool.subscribe(sub_id: zaps_subid, filters: [filter], handler: handle_event) } func unsubscribe() { - state.pool.unsubscribe(sub_id: zaps_subid) + state.nostrNetwork.pool.unsubscribe(sub_id: zaps_subid) } @MainActor diff --git a/damus/Nostr/Relay.swift b/damus/Nostr/Relay.swift @@ -38,10 +38,10 @@ extension RelayPool { /// Describes a relay for use in `RelayPool` public struct RelayDescriptor { let url: RelayURL - var info: LegacyKind3RelayRWConfiguration + var info: NIP65.RelayList.RelayItem.RWConfiguration let variant: RelayVariant - init(url: RelayURL, info: LegacyKind3RelayRWConfiguration, variant: RelayVariant = .regular) { + init(url: RelayURL, info: NIP65.RelayList.RelayItem.RWConfiguration, variant: RelayVariant = .regular) { self.url = url self.info = info self.variant = variant @@ -59,7 +59,7 @@ extension RelayPool { } static func nwc(url: RelayURL) -> RelayDescriptor { - return RelayDescriptor(url: url, info: .rw, variant: .nwc) + return RelayDescriptor(url: url, info: .readWrite, variant: .nwc) } } } diff --git a/damus/Nostr/RelayPool.swift b/damus/Nostr/RelayPool.swift @@ -26,7 +26,7 @@ struct SeenEvent: Hashable { /// Establishes and manages connections and subscriptions to a list of relays. class RelayPool { - var relays: [Relay] = [] + private(set) var relays: [Relay] = [] var handlers: [RelayHandler] = [] var request_queue: [QueuedRequest] = [] var seen: Set<SeenEvent> = Set() @@ -124,7 +124,7 @@ class RelayPool { } } - func add_relay(_ desc: RelayDescriptor) throws { + func add_relay(_ desc: RelayDescriptor) throws(RelayError) { let relay_id = desc.url if get_relay(relay_id) != nil { throw RelayError.RelayAlreadyExists @@ -306,11 +306,11 @@ class RelayPool { self.send_raw_to_local_ndb(req) // Always send Nostr events and data to NostrDB for a local copy for relay in relays { - if req.is_read && !(relay.descriptor.info.read ?? true) { + if req.is_read && !(relay.descriptor.info.canRead) { continue // Do not send read requests to relays that are not READ relays } - if req.is_write && !(relay.descriptor.info.write ?? true) { + if req.is_write && !(relay.descriptor.info.canWrite) { continue // Do not send write requests to relays that are not WRITE relays } @@ -414,7 +414,7 @@ class RelayPool { } func add_rw_relay(_ pool: RelayPool, _ url: RelayURL) { - try? pool.add_relay(RelayPool.RelayDescriptor(url: url, info: .rw)) + try? pool.add_relay(RelayPool.RelayDescriptor(url: url, info: .readWrite)) } diff --git a/damus/TestData.swift b/damus/TestData.swift @@ -83,8 +83,7 @@ var test_damus_state: DamusState = ({ let our_pubkey = test_pubkey let pool = RelayPool(ndb: ndb) let settings = UserSettingsStore() - let damus = DamusState(pool: pool, - keypair: test_keypair, + let damus = DamusState(keypair: test_keypair, likes: .init(our_pubkey: our_pubkey), boosts: .init(our_pubkey: our_pubkey), contacts: .init(our_pubkey: our_pubkey), @@ -100,8 +99,6 @@ var test_damus_state: DamusState = ({ drafts: .init(), events: .init(ndb: ndb), bookmarks: .init(pubkey: our_pubkey), - postbox: .init(pool: pool), - bootstrap_relays: .init(), replies: .init(our_pubkey: our_pubkey), wallet: .init(settings: settings), nav: .init(), diff --git a/damus/Util/PostBox.swift b/damus/Util/PostBox.swift @@ -54,7 +54,7 @@ enum CancelSendErr { } class PostBox { - let pool: RelayPool + private let pool: RelayPool var events: [NoteId: PostedEvent] init(pool: RelayPool) { diff --git a/damus/Util/Router.swift b/damus/Util/Router.swift @@ -126,7 +126,7 @@ enum Route: Hashable { case .FollowersYouKnow(let friendedFollowers, let followers): FollowersYouKnowView(damus_state: damusState, friended_followers: friendedFollowers, followers: followers) case .Script(let load_model): - LoadScript(pool: damusState.pool, model: load_model) + LoadScript(pool: damusState.nostrNetwork.pool, model: load_model) } } diff --git a/damus/Views/ActionBar/EventActionBar.swift b/damus/Views/ActionBar/EventActionBar.swift @@ -270,7 +270,7 @@ struct EventActionBar: View { generator.impactOccurred() - damus_state.postbox.send(like_ev) + damus_state.nostrNetwork.postbox.send(like_ev) } // MARK: Helper structures diff --git a/damus/Views/ActionBar/RepostAction.swift b/damus/Views/ActionBar/RepostAction.swift @@ -25,7 +25,7 @@ struct RepostAction: View { return } - damus_state.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/Views/AddRelayView.swift b/damus/Views/AddRelayView.swift @@ -15,6 +15,8 @@ struct AddRelayView: View { @Environment(\.dismiss) var dismiss + typealias UpdateError = NostrNetworkManager.UserRelayListManager.UpdateError + var body: some View { VStack { Text("Add relay", comment: "Title text to indicate user to an add a relay.") @@ -82,38 +84,21 @@ struct AddRelayView: View { new_relay = "wss://" + new_relay } - guard let url = RelayURL(new_relay), - let ev = state.contacts.event, - let keypair = state.keypair.to_full() else { + 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 } - let info = LegacyKind3RelayRWConfiguration.rw - let descriptor = RelayPool.RelayDescriptor(url: url, info: info) - do { - try state.pool.add_relay(descriptor) + try state.nostrNetwork.userRelayList.insert(relay: NIP65.RelayList.RelayItem(url: url, rwConfiguration: .readWrite)) relayAddErrorTitle = nil // Clear error title relayAddErrorMessage = nil // Clear error message - } catch RelayPool.RelayError.RelayAlreadyExists { - relayAddErrorTitle = NSLocalizedString("Duplicate relay", comment: "Title of the duplicate relay error message.") - relayAddErrorMessage = NSLocalizedString("The relay you are trying to add is already added.\nYou're all set!", comment: "An error message that appears when the user attempts to add a relay that has already been added.") - return - } catch { - return } - - state.pool.connect(to: [url]) - - if let new_ev = add_relay(ev: ev, keypair: keypair, current_relays: state.pool.our_descriptors, relay: url, info: info) { - process_contact_event(state: state, ev: ev) - - state.pool.send(.event(new_ev)) + catch { + present_sheet(.error(self.humanReadableError(for: error))) } - if let relay_metadata = make_relay_metadata(relays: state.pool.our_descriptors, keypair: keypair) { - state.postbox.send(relay_metadata) - } new_relay = "" this_app.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil) @@ -134,6 +119,17 @@ struct AddRelayView: View { } .padding() } + + func humanReadableError(for error: any Error) -> ErrorView.UserPresentableError { + guard let error = error as? UpdateError else { + return .init( + user_visible_description: NSLocalizedString("An unknown error occurred while adding a relay.", comment: "Title of an unknown relay error message."), + tip: NSLocalizedString("Please contact support.", comment: "Tip for an unknown relay error message."), + technical_info: error.localizedDescription + ) + } + return error.humanReadableError + } } // TODO diff --git a/damus/Views/Chat/ChatEventView.swift b/damus/Views/Chat/ChatEventView.swift @@ -244,7 +244,7 @@ struct ChatEventView: View { let generator = UIImpactFeedbackGenerator(style: .medium) generator.impactOccurred() - damus_state.postbox.send(like_ev) + damus_state.nostrNetwork.postbox.send(like_ev) } var action_bar: some View { diff --git a/damus/Views/ConfigView.swift b/damus/Views/ConfigView.swift @@ -182,7 +182,7 @@ struct ConfigView: View { let ev = created_deleted_account_profile(keypair: keypair) else { return } - state.postbox.send(ev) + state.nostrNetwork.postbox.send(ev) logout(state) } } diff --git a/damus/Views/DMChatView.swift b/damus/Views/DMChatView.swift @@ -138,7 +138,7 @@ struct DMChatView: View, KeyboardReadable { dms.draft = "" - damus_state.postbox.send(dm) + 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/Views/Events/EventLoaderView.swift b/damus/Views/Events/EventLoaderView.swift @@ -24,12 +24,12 @@ struct EventLoaderView<Content: View>: View { } func unsubscribe() { - damus_state.pool.unsubscribe(sub_id: subscription_uuid) + damus_state.nostrNetwork.pool.unsubscribe(sub_id: subscription_uuid) } func subscribe(filters: [NostrFilter]) { - damus_state.pool.register_handler(sub_id: subscription_uuid, handler: handle_event) - damus_state.pool.send(.subscribe(.init(filters: filters, sub_id: subscription_uuid))) + damus_state.nostrNetwork.pool.register_handler(sub_id: subscription_uuid, handler: handle_event) + damus_state.nostrNetwork.pool.send(.subscribe(.init(filters: filters, sub_id: subscription_uuid))) } func handle_event(relay_id: RelayURL, ev: NostrConnectionEvent) { diff --git a/damus/Views/Events/EventMenu.swift b/damus/Views/Events/EventMenu.swift @@ -113,7 +113,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.postbox.send(new_mutelist_ev) + damus_state.nostrNetwork.postbox.send(new_mutelist_ev) } let muted = damus_state.mutelist_manager.is_event_muted(event) isMutedThread = muted diff --git a/damus/Views/Muting/AddMuteItemView.swift b/damus/Views/Muting/AddMuteItemView.swift @@ -87,7 +87,7 @@ struct AddMuteItemView: View { } state.mutelist_manager.set_mutelist(mutelist) - state.postbox.send(mutelist) + state.nostrNetwork.postbox.send(mutelist) } new_text = "" diff --git a/damus/Views/Muting/MutelistView.swift b/damus/Views/Muting/MutelistView.swift @@ -30,7 +30,7 @@ struct MutelistView: View { } damus_state.mutelist_manager.set_mutelist(new_ev) - damus_state.postbox.send(new_ev) + 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/Views/Onboarding/SuggestedUsersViewModel.swift b/damus/Views/Onboarding/SuggestedUsersViewModel.swift @@ -77,7 +77,7 @@ class SuggestedUsersViewModel: ObservableObject { private func subscribeToSuggestedProfiles(pubkeys: [Pubkey]) { let filter = NostrFilter(kinds: [.metadata], authors: pubkeys) - damus_state.pool.subscribe(sub_id: sub_id, filters: [filter], handler: handle_event) + damus_state.nostrNetwork.pool.subscribe(sub_id: sub_id, filters: [filter], handler: handle_event) } func handle_event(relay_id: RelayURL, ev: NostrConnectionEvent) { diff --git a/damus/Views/Profile/EditMetadataView.swift b/damus/Views/Profile/EditMetadataView.swift @@ -65,7 +65,7 @@ struct EditMetadataView: View { return } - damus_state.postbox.send(metadata_ev) + damus_state.nostrNetwork.postbox.send(metadata_ev) } func is_ln_valid(ln: String) -> Bool { diff --git a/damus/Views/Profile/ProfileView.swift b/damus/Views/Profile/ProfileView.swift @@ -219,7 +219,7 @@ struct ProfileView: View { } damus_state.mutelist_manager.set_mutelist(new_ev) - damus_state.postbox.send(new_ev) + damus_state.nostrNetwork.postbox.send(new_ev) } } else { Button(NSLocalizedString("Mute", comment: "Button to mute a profile"), role: .destructive) { diff --git a/damus/Views/RelayFilterView.swift b/damus/Views/RelayFilterView.swift @@ -15,11 +15,11 @@ struct RelayFilterView: View { self.state = state self.timeline = timeline - //_relays = State(initialValue: state.pool.descriptors) + //_relays = State(initialValue: state.networkManager.pool.descriptors) } var relays: [RelayPool.RelayDescriptor] { - return state.pool.our_descriptors + return state.nostrNetwork.pool.our_descriptors } var body: some View { diff --git a/damus/Views/Relays/RelayConfigView.swift b/damus/Views/Relays/RelayConfigView.swift @@ -32,7 +32,7 @@ struct RelayConfigView: View { init(state: DamusState) { self.state = state - _relays = State(initialValue: state.pool.our_descriptors) + _relays = State(initialValue: state.nostrNetwork.pool.our_descriptors) UITabBar.appearance().isHidden = true } @@ -40,7 +40,7 @@ struct RelayConfigView: View { let rs: [RelayPool.RelayDescriptor] = [] let recommended_relay_addresses = get_default_bootstrap_relays() return recommended_relay_addresses.reduce(into: rs) { xs, x in - xs.append(RelayPool.RelayDescriptor(url: x, info: .rw)) + xs.append(RelayPool.RelayDescriptor(url: x, info: .readWrite)) } } @@ -98,7 +98,7 @@ struct RelayConfigView: View { } } .onReceive(handle_notify(.relays_changed)) { _ in - self.relays = state.pool.our_descriptors + self.relays = state.nostrNetwork.pool.our_descriptors } .onAppear { notify(.display_tabbar(false)) diff --git a/damus/Views/Relays/RelayDetailView.swift b/damus/Views/Relays/RelayDetailView.swift @@ -25,32 +25,12 @@ struct RelayDetailView: View { } func check_connection() -> Bool { - for relay in state.pool.relays { - if relay.id == self.relay { - return true - } - } - return false + return state.nostrNetwork.userRelayList.getUserCurrentRelayList()?.relays.keys.contains(self.relay) == true } func RemoveRelayButton(_ keypair: FullKeypair) -> some View { Button(action: { - guard let ev = state.contacts.event else { - return - } - - let descriptors = state.pool.our_descriptors - guard let new_ev = remove_relay( ev: ev, current_relays: descriptors, keypair: keypair, relay: relay) else { - return - } - - process_contact_event(state: state, ev: new_ev) - state.postbox.send(new_ev) - - if let relay_metadata = make_relay_metadata(relays: state.pool.our_descriptors, keypair: keypair) { - state.postbox.send(relay_metadata) - } - dismiss() + self.removeRelay() }) { HStack { Text("Disconnect", comment: "Button to disconnect from the relay.") @@ -63,19 +43,7 @@ struct RelayDetailView: View { func ConnectRelayButton(_ keypair: FullKeypair) -> some View { Button(action: { - guard let ev_before_add = state.contacts.event else { - return - } - guard let ev_after_add = add_relay(ev: ev_before_add, keypair: keypair, current_relays: state.pool.our_descriptors, relay: relay, info: .rw) else { - return - } - process_contact_event(state: state, ev: ev_after_add) - state.postbox.send(ev_after_add) - - if let relay_metadata = make_relay_metadata(relays: state.pool.our_descriptors, keypair: keypair) { - state.postbox.send(relay_metadata) - } - dismiss() + self.connectRelay() }) { HStack { Text("Connect", comment: "Button to connect to the relay.") @@ -209,12 +177,32 @@ struct RelayDetailView: View { } private var relay_object: RelayPool.Relay? { - state.pool.get_relay(relay) + state.nostrNetwork.pool.get_relay(relay) } private var relay_connection: RelayConnection? { relay_object?.connection } + + func removeRelay() { + do { + try state.nostrNetwork.userRelayList.remove(relayURL: self.relay) + dismiss() + } + catch { + present_sheet(.error(error.humanReadableError)) + } + } + + func connectRelay() { + do { + try state.nostrNetwork.userRelayList.insert(relay: NIP65.RelayList.RelayItem(url: relay, rwConfiguration: .readWrite)) + dismiss() + } + catch { + present_sheet(.error(error.humanReadableError)) + } + } } struct RelayDetailView_Previews: PreviewProvider { diff --git a/damus/Views/Relays/RelayStatusView.swift b/damus/Views/Relays/RelayStatusView.swift @@ -56,7 +56,7 @@ struct RelayStatusView: View { struct RelayStatusView_Previews: PreviewProvider { static var previews: some View { - let connection = test_damus_state.pool.get_relay(RelayURL("wss://relay.damus.io")!)!.connection + let connection = test_damus_state.nostrNetwork.pool.get_relay(RelayURL("wss://relay.damus.io")!)!.connection RelayStatusView(connection: connection) } } diff --git a/damus/Views/Relays/RelayToggle.swift b/damus/Views/Relays/RelayToggle.swift @@ -36,7 +36,7 @@ struct RelayToggle: View { } private var relay_connection: RelayConnection? { - state.pool.get_relay(relay_id)?.connection + state.nostrNetwork.pool.get_relay(relay_id)?.connection } } diff --git a/damus/Views/Relays/RelayView.swift b/damus/Views/Relays/RelayView.swift @@ -22,7 +22,7 @@ struct RelayView: View { self.recommended = recommended self.model_cache = state.relay_model_cache _showActionButtons = showActionButtons - let relay_state = RelayView.get_relay_state(pool: state.pool, relay: relay) + let relay_state = RelayView.get_relay_state(pool: state.nostrNetwork.pool, relay: relay) self._relay_state = State(initialValue: relay_state) } @@ -80,7 +80,7 @@ struct RelayView: View { AddButton(keypair: keypair) } else { Button(action: { - remove_action(privkey: keypair.privkey) + Task { await remove_action(privkey: keypair.privkey) } }) { Text("Added", comment: "Button to show relay server is already added to list.") .font(.caption) @@ -105,7 +105,7 @@ struct RelayView: View { .contentShape(Rectangle()) } .onReceive(handle_notify(.relays_changed)) { _ in - self.relay_state = RelayView.get_relay_state(pool: state.pool, relay: self.relay) + self.relay_state = RelayView.get_relay_state(pool: state.nostrNetwork.pool, relay: self.relay) } .onTapGesture { state.nav.push(route: Route.RelayDetail(relay: relay, metadata: model_cache.model(with_relay_id: relay)?.metadata)) @@ -113,46 +113,30 @@ struct RelayView: View { } private var relay_connection: RelayConnection? { - state.pool.get_relay(relay)?.connection + state.nostrNetwork.pool.get_relay(relay)?.connection } - func add_action(keypair: FullKeypair) { - guard let ev_before_add = state.contacts.event else { - return + func add_action(keypair: FullKeypair) async { + do { + try await state.nostrNetwork.userRelayList.insert(relay: NIP65.RelayList.RelayItem(url: relay, rwConfiguration: .readWrite)) } - guard let ev_after_add = add_relay(ev: ev_before_add, keypair: keypair, current_relays: state.pool.our_descriptors, relay: relay, info: .rw) else { - return - } - process_contact_event(state: state, ev: ev_after_add) - state.postbox.send(ev_after_add) - - if let relay_metadata = make_relay_metadata(relays: state.pool.our_descriptors, keypair: keypair) { - state.postbox.send(relay_metadata) + catch { + present_sheet(.error(error.humanReadableError)) } } - func remove_action(privkey: Privkey) { - guard let ev = state.contacts.event else { - return - } - - let descriptors = state.pool.our_descriptors - guard let keypair = state.keypair.to_full(), - let new_ev = remove_relay(ev: ev, current_relays: descriptors, keypair: keypair, relay: relay) else { - return + func remove_action(privkey: Privkey) async { + do { + try await state.nostrNetwork.userRelayList.remove(relayURL: relay) } - - process_contact_event(state: state, ev: new_ev) - state.postbox.send(new_ev) - - if let relay_metadata = make_relay_metadata(relays: state.pool.our_descriptors, keypair: keypair) { - state.postbox.send(relay_metadata) + catch { + present_sheet(.error(error.humanReadableError)) } } func AddButton(keypair: FullKeypair) -> some View { Button(action: { - add_action(keypair: keypair) + Task { await add_action(keypair: keypair) } }) { Text("Add", comment: "Button to add relay server to list.") .font(.caption) @@ -170,7 +154,7 @@ struct RelayView: View { func RemoveButton(privkey: Privkey, showText: Bool) -> some View { Button(action: { - remove_action(privkey: privkey) + Task { await remove_action(privkey: privkey) } }) { if showText { Text("Disconnect", comment: "Button to disconnect from a relay server.") diff --git a/damus/Views/ReportView.swift b/damus/Views/ReportView.swift @@ -132,9 +132,9 @@ struct ReportView_Previews: PreviewProvider { let ds = test_damus_state VStack { - ReportView(postbox: ds.postbox, target: ReportTarget.user(test_pubkey), keypair: test_keypair.to_full()!) + ReportView(postbox: ds.nostrNetwork.postbox, target: ReportTarget.user(test_pubkey), keypair: test_keypair.to_full()!) - ReportView(postbox: ds.postbox, target: ReportTarget.user(test_pubkey), keypair: test_keypair.to_full()!, report_sent: true, report_id: "report_id") + ReportView(postbox: ds.nostrNetwork.postbox, target: ReportTarget.user(test_pubkey), keypair: test_keypair.to_full()!, report_sent: true, report_id: "report_id") } } diff --git a/damus/Views/SaveKeysView.swift b/damus/Views/SaveKeysView.swift @@ -20,10 +20,12 @@ struct SaveKeysView: View { @FocusState var privkey_focused: Bool let first_contact_event: NdbNote? + let first_relay_list_event: NdbNote? init(account: CreateAccountModel) { self.account = account self.first_contact_event = make_first_contact_event(keypair: account.keypair) + self.first_relay_list_event = NIP65.RelayList(relays: get_default_bootstrap_relays()).toNostrEvent(keypair: account.full_keypair) } var body: some View { @@ -128,8 +130,12 @@ struct SaveKeysView: View { 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 } + guard let first_relay_list_event else { + error = NSLocalizedString("Could not create your initial relay list. 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 relay list failed to be created.") + return + } // Save contact list to storage right away so that we don't need to depend on the network to complete this important step - self.save_to_storage(first_contact_event: first_contact_event, for: account) + self.save_to_storage(first_contact_event: first_contact_event, first_relay_list_event: first_relay_list_event, for: account) let bootstrap_relays = load_bootstrap_relays(pubkey: account.pubkey) for relay in bootstrap_relays { @@ -143,13 +149,15 @@ struct SaveKeysView: View { self.pool.connect() } - func save_to_storage(first_contact_event: NdbNote, for account: CreateAccountModel) { + func save_to_storage(first_contact_event: NdbNote, first_relay_list_event: NdbNote, for account: CreateAccountModel) { // Send to NostrDB so that we have a local copy in storage self.pool.send_raw_to_local_ndb(.typical(.event(first_contact_event))) + self.pool.send_raw_to_local_ndb(.typical(.event(first_relay_list_event))) // Save the ID to user settings so that we can easily find it later. let settings = UserSettingsStore.globally_load_for(pubkey: account.pubkey) settings.latest_contact_event_id_hex = first_contact_event.id.hex() + settings.latestRelayListEventIdHex = first_relay_list_event.id.hex() } func handle_event(relay: RelayURL, ev: NostrConnectionEvent) { @@ -168,6 +176,10 @@ struct SaveKeysView: View { self.pool.send(.event(first_contact_event)) } + if let first_relay_list_event { + self.pool.send(.event(first_relay_list_event)) + } + do { try save_keypair(pubkey: account.pubkey, privkey: account.privkey) notify(.login(account.keypair)) diff --git a/damus/Views/SearchView.swift b/damus/Views/SearchView.swift @@ -69,7 +69,7 @@ struct SearchView: View { } appstate.mutelist_manager.set_mutelist(mutelist) - appstate.postbox.send(mutelist) + 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.postbox.send(mutelist) + appstate.nostrNetwork.postbox.send(mutelist) } var described_search: DescribedSearch { diff --git a/damus/Views/Settings/FirstAidSettingsView.swift b/damus/Views/Settings/FirstAidSettingsView.swift @@ -65,7 +65,7 @@ struct FirstAidSettingsView: View { reset_contact_list_state = .error(NSLocalizedString("An unexpected error happened while trying to create the new contact list. Please contact support.", comment: "Error message for a failed contact list reset operation")) return } - damus_state.pool.send(.event(new_contact_list_event)) + damus_state.nostrNetwork.pool.send(.event(new_contact_list_event)) reset_contact_list_state = .completed } } diff --git a/damus/Views/UserRelaysView.swift b/damus/Views/UserRelaysView.swift @@ -16,13 +16,13 @@ struct UserRelaysView: View { init(state: DamusState, relays: [RelayURL]) { self.state = state self.relays = relays - let relay_state = UserRelaysView.make_relay_state(pool: state.pool, relays: relays) + let relay_state = UserRelaysView.make_relay_state(state: state, relays: relays) self._relay_state = State(initialValue: relay_state) } - static func make_relay_state(pool: RelayPool, relays: [RelayURL]) -> [(RelayURL, Bool)] { + static func make_relay_state(state: DamusState, relays: [RelayURL]) -> [(RelayURL, Bool)] { return relays.map({ r in - return (r, pool.get_relay(r) == nil) + return (r, state.nostrNetwork.pool.get_relay(r) == nil) }).sorted { (a, b) in a.0 < b.0 } } diff --git a/damus/Views/Wallet/NWCSettings.swift b/damus/Views/Wallet/NWCSettings.swift @@ -173,7 +173,7 @@ struct NWCSettings: View { guard let meta = make_metadata_event(keypair: keypair, metadata: prof) else { return } - damus_state.postbox.send(meta) + damus_state.nostrNetwork.postbox.send(meta) } } diff --git a/damus/Views/Wallet/WalletView.swift b/damus/Views/Wallet/WalletView.swift @@ -84,8 +84,8 @@ struct WalletView: View { let delay = 0.0 // We don't need a delay when fetching a transaction list or balance - WalletConnect.request_transaction_list(url: nwc, pool: damus_state.pool, post: damus_state.postbox, delay: delay, on_flush: flusher) - WalletConnect.request_balance_information(url: nwc, pool: damus_state.pool, post: damus_state.postbox, delay: delay, on_flush: flusher) + WalletConnect.request_transaction_list(url: nwc, pool: damus_state.nostrNetwork.pool, post: damus_state.nostrNetwork.postbox, delay: delay, on_flush: flusher) + WalletConnect.request_balance_information(url: nwc, pool: damus_state.nostrNetwork.pool, post: damus_state.nostrNetwork.postbox, delay: delay, on_flush: flusher) return } } diff --git a/damusTests/AuthIntegrationTests.swift b/damusTests/AuthIntegrationTests.swift @@ -98,7 +98,7 @@ final class AuthIntegrationTests: XCTestCase { sent_messages.append(str) } XCTAssertEqual(pool.relays.count, 0) - let relay_descriptor = RelayPool.RelayDescriptor.init(url: relay_url, info: .rw) + let relay_descriptor = RelayPool.RelayDescriptor.init(url: relay_url, info: .readWrite) try! pool.add_relay(relay_descriptor) XCTAssertEqual(pool.relays.count, 1) let connection_expectation = XCTestExpectation(description: "Waiting for connection") @@ -142,7 +142,7 @@ final class AuthIntegrationTests: XCTestCase { sent_messages.append(str) } XCTAssertEqual(pool.relays.count, 0) - let relay_descriptor = RelayPool.RelayDescriptor.init(url: relay_url, info: .rw) + let relay_descriptor = RelayPool.RelayDescriptor.init(url: relay_url, info: .readWrite) try! pool.add_relay(relay_descriptor) XCTAssertEqual(pool.relays.count, 1) let connection_expectation = XCTestExpectation(description: "Waiting for connection") diff --git a/damusTests/Mocking/MockDamusState.swift b/damusTests/Mocking/MockDamusState.swift @@ -27,8 +27,7 @@ func generate_test_damus_state( }() let mutelist_manager = MutelistManager(user_keypair: test_keypair) - let damus = DamusState(pool: pool, - keypair: test_keypair, + let damus = DamusState(keypair: test_keypair, likes: .init(our_pubkey: our_pubkey), boosts: .init(our_pubkey: our_pubkey), contacts: .init(our_pubkey: our_pubkey), mutelist_manager: mutelist_manager, @@ -43,8 +42,6 @@ func generate_test_damus_state( drafts: .init(), events: .init(ndb: ndb), bookmarks: .init(pubkey: our_pubkey), - postbox: .init(pool: pool), - bootstrap_relays: .init(), replies: .init(our_pubkey: our_pubkey), wallet: .init(settings: settings), nav: .init(), diff --git a/damusTests/MutingTests.swift b/damusTests/MutingTests.swift @@ -35,7 +35,7 @@ final class MutingTests: XCTestCase { } test_damus_state.mutelist_manager.set_mutelist(mutelist) - test_damus_state.postbox.send(mutelist) + test_damus_state.nostrNetwork.postbox.send(mutelist) XCTAssert(test_damus_state.mutelist_manager.is_event_muted(spammy_test_note)) XCTAssertFalse(test_damus_state.mutelist_manager.is_event_muted(test_note)) diff --git a/damusTests/RequestTests.swift b/damusTests/RequestTests.swift @@ -20,7 +20,7 @@ final class RequestTests: XCTestCase { func testMakeAuthRequest() { let challenge_string = "8bc847dd-f2f6-4b3a-9c8a-71776ad9b071" let url = RelayURL("wss://example.com")! - let relayDescriptor = RelayPool.RelayDescriptor(url: url, info: .rw) + let relayDescriptor = RelayPool.RelayDescriptor(url: url, info: .readWrite) let relayConnection = RelayConnection(url: url) { _ in } processEvent: { _ in } diff --git a/highlighter action extension/ActionViewController.swift b/highlighter action extension/ActionViewController.swift @@ -163,7 +163,7 @@ struct ShareExtensionView: View { break case .active: print("txn: 📙 HIGHLIGHTER ACTIVE") - state.pool.ping() + state.nostrNetwork.pool.ping() @unknown default: break } @@ -238,7 +238,7 @@ struct ShareExtensionView: View { self.highlighter_state = .failed(error: "Cannot convert post data into a nostr event") return } - state.postbox.send(posted_event, on_flush: .once({ flushed_event in + 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/nostrscript/NostrScript.swift b/nostrscript/NostrScript.swift @@ -309,7 +309,7 @@ public func nscript_nostr_cmd(interp: UnsafeMutablePointer<wasm_interp>?, 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: .rw, variant: .ephemeral) + let desc = RelayPool.RelayDescriptor(url: url, info: .readWrite, variant: .ephemeral) return (try? script.pool.add_relay(desc)) != nil } diff --git a/share extension/ShareViewController.swift b/share extension/ShareViewController.swift @@ -193,7 +193,7 @@ struct ShareExtensionView: View { break case .active: print("txn: 📙 SHARE ACTIVE") - state.pool.ping() + state.nostrNetwork.pool.ping() @unknown default: break } @@ -230,7 +230,7 @@ struct ShareExtensionView: View { self.share_state = .failed(error: "Cannot convert post data into a nostr event") return } - state.postbox.send(posted_event, on_flush: .once({ flushed_event in + 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)