diff --git a/damus.xcodeproj/project.pbxproj b/damus.xcodeproj/project.pbxproj index 5f4e82f7..25214118 100644 --- a/damus.xcodeproj/project.pbxproj +++ b/damus.xcodeproj/project.pbxproj @@ -408,10 +408,22 @@ 5C6E1DAD2A193EC2008FC15A /* GradientButtonStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C6E1DAC2A193EC2008FC15A /* GradientButtonStyle.swift */; }; 5C6E1DAF2A194075008FC15A /* PinkGradient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C6E1DAE2A194075008FC15A /* PinkGradient.swift */; }; 5C7389B12B6EFA7100781E0A /* ProxyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C7389B02B6EFA7100781E0A /* ProxyView.swift */; }; + 5C8498022D5D150000F74FEB /* ZapExplainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C8498012D5D14FA00F74FEB /* ZapExplainer.swift */; }; + 5C8498032D5D150000F74FEB /* ZapExplainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C8498012D5D14FA00F74FEB /* ZapExplainer.swift */; }; + 5C8498042D5D150000F74FEB /* ZapExplainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C8498012D5D14FA00F74FEB /* ZapExplainer.swift */; }; 5C8711DE2C460C06007879C2 /* PostingTimelineView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C8711DD2C460C06007879C2 /* PostingTimelineView.swift */; }; 5CB017212D2D985E00A9ED05 /* CoinosButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CB017202D2D985800A9ED05 /* CoinosButton.swift */; }; 5CB017222D2D985E00A9ED05 /* CoinosButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CB017202D2D985800A9ED05 /* CoinosButton.swift */; }; 5CB017232D2D985E00A9ED05 /* CoinosButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CB017202D2D985800A9ED05 /* CoinosButton.swift */; }; + 5CB017252D42C5C400A9ED05 /* TransactionsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CB017242D42C5BD00A9ED05 /* TransactionsView.swift */; }; + 5CB017262D42C5C400A9ED05 /* TransactionsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CB017242D42C5BD00A9ED05 /* TransactionsView.swift */; }; + 5CB017272D42C5C400A9ED05 /* TransactionsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CB017242D42C5BD00A9ED05 /* TransactionsView.swift */; }; + 5CB0172D2D42C76A00A9ED05 /* BalanceView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CB0172C2D42C76600A9ED05 /* BalanceView.swift */; }; + 5CB0172E2D42C76A00A9ED05 /* BalanceView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CB0172C2D42C76600A9ED05 /* BalanceView.swift */; }; + 5CB0172F2D42C76A00A9ED05 /* BalanceView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CB0172C2D42C76600A9ED05 /* BalanceView.swift */; }; + 5CB017312D4422DB00A9ED05 /* NWCSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CB017302D4422D600A9ED05 /* NWCSettings.swift */; }; + 5CB017322D4422DB00A9ED05 /* NWCSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CB017302D4422D600A9ED05 /* NWCSettings.swift */; }; + 5CB017332D4422DB00A9ED05 /* NWCSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CB017302D4422D600A9ED05 /* NWCSettings.swift */; }; 5CC8529D2BD741CD0039FFC5 /* HighlightEvent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CC8529C2BD741CD0039FFC5 /* HighlightEvent.swift */; }; 5CC8529F2BD744F60039FFC5 /* HighlightView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CC8529E2BD744F60039FFC5 /* HighlightView.swift */; }; 5CC852A22BDED9B90039FFC5 /* HighlightDescription.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CC852A12BDED9B90039FFC5 /* HighlightDescription.swift */; }; @@ -1085,6 +1097,9 @@ D7373BA62B688EA300F7783D /* DamusPurpleTranslationSetupView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7373BA52B688EA200F7783D /* DamusPurpleTranslationSetupView.swift */; }; D7373BA82B68974500F7783D /* DamusPurpleNewUserOnboardingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7373BA72B68974500F7783D /* DamusPurpleNewUserOnboardingView.swift */; }; D7373BAA2B68A65A00F7783D /* PurpleAccountUpdateNotify.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7373BA92B68A65A00F7783D /* PurpleAccountUpdateNotify.swift */; }; + 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 */; }; 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 */; }; @@ -1481,6 +1496,18 @@ D78DB8592C1CE9CA00F0AB12 /* SwipeActions in Frameworks */ = {isa = PBXBuildFile; productRef = D78DB8582C1CE9CA00F0AB12 /* SwipeActions */; }; D78DB85B2C20FE5000F0AB12 /* VectorMath.swift in Sources */ = {isa = PBXBuildFile; fileRef = D78DB85A2C20FE4F00F0AB12 /* VectorMath.swift */; }; D78DB85F2C20FED300F0AB12 /* ChatBubbleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D78DB85E2C20FED300F0AB12 /* ChatBubbleView.swift */; }; + D78F080C2D7F78EF00FC6C75 /* Request.swift in Sources */ = {isa = PBXBuildFile; fileRef = D78F080B2D7F78EB00FC6C75 /* Request.swift */; }; + D78F080D2D7F78EF00FC6C75 /* Request.swift in Sources */ = {isa = PBXBuildFile; fileRef = D78F080B2D7F78EB00FC6C75 /* Request.swift */; }; + D78F080E2D7F78EF00FC6C75 /* Request.swift in Sources */ = {isa = PBXBuildFile; fileRef = D78F080B2D7F78EB00FC6C75 /* Request.swift */; }; + D78F080F2D7F78EF00FC6C75 /* Request.swift in Sources */ = {isa = PBXBuildFile; fileRef = D78F080B2D7F78EB00FC6C75 /* Request.swift */; }; + D78F08112D7F78F900FC6C75 /* Response.swift in Sources */ = {isa = PBXBuildFile; fileRef = D78F08102D7F78F600FC6C75 /* Response.swift */; }; + D78F08122D7F78F900FC6C75 /* Response.swift in Sources */ = {isa = PBXBuildFile; fileRef = D78F08102D7F78F600FC6C75 /* Response.swift */; }; + D78F08132D7F78F900FC6C75 /* Response.swift in Sources */ = {isa = PBXBuildFile; fileRef = D78F08102D7F78F600FC6C75 /* Response.swift */; }; + D78F08142D7F78F900FC6C75 /* Response.swift in Sources */ = {isa = PBXBuildFile; fileRef = D78F08102D7F78F600FC6C75 /* Response.swift */; }; + D78F08172D7F7F7500FC6C75 /* NIP04.swift in Sources */ = {isa = PBXBuildFile; fileRef = D78F08162D7F7F6C00FC6C75 /* NIP04.swift */; }; + D78F08182D7F7F7500FC6C75 /* NIP04.swift in Sources */ = {isa = PBXBuildFile; fileRef = D78F08162D7F7F6C00FC6C75 /* NIP04.swift */; }; + D78F08192D7F7F7500FC6C75 /* NIP04.swift in Sources */ = {isa = PBXBuildFile; fileRef = D78F08162D7F7F6C00FC6C75 /* NIP04.swift */; }; + D78F081A2D7F803100FC6C75 /* NIP04.swift in Sources */ = {isa = PBXBuildFile; fileRef = D78F08162D7F7F6C00FC6C75 /* NIP04.swift */; }; D798D21A2B0856CC00234419 /* Mentions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C7FF7D42823313F009601DB /* Mentions.swift */; }; D798D21B2B0856F200234419 /* NdbTagsIterator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CDD1AE12A6B3074001CD4DF /* NdbTagsIterator.swift */; }; D798D21C2B0857E400234419 /* Bech32Object.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CF0ABEF29857E9200D66079 /* Bech32Object.swift */; }; @@ -2368,8 +2395,12 @@ 5C6E1DAC2A193EC2008FC15A /* GradientButtonStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GradientButtonStyle.swift; sourceTree = ""; }; 5C6E1DAE2A194075008FC15A /* PinkGradient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PinkGradient.swift; sourceTree = ""; }; 5C7389B02B6EFA7100781E0A /* ProxyView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProxyView.swift; sourceTree = ""; }; + 5C8498012D5D14FA00F74FEB /* ZapExplainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ZapExplainer.swift; sourceTree = ""; }; 5C8711DD2C460C06007879C2 /* PostingTimelineView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostingTimelineView.swift; sourceTree = ""; }; 5CB017202D2D985800A9ED05 /* CoinosButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoinosButton.swift; sourceTree = ""; }; + 5CB017242D42C5BD00A9ED05 /* TransactionsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransactionsView.swift; sourceTree = ""; }; + 5CB0172C2D42C76600A9ED05 /* BalanceView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BalanceView.swift; sourceTree = ""; }; + 5CB017302D4422D600A9ED05 /* NWCSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NWCSettings.swift; sourceTree = ""; }; 5CC8529C2BD741CD0039FFC5 /* HighlightEvent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HighlightEvent.swift; sourceTree = ""; }; 5CC8529E2BD744F60039FFC5 /* HighlightView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HighlightView.swift; sourceTree = ""; }; 5CC852A12BDED9B90039FFC5 /* HighlightDescription.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HighlightDescription.swift; sourceTree = ""; }; @@ -2451,6 +2482,7 @@ D7373BA52B688EA200F7783D /* DamusPurpleTranslationSetupView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DamusPurpleTranslationSetupView.swift; sourceTree = ""; }; D7373BA72B68974500F7783D /* DamusPurpleNewUserOnboardingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DamusPurpleNewUserOnboardingView.swift; sourceTree = ""; }; D7373BA92B68A65A00F7783D /* PurpleAccountUpdateNotify.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PurpleAccountUpdateNotify.swift; sourceTree = ""; }; + D73B74E02D8365B40067BDBC /* ExtraFonts.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExtraFonts.swift; sourceTree = ""; }; D73E5F7E2C6AA066007EB227 /* DamusAliases.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DamusAliases.swift; sourceTree = ""; }; D73E5F802C6AA07A007EB227 /* HighlighterExtensionAliases.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HighlighterExtensionAliases.swift; sourceTree = ""; }; D74AAFC12B153395006CF0F4 /* HeadlessDamusState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HeadlessDamusState.swift; sourceTree = ""; }; @@ -2477,6 +2509,9 @@ D78CD5972B8990300014D539 /* DamusAppNotificationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DamusAppNotificationView.swift; sourceTree = ""; }; D78DB85A2C20FE4F00F0AB12 /* VectorMath.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VectorMath.swift; sourceTree = ""; }; D78DB85E2C20FED300F0AB12 /* ChatBubbleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatBubbleView.swift; sourceTree = ""; }; + D78F080B2D7F78EB00FC6C75 /* Request.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Request.swift; sourceTree = ""; }; + D78F08102D7F78F600FC6C75 /* Response.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Response.swift; sourceTree = ""; }; + D78F08162D7F7F6C00FC6C75 /* NIP04.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NIP04.swift; sourceTree = ""; }; D798D21D2B0858BB00234419 /* MigratedTypes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MigratedTypes.swift; sourceTree = ""; }; D798D2272B085CDA00234419 /* NdbNote+.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NdbNote+.swift"; sourceTree = ""; }; D798D22B2B086C7400234419 /* NostrEvent+.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NostrEvent+.swift"; sourceTree = ""; }; @@ -3222,6 +3257,10 @@ 4C7D095A2A098C5C00943473 /* Wallet */ = { isa = PBXGroup; children = ( + 5C8498012D5D14FA00F74FEB /* ZapExplainer.swift */, + 5CB017302D4422D600A9ED05 /* NWCSettings.swift */, + 5CB0172C2D42C76600A9ED05 /* BalanceView.swift */, + 5CB017242D42C5BD00A9ED05 /* TransactionsView.swift */, 4C7D095C2A098C5D00943473 /* ConnectWalletView.swift */, 4C7D095D2A098C5D00943473 /* WalletView.swift */, 4C7D09672A0AE9B200943473 /* NWCScannerView.swift */, @@ -3247,11 +3286,12 @@ 4C7FF7D628233637009601DB /* Util */ = { isa = PBXGroup; children = ( + D73B74E02D8365B40067BDBC /* ExtraFonts.swift */, D7DB93042D66A43B00DA1EE5 /* Undistractor.swift */, D73E5F7E2C6AA066007EB227 /* DamusAliases.swift */, E04A37C52B544F090029650D /* URIParsing.swift */, 4C1D4FB02A7958E60024F453 /* VersionInfo.swift */, - 4C7D09612A098D0E00943473 /* WalletConnect.swift */, + D78F080A2D7F78B000FC6C75 /* WalletConnect */, 4C198DF329F88D23004C165C /* Images */, 4C198DEA29F88C6B004C165C /* BlurHash */, 4CE4F0F329D779B5005914DB /* PostBox.swift */, @@ -3301,7 +3341,6 @@ D7EDED2D2B128E8A0018B19C /* CollectionExtension.swift */, D74AAFCE2B155D8C006CF0F4 /* ZapDataModel.swift */, D74AAFD32B155ECB006CF0F4 /* Zaps+.swift */, - D74AAFD52B155F0C006CF0F4 /* WalletConnect+.swift */, ); path = Util; sourceTree = ""; @@ -3613,6 +3652,7 @@ children = ( D7DB1FDC2D5A77E500CF06DA /* NIP44 */, D755B28B2D3E7D6500BBEEFA /* NIP37 */, + D78F08152D7F7F5F00FC6C75 /* NIP04 */, 4C45E5002BED4CE10025A428 /* NIP10 */, 4C1D4FB32A7967990024F453 /* build-git-hash.txt */, 4CA3529C2A76AE47003BB08B /* Notify */, @@ -3953,6 +3993,25 @@ path = Chat; sourceTree = ""; }; + D78F080A2D7F78B000FC6C75 /* WalletConnect */ = { + isa = PBXGroup; + children = ( + D78F08102D7F78F600FC6C75 /* Response.swift */, + D78F080B2D7F78EB00FC6C75 /* Request.swift */, + 4C7D09612A098D0E00943473 /* WalletConnect.swift */, + D74AAFD52B155F0C006CF0F4 /* WalletConnect+.swift */, + ); + path = WalletConnect; + sourceTree = ""; + }; + D78F08152D7F7F5F00FC6C75 /* NIP04 */ = { + isa = PBXGroup; + children = ( + D78F08162D7F7F6C00FC6C75 /* NIP04.swift */, + ); + path = NIP04; + sourceTree = ""; + }; D79C4C152AFEB061003A41B4 /* DamusNotificationService */ = { isa = PBXGroup; children = ( @@ -4404,6 +4463,7 @@ D7CBD1D42B8D21DC00BFD889 /* DamusPurpleNotificationManagement.swift in Sources */, 4C32B9572A9AD44700DC3548 /* Root.swift in Sources */, 4C3EA64428FF558100C48A62 /* sha256.c in Sources */, + 5C8498032D5D150000F74FEB /* ZapExplainer.swift in Sources */, 504323A72A34915F006AE6DC /* RelayModel.swift in Sources */, 4CF0ABF62985CD5500D66079 /* UserSearch.swift in Sources */, 4C32B9542A9AD44700DC3548 /* FlatBuffersUtils.swift in Sources */, @@ -4602,6 +4662,7 @@ 4CE879522996B68900F758CC /* RelayType.swift in Sources */, 4CE8795B2996C47A00F758CC /* ZapsModel.swift in Sources */, 4C3A1D3729637E0500558C0F /* PreviewCache.swift in Sources */, + D78F08142D7F78F900FC6C75 /* Response.swift in Sources */, 4C3EA67528FF7A5A00C48A62 /* take.c in Sources */, 4C3AC7A12835A81400E1F516 /* SetupView.swift in Sources */, 4C06670128FC7C5900038D2A /* RelayView.swift in Sources */, @@ -4627,6 +4688,7 @@ 4CAAD8AD298851D000060CEA /* AccountDeletion.swift in Sources */, 4CFF8F6329CC9AD7008DB934 /* ImageContextMenuModifier.swift in Sources */, 4C54AA0A29A55429003E4487 /* EventGroup.swift in Sources */, + D73B74E12D8365BA0067BDBC /* ExtraFonts.swift in Sources */, 4C7D09622A098D0E00943473 /* WalletConnect.swift in Sources */, 4C3EA67928FF7ABF00C48A62 /* list.c in Sources */, 4C64987E286D082C00EAE2B3 /* DirectMessagesModel.swift in Sources */, @@ -4663,6 +4725,7 @@ 4C3EA64C28FF59AC00C48A62 /* bech32_util.c in Sources */, 4CA9276C2A2910D10098A105 /* ReplyPart.swift in Sources */, D7C6787E2B2D34CC00BCEAFB /* NIP98AuthenticatedRequest.swift in Sources */, + 5CB017252D42C5C400A9ED05 /* TransactionsView.swift in Sources */, 4CE1399029F0661A00AC6A0B /* RepostAction.swift in Sources */, 4CE1399229F0666100AC6A0B /* ShareActionButton.swift in Sources */, 4C42812C298C848200DBF26F /* TranslateView.swift in Sources */, @@ -4712,6 +4775,8 @@ 4C8EC52529D1FA6C0085D9A8 /* DamusColors.swift in Sources */, 3A4647CF2A413ADC00386AD8 /* CondensedProfilePicturesView.swift in Sources */, 5C14C29B2BBBA29C00079FD2 /* RelaySoftwareDetail.swift in Sources */, + 5CB017312D4422DB00A9ED05 /* NWCSettings.swift in Sources */, + D78F080D2D7F78EF00FC6C75 /* Request.swift in Sources */, D78DB85F2C20FED300F0AB12 /* ChatBubbleView.swift in Sources */, D2277EEA2A089BD5006C3807 /* Router.swift in Sources */, 4C9D6D162B1AA9C6004E5CD9 /* DisplayTabBarNotify.swift in Sources */, @@ -4791,6 +4856,7 @@ 4C9146FE2A2A87C200DDEA40 /* nostrscript.c in Sources */, 4C5F9118283D88E40052CD1C /* FollowingModel.swift in Sources */, 4C1A9A1A29DCA17E00516EAC /* ReplyCounter.swift in Sources */, + D78F08182D7F7F7500FC6C75 /* NIP04.swift in Sources */, 50B5685329F97CB400A23243 /* CredentialHandler.swift in Sources */, 643EA5C8296B764E005081BB /* RelayFilterView.swift in Sources */, F71694EC2A662292001F4053 /* SuggestedUsersViewModel.swift in Sources */, @@ -4809,6 +4875,7 @@ F7908E92298B0F0700AB113A /* RelayDetailView.swift in Sources */, 4C9147002A2A891E00DDEA40 /* error.c in Sources */, 4CE879552996BAB900F758CC /* RelayPaidDetail.swift in Sources */, + 5CB0172F2D42C76A00A9ED05 /* BalanceView.swift in Sources */, 4C1253602A76CF890004F4B8 /* ScrollToTopNotify.swift in Sources */, 4CA3529E2A76AE67003BB08B /* FollowNotify.swift in Sources */, 4CF0ABD42980996B00D66079 /* Report.swift in Sources */, @@ -4953,6 +5020,7 @@ 82D6FABC2CD99F7900C925F4 /* refmap.c in Sources */, 82D6FABD2CD99F7900C925F4 /* verifier.c in Sources */, 82D6FABE2CD99F7900C925F4 /* NdbProfile.swift in Sources */, + D78F08112D7F78F900FC6C75 /* Response.swift in Sources */, 82D6FABF2CD99F7900C925F4 /* NdbTagIterator.swift in Sources */, 82D6FAC02CD99F7900C925F4 /* NdbNote.swift in Sources */, 82D6FAC12CD99F7900C925F4 /* AsciiCharacter.swift in Sources */, @@ -4989,6 +5057,7 @@ 82D6FAE02CD99F7900C925F4 /* DisplayTabBarNotify.swift in Sources */, 82D6FAE12CD99F7900C925F4 /* BroadcastNotify.swift in Sources */, 82D6FAE22CD99F7900C925F4 /* ComposeNotify.swift in Sources */, + D73B74E22D8365BA0067BDBC /* ExtraFonts.swift in Sources */, 82D6FAE32CD99F7900C925F4 /* FollowedNotify.swift in Sources */, 82D6FAE42CD99F7900C925F4 /* FollowNotify.swift in Sources */, 82D6FAE52CD99F7900C925F4 /* LikedNotify.swift in Sources */, @@ -5021,6 +5090,7 @@ 82D6FAFE2CD99F7900C925F4 /* Pubkey.swift in Sources */, 82D6FAFF2CD99F7900C925F4 /* NoteId.swift in Sources */, 82D6FB002CD99F7900C925F4 /* Referenced.swift in Sources */, + 5CB0172D2D42C76A00A9ED05 /* BalanceView.swift in Sources */, 82D6FB012CD99F7900C925F4 /* Block.swift in Sources */, 82D6FB022CD99F7900C925F4 /* MigratedTypes.swift in Sources */, 82D6FB032CD99F7900C925F4 /* DamusDuration.swift in Sources */, @@ -5028,6 +5098,7 @@ 82D6FB052CD99F7900C925F4 /* MusicController.swift in Sources */, 82D6FB062CD99F7900C925F4 /* UserStatusView.swift in Sources */, 82D6FB072CD99F7900C925F4 /* UserStatus.swift in Sources */, + 5CB017262D42C5C400A9ED05 /* TransactionsView.swift in Sources */, 82D6FB082CD99F7900C925F4 /* UserStatusSheet.swift in Sources */, 82D6FB092CD99F7900C925F4 /* SearchHeaderView.swift in Sources */, 82D6FB0A2CD99F7900C925F4 /* DamusGradient.swift in Sources */, @@ -5073,6 +5144,7 @@ 82D6FB322CD99F7900C925F4 /* FillAndStroke.swift in Sources */, 82D6FB332CD99F7900C925F4 /* Array.swift in Sources */, 82D6FB342CD99F7900C925F4 /* VectorMath.swift in Sources */, + 5C8498022D5D150000F74FEB /* ZapExplainer.swift in Sources */, 82D6FB352CD99F7900C925F4 /* OffsetExtension.swift in Sources */, 82D6FB362CD99F7900C925F4 /* RelayFilters.swift in Sources */, 82D6FB372CD99F7900C925F4 /* RelayModelCache.swift in Sources */, @@ -5118,6 +5190,7 @@ 82D6FB5E2CD99F7900C925F4 /* CredentialHandler.swift in Sources */, 82D6FB5F2CD99F7900C925F4 /* KeyboardVisible.swift in Sources */, 82D6FB602CD99F7900C925F4 /* StringUtil.swift in Sources */, + D78F08172D7F7F7500FC6C75 /* NIP04.swift in Sources */, 82D6FB612CD99F7900C925F4 /* Router.swift in Sources */, 82D6FB622CD99F7900C925F4 /* Log.swift in Sources */, 82D6FB632CD99F7900C925F4 /* AVPlayer+Additions.swift in Sources */, @@ -5347,6 +5420,7 @@ 82D6FC432CD99F7900C925F4 /* ReactionView.swift in Sources */, 82D6FC442CD99F7900C925F4 /* EventActionBar.swift in Sources */, 82D6FC452CD99F7900C925F4 /* EventDetailBar.swift in Sources */, + D78F080C2D7F78EF00FC6C75 /* Request.swift in Sources */, 82D6FC462CD99F7900C925F4 /* ShareAction.swift in Sources */, 82D6FC472CD99F7900C925F4 /* RepostAction.swift in Sources */, 82D6FC482CD99F7900C925F4 /* ShareActionButton.swift in Sources */, @@ -5372,6 +5446,7 @@ 82D6FC5A2CD99F7900C925F4 /* QRScanNSECView.swift in Sources */, 82D6FC5B2CD99F7900C925F4 /* NoteContentView.swift in Sources */, 82D6FC5C2CD99F7900C925F4 /* PostButton.swift in Sources */, + 5CB017322D4422DB00A9ED05 /* NWCSettings.swift in Sources */, 82D6FC5D2CD99F7900C925F4 /* PostView.swift in Sources */, 82D6FC5E2CD99F7900C925F4 /* AttachMediaUtility.swift in Sources */, 82D6FC5F2CD99F7900C925F4 /* MediaPicker.swift in Sources */, @@ -5523,6 +5598,7 @@ D73E5E8A2C6A97F4007EB227 /* PurpleStoreKitManager.swift in Sources */, D73E5E8D2C6A97F4007EB227 /* CameraService+Extensions.swift in Sources */, D73E5E8E2C6A97F4007EB227 /* ImageResizer.swift in Sources */, + D78F080E2D7F78EF00FC6C75 /* Request.swift in Sources */, D73E5E8F2C6A97F4007EB227 /* PhotoCaptureProcessor.swift in Sources */, D773BC602C6D538500349F0A /* CommentItem.swift in Sources */, D73E5E902C6A97F4007EB227 /* VideoCaptureProcessor.swift in Sources */, @@ -5537,6 +5613,7 @@ D73E5E992C6A97F4007EB227 /* Liked.swift in Sources */, D73E5E9A2C6A97F4007EB227 /* ProfileUpdate.swift in Sources */, D73E5E9B2C6A97F4007EB227 /* PostBlock.swift in Sources */, + 5CB017332D4422DB00A9ED05 /* NWCSettings.swift in Sources */, D73E5E9C2C6A97F4007EB227 /* Reply.swift in Sources */, D73E5E9D2C6A97F4007EB227 /* SearchModel.swift in Sources */, D73E5E9E2C6A97F4007EB227 /* NostrFilter+Hashable.swift in Sources */, @@ -5544,6 +5621,7 @@ D73E5F912C6AA71B007EB227 /* InputDismissKeyboard.swift in Sources */, D73E5E9F2C6A97F4007EB227 /* CreateAccountModel.swift in Sources */, D73E5EA12C6A97F4007EB227 /* SignalModel.swift in Sources */, + 5CB017272D42C5C400A9ED05 /* TransactionsView.swift in Sources */, D73E5EA22C6A97F4007EB227 /* FollowTarget.swift in Sources */, D73E5EA32C6A97F4007EB227 /* BookmarksManager.swift in Sources */, D73E5EA42C6A97F4007EB227 /* EventsModel.swift in Sources */, @@ -5551,6 +5629,7 @@ D73E5EA62C6A97F4007EB227 /* FollowersModel.swift in Sources */, D73E5EA72C6A97F4007EB227 /* SearchHomeModel.swift in Sources */, D73E5EA82C6A97F4007EB227 /* DirectMessageModel.swift in Sources */, + D78F08132D7F78F900FC6C75 /* Response.swift in Sources */, D73E5EA92C6A97F4007EB227 /* Report.swift in Sources */, D73E5EAA2C6A97F4007EB227 /* ZapsModel.swift in Sources */, D73E5EAB2C6A97F4007EB227 /* DraftsModel.swift in Sources */, @@ -5626,10 +5705,12 @@ D73E5EF22C6A97F4007EB227 /* DamusPurpleURLSheetView.swift in Sources */, D73E5EF32C6A97F4007EB227 /* DamusPurpleVerifyNpubView.swift in Sources */, D73E5EF42C6A97F4007EB227 /* DamusPurpleAccountView.swift in Sources */, + 5CB0172E2D42C76A00A9ED05 /* BalanceView.swift in Sources */, D73E5EF52C6A97F4007EB227 /* DamusPurpleNewUserOnboardingView.swift in Sources */, D73E5EF62C6A97F4007EB227 /* SearchingEventView.swift in Sources */, D73E5EF72C6A97F4007EB227 /* PullDownSearch.swift in Sources */, D73E5EF82C6A97F4007EB227 /* NotificationsView.swift in Sources */, + D73B74E32D8365BA0067BDBC /* ExtraFonts.swift in Sources */, D73E5EF92C6A97F4007EB227 /* EventGroupView.swift in Sources */, D73E5EFA2C6A97F4007EB227 /* NotificationItemView.swift in Sources */, D73E5EFB2C6A97F4007EB227 /* ProfilePicturesView.swift in Sources */, @@ -5684,6 +5765,7 @@ D73E5F292C6A97F4007EB227 /* ReplyDescription.swift in Sources */, D73E5F2A2C6A97F4007EB227 /* RelativeTime.swift in Sources */, D73E5F732C6A9885007EB227 /* TestData.swift in Sources */, + D78F08192D7F7F7500FC6C75 /* NIP04.swift in Sources */, D73E5F2B2C6A97F4007EB227 /* ReplyPart.swift in Sources */, D73E5F2C2C6A97F4007EB227 /* ProxyView.swift in Sources */, D73E5F2D2C6A97F4007EB227 /* SelectedEventView.swift in Sources */, @@ -5773,6 +5855,7 @@ D703D7A32C670E1D00A400EA /* nostr_bech32.c in Sources */, D703D7992C670DF900A400EA /* sha256.c in Sources */, D703D7972C670DED00A400EA /* wasm.c in Sources */, + 5C8498042D5D150000F74FEB /* ZapExplainer.swift in Sources */, D703D7842C670C4700A400EA /* SequenceUtils.swift in Sources */, D703D7912C670D1E00A400EA /* DisplayName.swift in Sources */, D703D7B02C6710A500A400EA /* Root.swift in Sources */, @@ -5985,6 +6068,7 @@ D798D2232B0859B700234419 /* KeychainStorage.swift in Sources */, D74AAFC32B153395006CF0F4 /* HeadlessDamusState.swift in Sources */, D7CE1B272B0BE224002EDAD4 /* bech32_util.c in Sources */, + D78F08122D7F78F900FC6C75 /* Response.swift in Sources */, D7CCFC102B05880F00323D86 /* Id.swift in Sources */, D7CB5D532B1174E900AD4105 /* DeepLPlan.swift in Sources */, D7EDED282B1180940018B19C /* ImageUploadModel.swift in Sources */, @@ -5994,9 +6078,11 @@ D7CE1B332B0BE6DE002EDAD4 /* Nostr.swift in Sources */, D7CE1B3D2B0BE719002EDAD4 /* Verifiable.swift in Sources */, D7CE1B382B0BE719002EDAD4 /* VeriferOptions.swift in Sources */, + D78F080F2D7F78EF00FC6C75 /* Request.swift in Sources */, D7CCFC152B05891000323D86 /* Referenced.swift in Sources */, D7CE1B2B2B0BE243002EDAD4 /* hex.c in Sources */, D798D2222B08598A00234419 /* ReferencedId.swift in Sources */, + D78F081A2D7F803100FC6C75 /* NIP04.swift in Sources */, D7B76C912C82507F003A16CB /* NIP98AuthenticatedRequest.swift in Sources */, D7CE1B492B0BE729002EDAD4 /* DisplayName.swift in Sources */, D7CE1B192B0BE132002EDAD4 /* builder.c in Sources */, diff --git a/damus/Components/NoteZapButton.swift b/damus/Components/NoteZapButton.swift index 3feef660..137598e9 100644 --- a/damus/Components/NoteZapButton.swift +++ b/damus/Components/NoteZapButton.swift @@ -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 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.pool, postbox: damus_state.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 = nwc_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.pool, post: damus_state.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/ContentView.swift b/damus/ContentView.swift index 13686752..d5e0d69d 100644 --- a/damus/ContentView.swift +++ b/damus/ContentView.swift @@ -367,7 +367,9 @@ 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 diff --git a/damus/Models/HomeModel.swift b/damus/Models/HomeModel.swift index c48c45e1..597ef39f 100644 --- a/damus/Models/HomeModel.swift +++ b/damus/Models/HomeModel.swift @@ -260,7 +260,7 @@ class HomeModel: ContactsDelegate { // TODO: Adapt KeychainStorage to StringCodable and instead of parsing to WalletConnectURL every time guard let nwc_str = damus_state.settings.nostr_wallet_connect, let nwc = WalletConnectURL(str: nwc_str), - let resp = await FullWalletResponse(from: ev, nwc: nwc) else { + let resp = await WalletConnect.FullWalletResponse(from: ev, nwc: nwc) else { return } @@ -274,12 +274,24 @@ class HomeModel: ContactsDelegate { guard resp.response.error == nil else { print("nwc error: \(resp.response)") - nwc_error(zapcache: self.damus_state.zaps, evcache: self.damus_state.events, resp: resp) + WalletConnect.handle_error(zapcache: self.damus_state.zaps, evcache: self.damus_state.events, resp: resp) return } + if resp.response.result_type == .list_transactions { + Log.info("Received NWC transaction list from %s", for: .nwc, relay.absoluteString) + damus_state.wallet.handle_nwc_response(response: resp) + return + } + + if resp.response.result_type == .get_balance { + Log.info("Received NWC balance information from %s", for: .nwc, relay.absoluteString) + damus_state.wallet.handle_nwc_response(response: resp) + return + } + print("nwc success: \(resp.response.result.debugDescription) [\(relay)]") - nwc_success(state: self.damus_state, resp: resp) + WalletConnect.handle_zap_success(state: self.damus_state, resp: resp) } } @@ -453,7 +465,7 @@ class HomeModel: ContactsDelegate { let nwc = WalletConnectURL(str: nwc_str), nwc.relay == relay_id { - subscribe_to_nwc(url: nwc, pool: pool) + WalletConnect.subscribe(url: nwc, pool: pool) } case .error(let merr): let desc = String(describing: merr) diff --git a/damus/Models/WalletModel.swift b/damus/Models/WalletModel.swift index b0df6e18..1662259a 100644 --- a/damus/Models/WalletModel.swift +++ b/damus/Models/WalletModel.swift @@ -13,10 +13,17 @@ enum WalletConnectState { case none } +/// Models and manages the user's NWC wallet based on the app's settings class WalletModel: ObservableObject { var settings: UserSettingsStore private(set) var previous_state: WalletConnectState var initial_percent: Int + /// The wallet's balance, in sats. + /// Starts with `nil` to signify it is not loaded yet + @Published private(set) var balance: Int64? = nil + /// The list of NWC transactions made in the wallet + /// Starts with `nil` to signify it is not loaded yet + @Published private(set) var transactions: [WalletConnect.Transaction]? = nil @Published private(set) var connect_state: WalletConnectState @@ -61,4 +68,27 @@ class WalletModel: ObservableObject { self.connect_state = .existing(nwc) self.previous_state = .existing(nwc) } + + /// Handles an NWC response event and updates the model. + /// + /// This takes a response received from the NWC relay and updates the internal state of this model. + /// + /// - Parameter response: The NWC response received from the network + func handle_nwc_response(response: WalletConnect.FullWalletResponse) { + switch response.response.result { + case .get_balance(let balanceResp): + self.balance = balanceResp.balance / 1000 + case .none: + return + case .some(.pay_invoice(_)): + return + case .list_transactions(let transactionsResp): + self.transactions = transactionsResp.transactions + } + } + + func resetWalletStateInformation() { + self.transactions = nil + self.balance = nil + } } diff --git a/damus/NIP04/NIP04.swift b/damus/NIP04/NIP04.swift new file mode 100644 index 00000000..fbedd501 --- /dev/null +++ b/damus/NIP04/NIP04.swift @@ -0,0 +1,55 @@ +// +// NIP04.swift +// damus +// +// Created by Daniel D’Aquino on 2025-03-10. +// +import Foundation + +/// Functions and utilities for the NIP-04 spec +struct NIP04 {} + +extension NIP04 { + /// Encrypts a message using NIP-04. + static func encrypt_message(message: String, privkey: Privkey, to_pk: Pubkey, encoding: EncEncoding = .base64) -> String? { + let iv = random_bytes(count: 16).bytes + guard let shared_sec = get_shared_secret(privkey: privkey, pubkey: to_pk) else { + return nil + } + let utf8_message = Data(message.utf8).bytes + guard let enc_message = aes_encrypt(data: utf8_message, iv: iv, shared_sec: shared_sec) else { + return nil + } + + switch encoding { + case .base64: + return encode_dm_base64(content: enc_message.bytes, iv: iv) + case .bech32: + return encode_dm_bech32(content: enc_message.bytes, iv: iv) + } + + } + + /// Creates an event with encrypted `contents` field, using NIP-04 + static func create_encrypted_event(_ message: String, to_pk: Pubkey, tags: [[String]], keypair: FullKeypair, created_at: UInt32, kind: UInt32) -> NostrEvent? { + let privkey = keypair.privkey + + guard let enc_content = encrypt_message(message: message, privkey: privkey, to_pk: to_pk) else { + return nil + } + + return NostrEvent(content: enc_content, keypair: keypair.to_keypair(), kind: kind, tags: tags, createdAt: created_at) + } + + /// Creates a NIP-04 style direct message event + static func create_dm(_ message: String, to_pk: Pubkey, tags: [[String]], keypair: Keypair, created_at: UInt32? = nil) -> NostrEvent? + { + let created = created_at ?? UInt32(Date().timeIntervalSince1970) + + guard let keypair = keypair.to_full() else { + return nil + } + + return create_encrypted_event(message, to_pk: to_pk, tags: tags, keypair: keypair, created_at: created, kind: 4) + } +} diff --git a/damus/Nostr/NostrEvent+.swift b/damus/Nostr/NostrEvent+.swift index 4c936fa7..4e369b45 100644 --- a/damus/Nostr/NostrEvent+.swift +++ b/damus/Nostr/NostrEvent+.swift @@ -68,7 +68,7 @@ func make_private_zap_request_event(identity: FullKeypair, enc_key: FullKeypair, guard let note = NostrEvent(content: message, keypair: identity.to_keypair(), kind: 9733, tags: tags), let note_json = encode_json(note), - let enc = encrypt_message(message: note_json, privkey: enc_key.privkey, to_pk: target.pubkey, encoding: .bech32) + let enc = NIP04.encrypt_message(message: note_json, privkey: enc_key.privkey, to_pk: target.pubkey, encoding: .bech32) else { return nil } diff --git a/damus/Nostr/NostrResponse.swift b/damus/Nostr/NostrResponse.swift index 48c2bc37..7a8eb8a4 100644 --- a/damus/Nostr/NostrResponse.swift +++ b/damus/Nostr/NostrResponse.swift @@ -45,7 +45,7 @@ enum NostrResponse { static func owned_from_json(json: String) -> NostrResponse? { return json.withCString{ cstr in - let bufsize: Int = max(Int(Double(json.utf8.count) * 4.0), Int(getpagesize())) + let bufsize: Int = max(Int(Double(json.utf8.count) * 8.0), Int(getpagesize())) let data = malloc(bufsize) if data == nil { diff --git a/damus/Util/ExtraFonts.swift b/damus/Util/ExtraFonts.swift new file mode 100644 index 00000000..131168df --- /dev/null +++ b/damus/Util/ExtraFonts.swift @@ -0,0 +1,15 @@ +// +// ExtraFonts.swift +// damus +// +// Created by Daniel D’Aquino on 2025-03-13. +// +import SwiftUI + +extension Font { + // Note: When changing the font size accessibility setting, these styles only update after an app restart. It's a current limitation of this. + + static let veryLargeTitle: Font = .system(size: UIFont.preferredFont(forTextStyle: .largeTitle).pointSize * 1.5, weight: .bold) // Makes a bigger title while allowing for iOS dynamic font sizing to take effect + static let veryVeryLargeTitle: Font = .system(size: UIFont.preferredFont(forTextStyle: .largeTitle).pointSize * 2.1, weight: .bold) // Makes a bigger title while allowing for iOS dynamic font sizing to take effect +} + diff --git a/damus/Util/Log.swift b/damus/Util/Log.swift index 5bba5e2b..e9e4f84b 100644 --- a/damus/Util/Log.swift +++ b/damus/Util/Log.swift @@ -15,6 +15,8 @@ enum LogCategory: String { case storage case networking case timeline + /// Logs related to Nostr Wallet Connect components + case nwc case push_notifications case damus_purple case image_uploading diff --git a/damus/Util/WalletConnect+.swift b/damus/Util/WalletConnect+.swift deleted file mode 100644 index 56cd1717..00000000 --- a/damus/Util/WalletConnect+.swift +++ /dev/null @@ -1,118 +0,0 @@ -// -// WalletConnect+.swift -// damus -// -// Created by Daniel D’Aquino on 2023-11-27. -// - -import Foundation - -func make_wallet_pay_invoice_request(invoice: String) -> WalletRequest { - let data = PayInvoiceRequest(invoice: invoice) - return WalletRequest(method: "pay_invoice", params: data) -} - -func make_wallet_balance_request() -> WalletRequest { - return WalletRequest(method: "get_balance", params: nil) -} - -struct EmptyRequest: Codable { -} - -struct PayInvoiceRequest: Codable { - let invoice: String -} - -func make_wallet_connect_request(req: WalletRequest, to_pk: Pubkey, keypair: FullKeypair) -> NostrEvent? { - let tags = [to_pk.tag] - let created_at = UInt32(Date().timeIntervalSince1970) - guard let content = encode_json(req) else { - return nil - } - return create_encrypted_event(content, to_pk: to_pk, tags: tags, keypair: keypair, created_at: created_at, kind: 23194) -} - -func subscribe_to_nwc(url: WalletConnectURL, pool: RelayPool) { - var filter = NostrFilter(kinds: [.nwc_response]) - filter.authors = [url.pubkey] - filter.limit = 0 - let sub = NostrSubscribe(filters: [filter], sub_id: "nwc") - - pool.send(.subscribe(sub), to: [url.relay], skip_ephemeral: false) -} - -@discardableResult -func nwc_pay(url: WalletConnectURL, pool: RelayPool, post: PostBox, invoice: String, delay: TimeInterval? = 5.0, on_flush: OnFlush? = nil) -> NostrEvent? { - let req = make_wallet_pay_invoice_request(invoice: invoice) - guard let ev = make_wallet_connect_request(req: req, to_pk: url.pubkey, keypair: url.keypair) else { - return nil - } - - try? pool.add_relay(.nwc(url: url.relay)) - subscribe_to_nwc(url: url, pool: pool) - post.send(ev, to: [url.relay], skip_ephemeral: false, delay: delay, on_flush: on_flush) - return ev -} - - -func nwc_success(state: DamusState, resp: FullWalletResponse) { - // find the pending zap and mark it as pending-confirmed - for kv in state.zaps.our_zaps { - let zaps = kv.value - - for zap in zaps { - guard case .pending(let pzap) = zap, - case .nwc(let nwc_state) = pzap.state, - case .postbox_pending(let nwc_req) = nwc_state.state, - nwc_req.id == resp.req_id - else { - continue - } - - if nwc_state.update_state(state: .confirmed) { - // notify the zaps model of an update so it can mark them as paid - state.events.get_cache_data(NoteId(pzap.target.id)).zaps_model.objectWillChange.send() - print("NWC success confirmed") - } - - return - } - } -} - -func send_donation_zap(pool: RelayPool, postbox: PostBox, nwc: WalletConnectURL, percent: Int, base_msats: Int64) async { - let percent_f = Double(percent) / 100.0 - let donations_msats = Int64(percent_f * Double(base_msats)) - - let payreq = LNUrlPayRequest(allowsNostr: true, commentAllowed: nil, nostrPubkey: "", callback: "https://sendsats.lol/@damus") - guard let invoice = await fetch_zap_invoice(payreq, zapreq: nil, msats: donations_msats, zap_type: .non_zap, comment: nil) else { - // we failed... oh well. no donation for us. - print("damus-donation failed to fetch invoice") - return - } - - print("damus-donation donating...") - nwc_pay(url: nwc, pool: pool, post: postbox, invoice: invoice, delay: nil) -} - -func nwc_error(zapcache: Zaps, evcache: EventCache, resp: FullWalletResponse) { - // find a pending zap with the nwc request id associated with this response and remove it - for kv in zapcache.our_zaps { - let zaps = kv.value - - for zap in zaps { - guard case .pending(let pzap) = zap, - case .nwc(let nwc_state) = pzap.state, - case .postbox_pending(let req) = nwc_state.state, - req.id == resp.req_id - else { - continue - } - - // remove the pending zap if there was an error - let reqid = ZapRequestId(from_pending: pzap) - remove_zap(reqid: reqid, zapcache: zapcache, evcache: evcache) - return - } - } -} diff --git a/damus/Util/WalletConnect.swift b/damus/Util/WalletConnect.swift deleted file mode 100644 index 853f226f..00000000 --- a/damus/Util/WalletConnect.swift +++ /dev/null @@ -1,155 +0,0 @@ -// -// WalletConnect.swift -// damus -// -// Created by William Casarin on 2023-03-22. -// - -import Foundation - -struct WalletConnectURL: Equatable { - static func == (lhs: WalletConnectURL, rhs: WalletConnectURL) -> Bool { - return lhs.keypair == rhs.keypair && - lhs.pubkey == rhs.pubkey && - lhs.relay == rhs.relay - } - - let relay: RelayURL - let keypair: FullKeypair - let pubkey: Pubkey - let lud16: String? - - func to_url() -> URL { - var urlComponents = URLComponents() - urlComponents.scheme = "nostrwalletconnect" - urlComponents.host = pubkey.hex() - urlComponents.queryItems = [ - URLQueryItem(name: "relay", value: relay.absoluteString), - URLQueryItem(name: "secret", value: keypair.privkey.hex()) - ] - - if let lud16 { - urlComponents.queryItems?.append(URLQueryItem(name: "lud16", value: lud16)) - } - - return urlComponents.url! - } - - init?(str: String) { - guard let components = URLComponents(string: str), - components.scheme == "nostrwalletconnect" || components.scheme == "nostr+walletconnect", - // The line below provides flexibility for both `nostrwalletconnect://` (non-compliant, but commonly used) and `nostrwalletconnect:` (NIP-47 compliant) formats - let encoded_pubkey = components.path == "" ? components.host : components.path, - let pubkey = hex_decode_pubkey(encoded_pubkey), - let items = components.queryItems, - let relay = items.first(where: { qi in qi.name == "relay" })?.value, - let relay_url = RelayURL(relay), - let secret = items.first(where: { qi in qi.name == "secret" })?.value, - secret.utf8.count == 64, - let decoded = hex_decode(secret) - else { - return nil - } - - let privkey = Privkey(Data(decoded)) - guard let our_pk = privkey_to_pubkey(privkey: privkey) else { return nil } - - let lud16 = items.first(where: { qi in qi.name == "lud16" })?.value - let keypair = FullKeypair(pubkey: our_pk, privkey: privkey) - self = WalletConnectURL(pubkey: pubkey, relay: relay_url, keypair: keypair, lud16: lud16) - } - - init(pubkey: Pubkey, relay: RelayURL, keypair: FullKeypair, lud16: String?) { - self.pubkey = pubkey - self.relay = relay - self.keypair = keypair - self.lud16 = lud16 - } -} - -struct WalletRequest: Codable { - let method: String - let params: T? -} - -struct WalletResponseErr: Codable { - let code: String? - let message: String? -} - -struct PayInvoiceResponse: Decodable { - let preimage: String -} - -enum WalletResponseResultType: String { - case pay_invoice -} - -enum WalletResponseResult { - case pay_invoice(PayInvoiceResponse) -} - -struct FullWalletResponse { - let req_id: NoteId - let response: WalletResponse - - init?(from: NostrEvent, nwc: WalletConnectURL) async { - guard let note_id = from.referenced_ids.first else { - return nil - } - - self.req_id = note_id - - let ares = Task { - guard let json = decrypt_dm(nwc.keypair.privkey, pubkey: nwc.pubkey, content: from.content, encoding: .base64), - let resp: WalletResponse = decode_json(json) - else { - let resp: WalletResponse? = nil - return resp - } - - return resp - } - - guard let res = await ares.value else { - return nil - } - - self.response = res - } - -} - -struct WalletResponse: Decodable { - let result_type: WalletResponseResultType - let error: WalletResponseErr? - let result: WalletResponseResult? - - private enum CodingKeys: CodingKey { - case result_type, error, result - } - - init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - let result_type_str = try container.decode(String.self, forKey: .result_type) - - guard let result_type = WalletResponseResultType(rawValue: result_type_str) else { - throw DecodingError.typeMismatch(WalletResponseResultType.self, .init(codingPath: decoder.codingPath, debugDescription: "result_type \(result_type_str) is unknown")) - } - - self.result_type = result_type - self.error = try container.decodeIfPresent(WalletResponseErr.self, forKey: .error) - - guard self.error == nil else { - self.result = nil - return - } - - switch result_type { - case .pay_invoice: - let res = try container.decode(PayInvoiceResponse.self, forKey: .result) - self.result = .pay_invoice(res) - } - } -} - diff --git a/damus/Util/WalletConnect/Request.swift b/damus/Util/WalletConnect/Request.swift new file mode 100644 index 00000000..a2e055c9 --- /dev/null +++ b/damus/Util/WalletConnect/Request.swift @@ -0,0 +1,137 @@ +// +// Request.swift +// damus +// +// Created by Daniel D’Aquino on 2025-03-10. +// + +import Foundation + +extension WalletConnect { + /// Models a request to an NWC wallet provider + enum Request: Codable { + /// Pay an invoice + case payInvoice( + /// bolt-11 invoice string + invoice: String + ) + /// Get the current wallet balance + case getBalance + /// Get the current wallet transaction history + case getTransactionList( + /// Starting timestamp in seconds since epoch (inclusive), optional. + from: UInt64?, + /// Ending timestamp in seconds since epoch (inclusive), optional. + until: UInt64?, + /// Maximum number of invoices to return, optional. + limit: Int?, + /// Offset of the first invoice to return, optional. + offset: Int?, + /// Include unpaid invoices, optional, default false. + unpaid: Bool?, + /// "incoming" for invoices, "outgoing" for payments, undefined for both. + type: String? + ) + + + // MARK: - Interface + + /// Converts the NWC request into a raw Nostr event to be sent in the network + /// + /// - Parameters: + /// - to_pk: The destination pubkey (used for encryption) + /// - keypair: The requester's pubkey (used for encryption and signing) + /// - Returns: The NWC request in a raw Nostr Event format, or nil if it cannot be encoded + func to_nostr_event(to_pk: Pubkey, keypair: FullKeypair) -> NostrEvent? { + let tags = [to_pk.tag] + let created_at = UInt32(Date().timeIntervalSince1970) + guard let content = encode_json(self) else { + return nil + } + return NIP04.create_encrypted_event(content, to_pk: to_pk, tags: tags, keypair: keypair, created_at: created_at, kind: NostrKind.nwc_request.rawValue) + } + + // MARK: - Encoding and decoding + + /// Keys for top-level JSON + private enum CodingKeys: String, CodingKey { + case method + case params + } + + /// Keys for the JSON inside the "params" object + private enum ParamKeys: String, CodingKey { + case invoice + case from, until, limit, offset, unpaid, type + } + + /// Constants for possible request "method" verbs + private enum Method: String { + case payInvoice = "pay_invoice" + case getBalance = "get_balance" + case listTransactions = "list_transactions" + } + + /// Decodes a payload into this request structure + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let method = try container.decode(String.self, forKey: .method) + + + switch method { + case Method.payInvoice.rawValue: + let paramsContainer = try container.nestedContainer(keyedBy: ParamKeys.self, forKey: .params) + let invoice = try paramsContainer.decode(String.self, forKey: .invoice) + self = .payInvoice(invoice: invoice) + + case Method.getBalance.rawValue: + // No params to decode + self = .getBalance + + case Method.listTransactions.rawValue: + let paramsContainer = try container.nestedContainer(keyedBy: ParamKeys.self, forKey: .params) + let from = try paramsContainer.decodeIfPresent(UInt64.self, forKey: .from) + let until = try paramsContainer.decodeIfPresent(UInt64.self, forKey: .until) + let limit = try paramsContainer.decodeIfPresent(Int.self, forKey: .limit) + let offset = try paramsContainer.decodeIfPresent(Int.self, forKey: .offset) + let unpaid = try paramsContainer.decodeIfPresent(Bool.self, forKey: .unpaid) + let type = try paramsContainer.decodeIfPresent(String.self, forKey: .type) + self = .getTransactionList(from: from, until: until, limit: limit, offset: offset, unpaid: unpaid, type: type) + + default: + throw DecodingError.dataCorruptedError( + forKey: .method, + in: container, + debugDescription: "Unknown wallet method \"\(method)\"" + ) + } + } + + /// Encodes this request structure into a payload + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + + switch self { + case .payInvoice(let invoice): + try container.encode(Method.payInvoice.rawValue, forKey: .method) + var paramsContainer = container.nestedContainer(keyedBy: ParamKeys.self, forKey: .params) + try paramsContainer.encode(invoice, forKey: .invoice) + + case .getBalance: + try container.encode(Method.getBalance.rawValue, forKey: .method) + // "params": null + try container.encodeNil(forKey: .params) + + case .getTransactionList(let from, let until, let limit, let offset, let unpaid, let type): + try container.encode(Method.listTransactions.rawValue, forKey: .method) + var paramsContainer = container.nestedContainer(keyedBy: ParamKeys.self, forKey: .params) + try paramsContainer.encodeIfPresent(from, forKey: .from) + try paramsContainer.encodeIfPresent(until, forKey: .until) + try paramsContainer.encodeIfPresent(limit, forKey: .limit) + try paramsContainer.encodeIfPresent(offset, forKey: .offset) + try paramsContainer.encodeIfPresent(unpaid, forKey: .unpaid) + try paramsContainer.encodeIfPresent(type, forKey: .type) + } + } + } +} diff --git a/damus/Util/WalletConnect/Response.swift b/damus/Util/WalletConnect/Response.swift new file mode 100644 index 00000000..1640d91a --- /dev/null +++ b/damus/Util/WalletConnect/Response.swift @@ -0,0 +1,110 @@ +// +// Response.swift +// damus +// +// Created by Daniel D’Aquino on 2025-03-10. +// + +extension WalletConnect { + /// Models a response from the NWC provider + struct Response: Decodable { + let result_type: Response.Result.ResultType + let error: WalletResponseErr? + let result: Response.Result? + + private enum CodingKeys: CodingKey { + case result_type, error, result + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let result_type_str = try container.decode(String.self, forKey: .result_type) + + guard let result_type = Response.Result.ResultType(rawValue: result_type_str) else { + throw DecodingError.typeMismatch(Response.Result.ResultType.self, .init(codingPath: decoder.codingPath, debugDescription: "result_type \(result_type_str) is unknown")) + } + + self.result_type = result_type + self.error = try container.decodeIfPresent(WalletResponseErr.self, forKey: .error) + + guard self.error == nil else { + self.result = nil + return + } + + switch result_type { + case .pay_invoice: + let res = try container.decode(Result.PayInvoiceResponse.self, forKey: .result) + self.result = .pay_invoice(res) + case .get_balance: + let res = try container.decode(Result.GetBalanceResponse.self, forKey: .result) + self.result = .get_balance(res) + case .list_transactions: + let res = try container.decode(Result.ListTransactionsResponse.self, forKey: .result) + self.result = .list_transactions(res) + } + } + } + + struct FullWalletResponse { + let req_id: NoteId + let response: Response + + init?(from: NostrEvent, nwc: WalletConnect.ConnectURL) async { + guard let note_id = from.referenced_ids.first else { + return nil + } + + self.req_id = note_id + + let ares = Task { + guard let json = decrypt_dm(nwc.keypair.privkey, pubkey: nwc.pubkey, content: from.content, encoding: .base64), + let resp: WalletConnect.Response = decode_json(json) + else { + let resp: WalletConnect.Response? = nil + return resp + } + + return resp + } + + guard let res = await ares.value else { + return nil + } + + self.response = res + } + } + + struct WalletResponseErr: Codable { + let code: String? + let message: String? + } +} + +extension WalletConnect.Response { + /// The response data resulting from an NWC request + enum Result { + case pay_invoice(PayInvoiceResponse) + case get_balance(GetBalanceResponse) + case list_transactions(ListTransactionsResponse) + + enum ResultType: String { + case pay_invoice + case get_balance + case list_transactions + } + + struct PayInvoiceResponse: Decodable { + let preimage: String + } + + struct GetBalanceResponse: Decodable { + let balance: Int64 + } + + struct ListTransactionsResponse: Decodable { + let transactions: [WalletConnect.Transaction] + } + } +} diff --git a/damus/Util/WalletConnect/WalletConnect+.swift b/damus/Util/WalletConnect/WalletConnect+.swift new file mode 100644 index 00000000..94128339 --- /dev/null +++ b/damus/Util/WalletConnect/WalletConnect+.swift @@ -0,0 +1,170 @@ +// +// WalletConnect+.swift +// damus +// +// Created by Daniel D’Aquino on 2023-11-27. +// + +import Foundation + +// TODO: Eventually we should move these convenience functions into structured classes responsible for managing this type of functionality, such as `WalletModel` + +extension WalletConnect { + /// Creates and sends a subscription to an NWC relay requesting NWC responses to be sent back. + /// + /// Notes: This assumes there is already a listener somewhere else + /// + /// - Parameters: + /// - url: The Nostr Wallet Connect URL containing connection info to the NWC wallet + /// - pool: The RelayPool to send the subscription request through + static func subscribe(url: WalletConnectURL, pool: RelayPool) { + var filter = NostrFilter(kinds: [.nwc_response]) + filter.authors = [url.pubkey] + filter.limit = 0 + let sub = NostrSubscribe(filters: [filter], sub_id: "nwc") + + pool.send(.subscribe(sub), to: [url.relay], skip_ephemeral: false) + } + + /// Sends out a request to pay an invoice to the NWC relay, and ensures that: + /// 1. the NWC relay is connected and we are listening to NWC events + /// 2. the NWC relay is connected and we are listening to NWC + /// + /// Note: This does not return information about whether the payment is succesful or not. The actual confirmation is handled elsewhere around `HomeModel` and `WalletModel` + /// + /// - Parameters: + /// - url: The NWC wallet connection URL + /// - pool: The relay pool to connect to + /// - post: The postbox to send events in + /// - delay: The delay before actually sending the request to the network _(this makes it possible to cancel a zap)_ + /// - on_flush: A callback to call after the event has been flushed to the network + /// - Returns: The Nostr Event that was sent to the network, representing the request that was made + @discardableResult + static func pay(url: WalletConnectURL, pool: RelayPool, post: PostBox, invoice: String, delay: TimeInterval? = 5.0, on_flush: OnFlush? = nil) -> NostrEvent? { + let req = WalletConnect.Request.payInvoice(invoice: invoice) + guard let ev = req.to_nostr_event(to_pk: url.pubkey, keypair: url.keypair) else { + return nil + } + + try? pool.add_relay(.nwc(url: url.relay)) // Ensure the NWC relay is connected + WalletConnect.subscribe(url: url, pool: pool) // Ensure we are listening to NWC updates from the relay + post.send(ev, to: [url.relay], skip_ephemeral: false, delay: delay, on_flush: on_flush) + return ev + } + + /// Sends out a wallet balance request to the NWC relay, and ensures that: + /// 1. the NWC relay is connected and we are listening to NWC events + /// 2. the NWC relay is connected and we are listening to NWC + /// + /// Note: This does not return the actual balance information. The actual balance is handled elsewhere around `HomeModel` and `WalletModel` + /// + /// - Parameters: + /// - url: The NWC wallet connection URL + /// - pool: The relay pool to connect to + /// - post: The postbox to send events in + /// - delay: The delay before actually sending the request to the network + /// - on_flush: A callback to call after the event has been flushed to the network + /// - Returns: The Nostr Event that was sent to the network, representing the request that was made + @discardableResult + static func request_balance_information(url: WalletConnectURL, pool: RelayPool, post: PostBox, delay: TimeInterval? = 0.0, on_flush: OnFlush? = nil) -> NostrEvent? { + let req = WalletConnect.Request.getBalance + guard let ev = req.to_nostr_event(to_pk: url.pubkey, keypair: url.keypair) else { + return nil + } + + try? pool.add_relay(.nwc(url: url.relay)) // Ensure the NWC relay is connected + WalletConnect.subscribe(url: url, pool: pool) // Ensure we are listening to NWC updates from the relay + post.send(ev, to: [url.relay], skip_ephemeral: false, delay: delay, on_flush: on_flush) + return ev + } + + /// Sends out a wallet transaction list request to the NWC relay, and ensures that: + /// 1. the NWC relay is connected and we are listening to NWC events + /// 2. the NWC relay is connected and we are listening to NWC + /// + /// Note: This does not return the actual transaction list. The actual transaction list is handled elsewhere around `HomeModel` and `WalletModel` + /// + /// - Parameters: + /// - url: The NWC wallet connection URL + /// - pool: The relay pool to connect to + /// - post: The postbox to send events in + /// - delay: The delay before actually sending the request to the network + /// - on_flush: A callback to call after the event has been flushed to the network + /// - Returns: The Nostr Event that was sent to the network, representing the request that was made + @discardableResult + static func request_transaction_list(url: WalletConnectURL, pool: RelayPool, post: PostBox, delay: TimeInterval? = 0.0, on_flush: OnFlush? = nil) -> NostrEvent? { + let req = WalletConnect.Request.getTransactionList(from: nil, until: nil, limit: 10, offset: 0, unpaid: false, type: "") + guard let ev = req.to_nostr_event(to_pk: url.pubkey, keypair: url.keypair) else { + return nil + } + + try? pool.add_relay(.nwc(url: url.relay)) // Ensure the NWC relay is connected + WalletConnect.subscribe(url: url, pool: pool) // Ensure we are listening to NWC updates from the relay + post.send(ev, to: [url.relay], skip_ephemeral: false, delay: delay, on_flush: on_flush) + return ev + } + + static func handle_zap_success(state: DamusState, resp: WalletConnect.FullWalletResponse) { + // find the pending zap and mark it as pending-confirmed + for kv in state.zaps.our_zaps { + let zaps = kv.value + + for zap in zaps { + guard case .pending(let pzap) = zap, + case .nwc(let nwc_state) = pzap.state, + case .postbox_pending(let nwc_req) = nwc_state.state, + nwc_req.id == resp.req_id + else { + continue + } + + if nwc_state.update_state(state: .confirmed) { + // notify the zaps model of an update so it can mark them as paid + state.events.get_cache_data(NoteId(pzap.target.id)).zaps_model.objectWillChange.send() + print("NWC success confirmed") + } + + return + } + } + } + + /// Send a donation zap to the Damus team + static func send_donation_zap(pool: RelayPool, postbox: PostBox, nwc: WalletConnectURL, percent: Int, base_msats: Int64) async { + let percent_f = Double(percent) / 100.0 + let donations_msats = Int64(percent_f * Double(base_msats)) + + let payreq = LNUrlPayRequest(allowsNostr: true, commentAllowed: nil, nostrPubkey: "", callback: "https://sendsats.lol/@damus") + guard let invoice = await fetch_zap_invoice(payreq, zapreq: nil, msats: donations_msats, zap_type: .non_zap, comment: nil) else { + // we failed... oh well. no donation for us. + print("damus-donation failed to fetch invoice") + return + } + + print("damus-donation donating...") + WalletConnect.pay(url: nwc, pool: pool, post: postbox, invoice: invoice, delay: nil) + } + + /// Handles a received Nostr Wallet Connect error + static func handle_error(zapcache: Zaps, evcache: EventCache, resp: WalletConnect.FullWalletResponse) { + // find a pending zap with the nwc request id associated with this response and remove it + for kv in zapcache.our_zaps { + let zaps = kv.value + + for zap in zaps { + guard case .pending(let pzap) = zap, + case .nwc(let nwc_state) = pzap.state, + case .postbox_pending(let req) = nwc_state.state, + req.id == resp.req_id + else { + continue + } + + // remove the pending zap if there was an error + let reqid = ZapRequestId(from_pending: pzap) + remove_zap(reqid: reqid, zapcache: zapcache, evcache: evcache) + return + } + } + } +} diff --git a/damus/Util/WalletConnect/WalletConnect.swift b/damus/Util/WalletConnect/WalletConnect.swift new file mode 100644 index 00000000..7bacf3be --- /dev/null +++ b/damus/Util/WalletConnect/WalletConnect.swift @@ -0,0 +1,92 @@ +// +// WalletConnect.swift +// damus +// +// Created by William Casarin on 2023-03-22. +// + +import Foundation + +struct WalletConnect {} + +typealias WalletConnectURL = WalletConnect.ConnectURL // Declared to facilitate refactor + +extension WalletConnect { + /// Models a decoded NWC URL, containing information to connect to an NWC wallet. + struct ConnectURL: Equatable { + let relay: RelayURL + let keypair: FullKeypair + let pubkey: Pubkey + let lud16: String? + + static func == (lhs: ConnectURL, rhs: ConnectURL) -> Bool { + return lhs.keypair == rhs.keypair && + lhs.pubkey == rhs.pubkey && + lhs.relay == rhs.relay + } + + func to_url() -> URL { + var urlComponents = URLComponents() + urlComponents.scheme = "nostrwalletconnect" + urlComponents.host = pubkey.hex() + urlComponents.queryItems = [ + URLQueryItem(name: "relay", value: relay.absoluteString), + URLQueryItem(name: "secret", value: keypair.privkey.hex()) + ] + + if let lud16 { + urlComponents.queryItems?.append(URLQueryItem(name: "lud16", value: lud16)) + } + + return urlComponents.url! + } + + init?(str: String) { + guard let components = URLComponents(string: str), + components.scheme == "nostrwalletconnect" || components.scheme == "nostr+walletconnect", + // The line below provides flexibility for both `nostrwalletconnect://` (non-compliant, but commonly used) and `nostrwalletconnect:` (NIP-47 compliant) formats + let encoded_pubkey = components.path == "" ? components.host : components.path, + let pubkey = hex_decode_pubkey(encoded_pubkey), + let items = components.queryItems, + let relay = items.first(where: { qi in qi.name == "relay" })?.value, + let relay_url = RelayURL(relay), + let secret = items.first(where: { qi in qi.name == "secret" })?.value, + secret.utf8.count == 64, + let decoded = hex_decode(secret) + else { + return nil + } + + let privkey = Privkey(Data(decoded)) + guard let our_pk = privkey_to_pubkey(privkey: privkey) else { return nil } + + let lud16 = items.first(where: { qi in qi.name == "lud16" })?.value + let keypair = FullKeypair(pubkey: our_pk, privkey: privkey) + self = ConnectURL(pubkey: pubkey, relay: relay_url, keypair: keypair, lud16: lud16) + } + + init(pubkey: Pubkey, relay: RelayURL, keypair: FullKeypair, lud16: String?) { + self.pubkey = pubkey + self.relay = relay + self.keypair = keypair + self.lud16 = lud16 + } + } + + /// Models an NWC wallet transaction + struct Transaction: Decodable, Equatable, Hashable { + let type: String + let invoice: String? + let description: String? + let description_hash: String? + let preimage: String? + let payment_hash: String? + let amount: Int64 + let fees_paid: Int64? + let created_at: UInt64 // unixtimestamp, // invoice/payment creation time + let expires_at: UInt64? // unixtimestamp, // invoice expiration time, optional if not applicable + let settled_at: UInt64? // unixtimestamp, // invoice/payment settlement time, optional if unpaid + //"metadata": {} // generic metadata that can be used to add things like zap/boostagram details for a payer name/comment/etc. + } +} + diff --git a/damus/Views/DMChatView.swift b/damus/Views/DMChatView.swift index 2a39cf09..a15f2640 100644 --- a/damus/Views/DMChatView.swift +++ b/damus/Views/DMChatView.swift @@ -131,7 +131,7 @@ struct DMChatView: View, KeyboardReadable { .map(\.asString) .joined(separator: "") - guard let dm = create_dm(content, to_pk: pubkey, tags: tags, keypair: damus_state.keypair) else { + guard let dm = NIP04.create_dm(content, to_pk: pubkey, tags: tags, keypair: damus_state.keypair) else { print("error creating dm") return } @@ -176,46 +176,6 @@ struct DMChatView_Previews: PreviewProvider { } } -func encrypt_message(message: String, privkey: Privkey, to_pk: Pubkey, encoding: EncEncoding = .base64) -> String? { - let iv = random_bytes(count: 16).bytes - guard let shared_sec = get_shared_secret(privkey: privkey, pubkey: to_pk) else { - return nil - } - let utf8_message = Data(message.utf8).bytes - guard let enc_message = aes_encrypt(data: utf8_message, iv: iv, shared_sec: shared_sec) else { - return nil - } - - switch encoding { - case .base64: - return encode_dm_base64(content: enc_message.bytes, iv: iv) - case .bech32: - return encode_dm_bech32(content: enc_message.bytes, iv: iv) - } - -} - -func create_encrypted_event(_ message: String, to_pk: Pubkey, tags: [[String]], keypair: FullKeypair, created_at: UInt32, kind: UInt32) -> NostrEvent? { - let privkey = keypair.privkey - - guard let enc_content = encrypt_message(message: message, privkey: privkey, to_pk: to_pk) else { - return nil - } - - return NostrEvent(content: enc_content, keypair: keypair.to_keypair(), kind: kind, tags: tags, createdAt: created_at) -} - -func create_dm(_ message: String, to_pk: Pubkey, tags: [[String]], keypair: Keypair, created_at: UInt32? = nil) -> NostrEvent? -{ - let created = created_at ?? UInt32(Date().timeIntervalSince1970) - - guard let keypair = keypair.to_full() else { - return nil - } - - return create_encrypted_event(message, to_pk: to_pk, tags: tags, keypair: keypair, created_at: created, kind: 4) -} - extension View { /// Layers the given views behind this ``TextEditor``. func textEditorBackground(@ViewBuilder _ content: () -> V) -> some View where V : View { diff --git a/damus/Views/Wallet/BalanceView.swift b/damus/Views/Wallet/BalanceView.swift new file mode 100644 index 00000000..93b236f9 --- /dev/null +++ b/damus/Views/Wallet/BalanceView.swift @@ -0,0 +1,56 @@ +// +// BalanceView.swift +// damus +// +// Created by eric on 1/23/25. +// + +import SwiftUI + +struct BalanceView: View { + var balance: Int64? + + var body: some View { + VStack(spacing: 5) { + Text("Current balance", comment: "Label for displaying current wallet balance") + .foregroundStyle(DamusColors.neutral6) + if let balance { + self.numericalBalanceView(text: NumberFormatter.localizedString(from: NSNumber(integerLiteral: Int(balance)), number: .decimal)) + } + else { + // Make sure we do not show any numeric value to the user when still loading (or when failed to load) + // This is important because if we show a numeric value like "zero" when things are not loaded properly, we risk scaring the user into thinking that they have lost funds. + self.numericalBalanceView(text: "??") + .redacted(reason: .placeholder) + .shimmer(true) + } + } + } + + func numericalBalanceView(text: String) -> some View { + HStack { + Text(verbatim: text) + .lineLimit(1) + .minimumScaleFactor(0.70) + .font(.veryVeryLargeTitle) + .fontWeight(.heavy) + .foregroundStyle(PinkGradient) + + HStack(alignment: .top) { + Text("SATS", comment: "Abbreviation for Satoshis, smallest bitcoin unit") + .font(.caption) + .fontWeight(.heavy) + .foregroundStyle(PinkGradient) + } + } + .padding(.bottom) + } +} + +struct BalanceView_Previews: PreviewProvider { + static var previews: some View { + BalanceView(balance: 100000000) + BalanceView(balance: nil) + } +} + diff --git a/damus/Views/Wallet/ConnectWalletView.swift b/damus/Views/Wallet/ConnectWalletView.swift index 99e27064..01beeb3d 100644 --- a/damus/Views/Wallet/ConnectWalletView.swift +++ b/damus/Views/Wallet/ConnectWalletView.swift @@ -15,13 +15,13 @@ struct ConnectWalletView: View { @State private var showAlert = false @State var error: String? = nil @State var wallet_scan_result: WalletScanResult = .scanning + @State var show_introduction: Bool = true var nav: NavigationCoordinator var body: some View { MainContent .navigationTitle(NSLocalizedString("Wallet", comment: "Navigation title for attaching Nostr Wallet Connect lightning wallet.")) .navigationBarTitleDisplayMode(.inline) - .padding() .onChange(of: wallet_scan_result) { res in scanning = false @@ -48,57 +48,137 @@ struct ConnectWalletView: View { } } - func AreYouSure(nwc: WalletConnectURL) -> some View { - VStack(spacing: 25) { - - Text("Are you sure you want to connect this wallet?", comment: "Prompt to ask user if they want to attach their Nostr Wallet Connect lightning wallet.") - .fontWeight(.bold) - .multilineTextAlignment(.center) - - Text(nwc.relay.absoluteString) - .font(.body) - .foregroundColor(.gray) - - if let lud16 = nwc.lud16 { - Text(lud16) - .font(.body) - .foregroundColor(.gray) - } - - Button(action: { - model.connect(nwc) - }) { - HStack { - Text("Connect", comment: "Text for button to conect to Nostr Wallet Connect lightning wallet.") - .fontWeight(.semibold) + struct AreYouSure: View { + let nwc: WalletConnectURL + @Binding var show_introduction: Bool + @ObservedObject var model: WalletModel + + var body: some View { + ScrollView { + VStack(spacing: 25) { + + Text("Setup Wallet", comment: "Heading for wallet setup confirmation screen") + .font(.veryLargeTitle) + .fontWeight(.bold) + .multilineTextAlignment(.center) + + Spacer() + + ConnectGraphic + + Spacer() + + NWCSettings.AccountDetailsView(nwc: nwc) + + Spacer() + + Button(action: { + model.connect(nwc) + show_introduction = false + }) { + HStack { + Text("Connect", comment: "Text for button to conect to Nostr Wallet Connect lightning wallet.") + .fontWeight(.semibold) + } + .frame(minWidth: 300, maxWidth: .infinity, maxHeight: 18, alignment: .center) + } + .buttonStyle(GradientButtonStyle()) + + Button(action: { + model.cancel() + show_introduction = true + }) { + HStack { + Text("Cancel", comment: "Text for button to cancel out of connecting Nostr Wallet Connect lightning wallet.") + .padding() + } + .frame(minWidth: 300, maxWidth: .infinity, alignment: .center) + } + .buttonStyle(NeutralButtonStyle()) } - .frame(minWidth: 300, maxWidth: .infinity, maxHeight: 18, alignment: .center) + .padding(.bottom, 50) + .padding() } - .buttonStyle(GradientButtonStyle()) - - Button(action: { - model.cancel() - }) { - HStack { - Text("Cancel", comment: "Text for button to cancel out of connecting Nostr Wallet Connect lightning wallet.") - .padding() - } - .frame(minWidth: 300, maxWidth: .infinity, alignment: .center) + } + + var ConnectGraphic: some View { + HStack(spacing: 0) { + Button(action: {}, label: { + Image("damus-home") + .resizable() + .frame(width: 30, height: 30) + }) + .buttonStyle(NeutralButtonStyle(padding: EdgeInsets(top: 15, leading: 15, bottom: 15, trailing: 15), cornerRadius: 9999)) + .disabled(true) + .padding(.horizontal, 30) + + Image("chevron-double-right") + .resizable() + .frame(width: 25, height: 25) + + Button(action: {}, label: { + Image("wallet") + .resizable() + .frame(width: 30, height: 30) + .foregroundStyle(LINEAR_GRADIENT) + }) + .buttonStyle(NeutralButtonStyle(padding: EdgeInsets(top: 15, leading: 15, bottom: 15, trailing: 15), cornerRadius: 9999)) + .disabled(true) + .padding(.horizontal, 30) } - .buttonStyle(NeutralButtonStyle()) } } - var ConnectWallet: some View { - VStack(spacing: 25) { + var AutomaticSetup: some View { + VStack(spacing: 10) { + Text("AUTOMATIC SETUP", comment: "Heading for the section that performs an automatic wallet connection setup.") + .font(.caption) + .padding(.top) + .foregroundStyle(PinkGradient) - AlbyButton() { - openURL(URL(string:"https://nwc.getalby.com/apps/new?c=Damus")!) - } + Text("Create new wallet", comment: "Button text for creating a new wallet.") + .font(.title) + .fontWeight(.bold) + + Text("Easily create a new wallet and attach it to your account.", comment: "Description for the create new wallet feature.") + .font(.body) + .multilineTextAlignment(.center) + + Spacer() CoinosButton() { + show_introduction = false openURL(URL(string:"https://coinos.io/settings/nostr")!) } + .padding() + } + .frame(minHeight: 250) + .padding(10) + .cornerRadius(10) + .overlay( + RoundedRectangle(cornerRadius: 25) + .stroke(DamusColors.neutral3, lineWidth: 2) + .padding(2) // Avoids border clipping on the sides + ) + .padding(.top, 20) + } + + var ManualSetup: some View { + VStack(spacing: 10) { + Text("MANUAL SETUP", comment: "Label for manual wallet setup.") + .font(.caption) + .padding(.top) + .foregroundStyle(PinkGradient) + + Text("Use existing", comment: "Button text to use an existing wallet.") + .font(.title) + .fontWeight(.bold) + + Text("Attach to any third party provider you already use.", comment: "Information text guiding users on attaching existing provider.") + .font(.body) + .multilineTextAlignment(.center) + + Spacer() Button(action: { if let pasted_nwc = UIPasteboard.general.string { @@ -115,9 +195,10 @@ struct ConnectWalletView: View { Text("Paste NWC Address", comment: "Text for button to connect a lightning wallet.") .fontWeight(.semibold) } - .frame(minWidth: 300, maxWidth: .infinity, maxHeight: 18, alignment: .center) + .frame(minWidth: 250, maxWidth: .infinity, maxHeight: 15, alignment: .center) } .buttonStyle(GradientButtonStyle()) + .padding(.horizontal) Button(action: { nav.push(route: Route.WalletScanner(result: $wallet_scan_result)) @@ -127,74 +208,80 @@ struct ConnectWalletView: View { Text("Scan NWC Address", comment: "Text for button to connect a lightning wallet.") .fontWeight(.semibold) } - .frame(minWidth: 300, maxWidth: .infinity, maxHeight: 18, alignment: .center) + .frame(minWidth: 250, maxWidth: .infinity, maxHeight: 15, alignment: .center) } .buttonStyle(GradientButtonStyle()) - - - if let err = self.error { - Text(err) - .foregroundColor(.red) + .padding(.horizontal) + .padding(.bottom) + } + .frame(minHeight: 300) + .padding(10) + .cornerRadius(10) + .overlay( + RoundedRectangle(cornerRadius: 25) + .stroke(DamusColors.neutral3, lineWidth: 2) + .padding(2) // Avoids border clipping on the sides + ) + .padding(.top, 20) + } + + var ConnectWallet: some View { + ScrollView { + VStack(spacing: 25) { + + Text("Setup Wallet", comment: "Heading for Nostr Wallet Connect setup screen") + .font(.veryLargeTitle) + .fontWeight(.bold) + .multilineTextAlignment(.center) + + AutomaticSetup + + ManualSetup + + if let err = self.error { + Text(err) + .foregroundColor(.red) + } } - } - } - - var TopSection: some View { - HStack(spacing: 0) { - Button(action: {}, label: { - Image("damus-home") - .resizable() - .frame(width: 30, height: 30) - }) - .buttonStyle(NeutralButtonStyle(padding: EdgeInsets(top: 15, leading: 15, bottom: 15, trailing: 15), cornerRadius: 9999)) - .disabled(true) - .padding(.horizontal, 30) - - Image("chevron-double-right") - .resizable() - .frame(width: 25, height: 25) - - Button(action: {}, label: { - Image("wallet") - .resizable() - .frame(width: 30, height: 30) - .foregroundStyle(LINEAR_GRADIENT) - }) - .buttonStyle(NeutralButtonStyle(padding: EdgeInsets(top: 15, leading: 15, bottom: 15, trailing: 15), cornerRadius: 9999)) - .disabled(true) - .padding(.horizontal, 30) - } - } - - var TitleSection: some View { - VStack(spacing: 25) { - Text("Damus Wallet", comment: "Title text for Damus Wallet view.") - .fontWeight(.bold) - - Text("Securely connect your Damus app to your wallet using Nostr Wallet Connect", comment: "Text to prompt user to connect their wallet using 'Nostr Wallet Connect'.") - .font(.caption) - .multilineTextAlignment(.center) + .padding(.bottom, 50) + .padding() } } var MainContent: some View { Group { - TopSection switch model.connect_state { case .new(let nwc): - AreYouSure(nwc: nwc) + AreYouSure(nwc: nwc, show_introduction: $show_introduction, model: self.model) + .onAppear() { + show_introduction = false + } case .existing: Text(verbatim: "Shouldn't happen") case .none: - TitleSection ConnectWallet } } + .fullScreenCover(isPresented: $show_introduction, content: { + ZapExplainerView(show_introduction: $show_introduction, nav: nav) + }) } } struct ConnectWalletView_Previews: PreviewProvider { static var previews: some View { ConnectWalletView(model: WalletModel(settings: UserSettingsStore()), nav: .init()) + .previewDisplayName("Main Wallet Connect View") + ConnectWalletView.AreYouSure(nwc: get_test_nwc(), show_introduction: .constant(false), model: WalletModel(settings: test_damus_state.settings)) + .previewDisplayName("Are you sure screen") + } + + static func get_test_nwc() -> WalletConnectURL { + let pk = "9d088f4760422443d4699b485e2ac66e565a2f5da1198c55ddc5679458e3f67a" + let sec = "ff2eefd57196d42089e1b42acc39916d7ecac52e0625bd70597bbd5be14aff18" + let relay = "wss://relay.getalby.com/v1" + let str = "nostrwalletconnect://\(pk)?relay=\(relay)&secret=\(sec)" + + return WalletConnectURL(str: str)! } } diff --git a/damus/Views/Wallet/NWCSettings.swift b/damus/Views/Wallet/NWCSettings.swift new file mode 100644 index 00000000..95461efe --- /dev/null +++ b/damus/Views/Wallet/NWCSettings.swift @@ -0,0 +1,227 @@ +// +// NWCSettings.swift +// damus +// +// Created by eric on 1/24/25. +// + +import SwiftUI + +struct NWCSettings: View { + + let damus_state: DamusState + let nwc: WalletConnectURL + @ObservedObject var model: WalletModel + @ObservedObject var settings: UserSettingsStore + + + func donation_binding() -> Binding { + return Binding(get: { + return Double(model.settings.donation_percent) + }, set: { v in + model.settings.donation_percent = Int(v) + }) + } + + static let min_donation: Double = 0.0 + static let max_donation: Double = 100.0 + + var percent: Double { + Double(model.settings.donation_percent) / 100.0 + } + + var tip_msats: String { + let msats = Int64(percent * Double(model.settings.default_zap_amount * 1000)) + let s = format_msats_abbrev(msats) + // TODO: fix formatting and remove this hack + let parts = s.split(separator: ".") + if parts.count == 1 { + return s + } + if let end = parts[safe: 1] { + if end.allSatisfy({ c in c.isNumber }) { + return String(parts[0]) + } else { + return s + } + } + return s + } + + var SupportDamus: some View { + ZStack(alignment: .topLeading) { + RoundedRectangle(cornerRadius: 20) + .fill(DamusGradient.gradient.opacity(0.5)) + + VStack(alignment: .leading, spacing: 20) { + HStack { + Image("logo-nobg") + .resizable() + .frame(width: 50, height: 50) + Text("Support Damus", comment: "Text calling for the user to support Damus through zaps") + .font(.title.bold()) + .foregroundColor(.white) + } + + Text("Help build the future of decentralized communication on the web.", comment: "Text indicating the goal of developing Damus which the user can help with.") + .fixedSize(horizontal: false, vertical: true) + .foregroundColor(.white) + + Text("An additional percentage of each zap will be sent to support Damus development", comment: "Text indicating that they can contribute zaps to support Damus development.") + .fixedSize(horizontal: false, vertical: true) + .foregroundColor(.white) + + let binding = donation_binding() + + HStack { + Slider(value: binding, + in: NWCSettings.min_donation...NWCSettings.max_donation, + label: { }) + Text("\(Int(binding.wrappedValue))%", comment: "Percentage of additional zap that should be sent to support Damus development.") + .font(.title.bold()) + .foregroundColor(.white) + .frame(width: 80) + } + + HStack{ + Spacer() + + VStack { + HStack { + Text("\(Image("zap.fill")) \(format_msats_abbrev(Int64(model.settings.default_zap_amount) * 1000))") + .font(.title) + .foregroundColor(percent == 0 ? .gray : .yellow) + .frame(width: 120) + } + + Text("Zap", comment: "Text underneath the number of sats indicating that it's the amount used for zaps.") + .foregroundColor(.white) + } + Spacer() + + Text(verbatim: "+") + .font(.title) + .foregroundColor(.white) + Spacer() + + VStack { + HStack { + Text("\(Image("zap.fill")) \(tip_msats)") + .font(.title) + .foregroundColor(percent == 0 ? .gray : Color.yellow) + .frame(width: 120) + } + + Text(verbatim: percent == 0 ? "🩶" : "💜") + .foregroundColor(.white) + } + Spacer() + } + + EventProfile(damus_state: damus_state, pubkey: damus_state.pubkey, size: .small) + } + .padding(25) + } + .frame(height: 370) + } + + var body: some View { + + VStack(alignment: .leading, spacing: 20) { + + SupportDamus + .padding(.bottom) + + AccountDetailsView(nwc: nwc) + + Button(action: { + self.model.disconnect() + }) { + HStack { + Text("Disconnect Wallet", comment: "Text for button to disconnect from Nostr Wallet Connect lightning wallet.") + } + .frame(minWidth: 300, maxWidth: .infinity, maxHeight: 18, alignment: .center) + } + .buttonStyle(GradientButtonStyle()) + } + .padding() + .onAppear() { + model.initial_percent = model.settings.donation_percent + } + .onChange(of: model.settings.donation_percent) { p in + let profile_txn = damus_state.profiles.lookup(id: damus_state.pubkey) + guard let profile = profile_txn?.unsafeUnownedValue else { + return + } + + let prof = Profile(name: profile.name, display_name: profile.display_name, about: profile.about, picture: profile.picture, banner: profile.banner, website: profile.website, lud06: profile.lud06, lud16: profile.lud16, nip05: profile.nip05, damus_donation: p, reactions: profile.reactions) + + notify(.profile_updated(.manual(pubkey: self.damus_state.pubkey, profile: prof))) + } + .onDisappear { + let profile_txn = damus_state.profiles.lookup(id: damus_state.pubkey) + + guard let keypair = damus_state.keypair.to_full(), + let profile = profile_txn?.unsafeUnownedValue, + model.initial_percent != profile.damus_donation + else { + return + } + + let prof = Profile(name: profile.name, display_name: profile.display_name, about: profile.about, picture: profile.picture, banner: profile.banner, website: profile.website, lud06: profile.lud06, lud16: profile.lud16, nip05: profile.nip05, damus_donation: model.settings.donation_percent, reactions: profile.reactions) + + guard let meta = make_metadata_event(keypair: keypair, metadata: prof) else { + return + } + damus_state.postbox.send(meta) + } + } + + struct AccountDetailsView: View { + let nwc: WalletConnect.ConnectURL + + var body: some View { + VStack(alignment: .leading) { + + Text("Account details", comment: "Prompt to ask user if they want to attach their Nostr Wallet Connect lightning wallet.") + .font(.title2) + .fontWeight(.bold) + .multilineTextAlignment(.center) + .padding(.bottom) + + Text("Routing", comment: "Label indicating the routing address for Nostr Wallet Connect payments. In other words, the relay used by the NWC wallet provider") + .font(.headline) + + Text(nwc.relay.absoluteString) + .font(.body) + .fontWeight(.bold) + .foregroundColor(.gray) + .padding(.bottom) + + if let lud16 = nwc.lud16 { + Text("Account", comment: "Label for the user account information with the Nostr Wallet Connect wallet provider.") + .font(.headline) + + Text(lud16) + .font(.body) + .fontWeight(.bold) + .foregroundColor(.gray) + } + } + .frame(maxWidth: .infinity, minHeight: 250, alignment: .leading) + .padding(.horizontal, 20) + .cornerRadius(10) + .overlay( + RoundedRectangle(cornerRadius: 25) + .stroke(DamusColors.neutral3, lineWidth: 2) + ) + } + } +} + +struct NWCSettings_Previews: PreviewProvider { + static let tds = test_damus_state + static var previews: some View { + NWCSettings(damus_state: tds, nwc: test_wallet_connect_url, model: WalletModel(state: .existing(test_wallet_connect_url), settings: tds.settings), settings: tds.settings) + } +} diff --git a/damus/Views/Wallet/TransactionsView.swift b/damus/Views/Wallet/TransactionsView.swift new file mode 100644 index 00000000..31d3a026 --- /dev/null +++ b/damus/Views/Wallet/TransactionsView.swift @@ -0,0 +1,147 @@ +// +// TransactionsView.swift +// damus +// +// Created by eric on 1/23/25. +// + +import SwiftUI + +struct TransactionView: View { + + let damus_state: DamusState + var transaction: WalletConnect.Transaction + + var body: some View { + let txType = transaction.type == "incoming" ? "arrow-bottom-left" : "arrow-top-right" + let txColor = transaction.type == "incoming" ? DamusColors.success : Color.gray + let txOp = transaction.type == "incoming" ? "+" : "-" + let created_at = Date.init(timeIntervalSince1970: TimeInterval(transaction.created_at)) + let formatter = RelativeDateTimeFormatter() + let relativeDate = formatter.localizedString(for: created_at, relativeTo: Date.now) + let event = decode_nostr_event_json(transaction.description ?? "") + let pubkey = (event?.pubkey ?? ANON_PUBKEY) + + VStack(alignment: .leading) { + HStack(alignment: .center) { + ZStack { + ProfilePicView(pubkey: pubkey, size: 45, highlight: .custom(.damusAdaptableBlack, 0.1), profiles: damus_state.profiles, disable_animation: damus_state.settings.disable_animation) + + Image(txType) + .resizable() + .frame(width: 18, height: 18) + .foregroundColor(.white) + .padding(2) + .background(txColor) + .clipShape(Circle()) + .overlay(Circle().stroke(Color.damusAdaptableWhite, lineWidth: 1.0)) + .padding(.top, 25) + .padding(.leading, 35) + } + + VStack(alignment: .leading, spacing: 10) { + + Text(self.userDisplayName(pubkey: pubkey)) + .font(.headline) + .bold() + .foregroundColor(DamusColors.adaptableBlack) + + Text("\(relativeDate)") + .font(.caption) + .foregroundColor(Color.gray) + } + .padding(.horizontal, 10) + + Spacer() + + Text("\(txOp) \(transaction.amount/1000) sats") + .font(.headline) + .foregroundColor(txColor) + .bold() + } + .frame(maxWidth: .infinity, minHeight: 75, alignment: .center) + .padding(.horizontal, 10) + .background(DamusColors.neutral1) + .cornerRadius(10) + .overlay( + RoundedRectangle(cornerRadius: 10) + .stroke(DamusColors.neutral3, lineWidth: 1) + ) + } + } + + func userDisplayName(pubkey: Pubkey) -> String { + let profile_txn = damus_state.profiles.lookup(id: pubkey, txn_name: "txview-profile") + let profile = profile_txn?.unsafeUnownedValue + + if let display_name = profile?.display_name { + return display_name + } else if let name = profile?.name { + return "@" + name + } else { + return NSLocalizedString("Unknown", comment: "A name label for an unknown user") + } + } + +} + +struct TransactionsView: View { + + let damus_state: DamusState + let transactions: [WalletConnect.Transaction]? + var sortedTransactions: [WalletConnect.Transaction]? { + transactions?.sorted(by: { $0.created_at > $1.created_at }) + } + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + Text("Latest transactions", comment: "Heading for latest wallet transactions list") + .foregroundStyle(DamusColors.neutral6) + + if let sortedTransactions { + if sortedTransactions.isEmpty { + emptyTransactions + } else { + ForEach(sortedTransactions, id: \.self) { transaction in + TransactionView(damus_state: damus_state, transaction: transaction) + } + } + } + else { + // Make sure we do not show "No transactions yet" to the user when still loading (or when failed to load) + // This is important because if we show that when things are not loaded properly, we risk scaring the user into thinking that they have lost funds. + emptyTransactions + .redacted(reason: .placeholder) + .shimmer(true) + } + } + } + + var emptyTransactions: some View { + HStack { + Text("No transactions yet", comment: "Message shown when no transactions are available") + .foregroundStyle(DamusColors.neutral6) + } + .frame(maxWidth: .infinity, minHeight: 75, alignment: .center) + .padding(.horizontal, 10) + .background(DamusColors.neutral1) + .cornerRadius(10) + .overlay( + RoundedRectangle(cornerRadius: 10) + .stroke(DamusColors.neutral3, lineWidth: 1) + ) + } +} + +struct TransactionsView_Previews: PreviewProvider { + static let tds = test_damus_state + static let transaction1: WalletConnect.Transaction = WalletConnect.Transaction(type: "incoming", invoice: "", description: "{\"id\":\"7c0999a5870ca3ba0186a29a8650152b555cee29b53b5b8747d8a3798042d01c\",\"pubkey\":\"b8851a06dfd79d48fc325234a15e9a46a32a0982a823b54cdf82514b9b120ba1\",\"created_at\":1736383715,\"kind\":9734,\"tags\":[[\"p\",\"520830c334a3f79f88cac934580d26f91a7832c6b21fb9625690ea2ed81b5626\"],[\"amount\",\"21000\"],[\"e\",\"a25e152a4cd1b3bbc3d22e8e9315d8ea1f35c227b2f212c7cff18abff36fa208\"],[\"relays\",\"wss://nos.lol\",\"wss://nostr.wine\",\"wss://premium.primal.net\",\"wss://relay.damus.io\",\"wss://relay.nostr.band\",\"wss://relay.nostrarabia.com\"]],\"content\":\"🫡 Onward!\",\"sig\":\"e77d16822fa21b9c2e6b580b51c470588052c14aeb222f08f0e735027e366157c8742a6d5cb850780c2bf44ac63d89b048e5cc56dd47a1bfc740a3173e578f4e\"}", description_hash: "", preimage: "", payment_hash: "1234567890", amount: 21000, fees_paid: 0, created_at: 1737736866, expires_at: 0, settled_at: 0) + static let transaction2: WalletConnect.Transaction = WalletConnect.Transaction(type: "incoming", invoice: "", description: "", description_hash: "", preimage: "", payment_hash: "123456789033", amount: 100000000, fees_paid: 0, created_at: 1737690090, expires_at: 0, settled_at: 0) + static let transaction3: WalletConnect.Transaction = WalletConnect.Transaction(type: "outgoing", invoice: "", description: "", description_hash: "", preimage: "", payment_hash: "123456789042", amount: 303000, fees_paid: 0, created_at: 1737590101, expires_at: 0, settled_at: 0) + static let transaction4: WalletConnect.Transaction = WalletConnect.Transaction(type: "incoming", invoice: "", description: "", description_hash: "", preimage: "", payment_hash: "1234567890662", amount: 720000, fees_paid: 0, created_at: 1737090300, expires_at: 0, settled_at: 0) + static var test_transactions: [WalletConnect.Transaction] = [transaction1, transaction2, transaction3, transaction4] + + static var previews: some View { + TransactionsView(damus_state: tds, transactions: test_transactions) + } +} diff --git a/damus/Views/Wallet/WalletView.swift b/damus/Views/Wallet/WalletView.swift index 949f9fb6..21683c2b 100644 --- a/damus/Views/Wallet/WalletView.swift +++ b/damus/Views/Wallet/WalletView.swift @@ -9,6 +9,7 @@ import SwiftUI struct WalletView: View { let damus_state: DamusState + @State var show_settings: Bool = false @ObservedObject var model: WalletModel @ObservedObject var settings: UserSettingsStore @@ -21,178 +22,20 @@ struct WalletView: View { func MainWalletView(nwc: WalletConnectURL) -> some View { ScrollView { VStack(spacing: 35) { - if !damus_state.settings.nozaps { - SupportDamus - .padding(.vertical, 20) - } - VStack(spacing: 5) { - VStack(spacing: 10) { - Text("Wallet Relay", comment: "Label text indicating that below it is the information about the wallet relay.") - .fontWeight(.semibold) - .padding(.top) - - Divider() - - RelayView(state: damus_state, relay: nwc.relay, showActionButtons: .constant(false), recommended: false) - } - .frame(maxWidth: .infinity, minHeight: 125, alignment: .top) - .padding(.horizontal, 10) - .background(DamusColors.neutral1) - .cornerRadius(10) - .overlay( - RoundedRectangle(cornerRadius: 10) - .stroke(DamusColors.neutral3, lineWidth: 1) - ) - if let lud16 = nwc.lud16 { - VStack(spacing: 10) { - Text("Wallet Address", comment: "Label text indicating that below it is the wallet address.") - .fontWeight(.semibold) - - Divider() - - Text(lud16) - } - .frame(maxWidth: .infinity, minHeight: 75, alignment: .center) - .padding(.horizontal, 10) - .background(DamusColors.neutral1) - .cornerRadius(10) - .overlay( - RoundedRectangle(cornerRadius: 10) - .stroke(DamusColors.neutral3, lineWidth: 1) - ) - } + BalanceView(balance: model.balance) + + TransactionsView(damus_state: damus_state, transactions: model.transactions) } - - Button(action: { - self.model.disconnect() - }) { - HStack { - Text("Disconnect Wallet", comment: "Text for button to disconnect from Nostr Wallet Connect lightning wallet.") - } - .frame(minWidth: 300, maxWidth: .infinity, maxHeight: 18, alignment: .center) - } - .buttonStyle(GradientButtonStyle()) - .padding(.bottom, 50) // Bottom padding while Scrolling - } .navigationTitle(NSLocalizedString("Wallet", comment: "Navigation title for Wallet view")) .navigationBarTitleDisplayMode(.inline) .padding() + .padding(.bottom, 50) } } - - func donation_binding() -> Binding { - return Binding(get: { - return Double(model.settings.donation_percent) - }, set: { v in - model.settings.donation_percent = Int(v) - }) - } - - static let min_donation: Double = 0.0 - static let max_donation: Double = 100.0 - - var percent: Double { - Double(model.settings.donation_percent) / 100.0 - } - - var tip_msats: String { - let msats = Int64(percent * Double(model.settings.default_zap_amount * 1000)) - let s = format_msats_abbrev(msats) - // TODO: fix formatting and remove this hack - let parts = s.split(separator: ".") - if parts.count == 1 { - return s - } - if let end = parts[safe: 1] { - if end.allSatisfy({ c in c.isNumber }) { - return String(parts[0]) - } else { - return s - } - } - return s - } - - var SupportDamus: some View { - ZStack(alignment: .topLeading) { - RoundedRectangle(cornerRadius: 20) - .fill(DamusGradient.gradient.opacity(0.5)) - - VStack(alignment: .leading, spacing: 20) { - HStack { - Image("logo-nobg") - .resizable() - .frame(width: 50, height: 50) - Text("Support Damus", comment: "Text calling for the user to support Damus through zaps") - .font(.title.bold()) - .foregroundColor(.white) - } - - Text("Help build the future of decentralized communication on the web.", comment: "Text indicating the goal of developing Damus which the user can help with.") - .fixedSize(horizontal: false, vertical: true) - .foregroundColor(.white) - - Text("An additional percentage of each zap will be sent to support Damus development", comment: "Text indicating that they can contribute zaps to support Damus development.") - .fixedSize(horizontal: false, vertical: true) - .foregroundColor(.white) - - let binding = donation_binding() - - HStack { - Slider(value: binding, - in: WalletView.min_donation...WalletView.max_donation, - label: { }) - Text("\(Int(binding.wrappedValue))%", comment: "Percentage of additional zap that should be sent to support Damus development.") - .font(.title.bold()) - .foregroundColor(.white) - .frame(width: 80) - } - - HStack{ - Spacer() - - VStack { - HStack { - Text("\(Image("zap.fill")) \(format_msats_abbrev(Int64(model.settings.default_zap_amount) * 1000))") - .font(.title) - .foregroundColor(percent == 0 ? .gray : .yellow) - .frame(width: 120) - } - - Text("Zap", comment: "Text underneath the number of sats indicating that it's the amount used for zaps.") - .foregroundColor(.white) - } - Spacer() - - Text(verbatim: "+") - .font(.title) - .foregroundColor(.white) - Spacer() - - VStack { - HStack { - Text("\(Image("zap.fill")) \(tip_msats)") - .font(.title) - .foregroundColor(percent == 0 ? .gray : Color.yellow) - .frame(width: 120) - } - - Text(verbatim: percent == 0 ? "🩶" : "💜") - .foregroundColor(.white) - } - Spacer() - } - - EventProfile(damus_state: damus_state, pubkey: damus_state.pubkey, size: .small) - } - .padding(25) - } - .frame(height: 370) - } - + var body: some View { switch model.connect_state { case .new: @@ -201,38 +44,50 @@ struct WalletView: View { ConnectWalletView(model: model, nav: damus_state.nav) case .existing(let nwc): MainWalletView(nwc: nwc) + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Button( + action: { show_settings = true }, + label: { + Image("settings") + .foregroundColor(.gray) + } + ) + } + } .onAppear() { - model.initial_percent = settings.donation_percent + Task { await self.updateWalletInformation() } } - .onChange(of: settings.donation_percent) { p in - let profile_txn = damus_state.profiles.lookup(id: damus_state.pubkey) - guard let profile = profile_txn?.unsafeUnownedValue else { - return - } - - let prof = Profile(name: profile.name, display_name: profile.display_name, about: profile.about, picture: profile.picture, banner: profile.banner, website: profile.website, lud06: profile.lud06, lud16: profile.lud16, nip05: profile.nip05, damus_donation: p, reactions: profile.reactions) - - notify(.profile_updated(.manual(pubkey: self.damus_state.pubkey, profile: prof))) + .refreshable { + model.resetWalletStateInformation() + await self.updateWalletInformation() } - .onDisappear { - let profile_txn = damus_state.profiles.lookup(id: damus_state.pubkey) - - guard let keypair = damus_state.keypair.to_full(), - let profile = profile_txn?.unsafeUnownedValue, - model.initial_percent != profile.damus_donation - else { - return + .sheet(isPresented: $show_settings, onDismiss: { self.show_settings = false }) { + ScrollView { + NWCSettings(damus_state: damus_state, nwc: nwc, model: model, settings: settings) + .padding(.top, 30) } - - 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: settings.donation_percent, reactions: profile.reactions) - - guard let meta = make_metadata_event(keypair: keypair, metadata: prof) else { - return - } - damus_state.postbox.send(meta) + .presentationDragIndicator(.visible) + .presentationDetents([.large]) } } } + + @MainActor + func updateWalletInformation() async { + guard let url = damus_state.settings.nostr_wallet_connect, + let nwc = WalletConnectURL(str: url) else { + return + } + + let flusher: OnFlush? = nil + + 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) + return + } } let test_wallet_connect_url = WalletConnectURL(pubkey: test_pubkey, relay: .init("wss://relay.damus.io")!, keypair: test_damus_state.keypair.to_full()!, lud16: "jb55@sendsats.com") diff --git a/damus/Views/Wallet/ZapExplainer.swift b/damus/Views/Wallet/ZapExplainer.swift new file mode 100644 index 00000000..9fdc1af1 --- /dev/null +++ b/damus/Views/Wallet/ZapExplainer.swift @@ -0,0 +1,203 @@ +// +// ZapExplainer.swift +// damus +// +// Created by eric on 2/12/25. +// + +import SwiftUI + +struct ZapExplainerView: View { + + @Binding var show_introduction: Bool + var nav: NavigationCoordinator + + @Environment(\.colorScheme) var colorScheme + + var body: some View { + ScrollView { + VStack { + Text("Get cash instantly from your followers", comment: "Feature description for receiving money instantly.") + .font(.veryLargeTitle) + .multilineTextAlignment(.center) + .padding(.top) + + VStack(alignment: .leading) { + GetPaid + Gift + GiveThanks + } + + WhyZaps + + ScrollView(.horizontal) { + HStack(spacing: 20) { + FindWallet + + LinkAccount + + StartReceiving + } + .padding(5) + } + .scrollIndicators(.hidden) + + Button(action: { + show_introduction = false + }) { + HStack { + Text("Set up wallet", comment: "Text for button to disconnect from Nostr Wallet Connect lightning wallet.") + } + .frame(minWidth: 300, maxWidth: .infinity, maxHeight: 18, alignment: .center) + } + .buttonStyle(GradientButtonStyle()) + .padding(.top, 30) + + Button(action: { + nav.popToRoot() + }) { + HStack { + Text("Maybe later", comment: "Text for button to disconnect from Nostr Wallet Connect lightning wallet.") + } + .frame(minWidth: 300, maxWidth: .infinity, maxHeight: 18, alignment: .center) + .padding() + } + .buttonStyle(NeutralButtonStyle()) + } + .padding(.bottom) + .padding(.horizontal) + } + .scrollIndicators(.never) + .background( + Image("eula-bg") + .resizable() + .blur(radius: 70) + .opacity(colorScheme == .light ? 0.6 : 1.0) + .ignoresSafeArea(), + alignment: .top + ) + } + + var GetPaid: some View { + self.benefitPoint( + imageName: "zap.fill", + heading: NSLocalizedString("Get paid for being you", comment: "Description for monetizing one's presence."), + description: NSLocalizedString("Setting up Zaps lets people know you're ready to start receiving money.", comment: "Information about enabling payments.") + ) + } + + var Gift: some View { + self.benefitPoint( + imageName: "gift", + heading: NSLocalizedString("Let your fans show their support", comment: "Heading pointing out a benefit of connecting a lightning wallet."), + description: NSLocalizedString("You drive the conversation and we want to make it easier for people to support your work beyond follows, reposts, and likes.", comment: "Text explaining the benefit of connecting a lightning wallet for content creators.") + ) + } + + var GiveThanks: some View { + self.benefitPoint( + imageName: "gift", + heading: NSLocalizedString("Give thanks", comment: "Heading explaining a benefit of connecting a lightning wallet."), + description: NSLocalizedString("When supporters tip with Zaps, they can add a note and we can make it easy for you to instantly reply to show your gratitude.", comment: "Description explaining a benefit of connecting a lightning wallet.") + ) + } + + func benefitPoint(imageName: String, heading: String, description: String) -> some View { + VStack(alignment: .leading) { + HStack(alignment: .top, spacing: 10) { + Button(action: {}, label: { + Image(imageName) + .resizable() + .frame(width: 25, height: 25) + }) + .buttonStyle(NeutralButtonStyle(padding: EdgeInsets(top: 10, leading: 10, bottom: 10, trailing: 10), cornerRadius: 9999)) + .disabled(true) + + VStack(alignment: .leading, spacing: 10) { + Text(heading) + .font(.title2) + .fontWeight(.bold) + + Text(description) + .font(.body) + } + .padding(.top, 9) + } + } + .padding(.top) + } + + var WhyZaps: some View { + VStack(alignment: .leading, spacing: 15) { + Text("Why add Zaps?", comment: "Heading to explain the benefits of zaps.") + .font(.title) + .fontWeight(.bold) + + Text("Zaps are an easy way to support the incredible\nvoices that make up the conversation on nostr.\nHere's how it works", comment: "Describing the functional benefits of Zaps.") + .lineLimit(4) + .font(.body) + } + .padding(.top, 30) + } + + var FindWallet: some View { + self.WhyAddZapsBox( + iconName: "wallet.fill", + heading: NSLocalizedString("Find a Wallet", comment: "The heading for one of the \"Why add Zaps?\" boxes"), + description: NSLocalizedString("Choose the third-party payment provider you'd like to use.", comment: "The description for one of the \"Why add Zaps?\" boxes") + ) + } + + var LinkAccount: some View { + self.WhyAddZapsBox( + iconName: "link", + heading: NSLocalizedString("Link your account", comment: "The heading for one of the \"Why add Zaps?\" boxes"), + description: NSLocalizedString("Link to services that support Nostr Wallet Connect like Alby, Coinos and more.", comment: "The description for one of the \"Why add Zaps?\" boxes") + ) + } + + var StartReceiving: some View { + self.WhyAddZapsBox( + iconName: "bitcoin", + heading: NSLocalizedString("Start receiving money", comment: "The heading for one of the \"Why add Zaps?\" boxes"), + description: NSLocalizedString("People will be able to send you cash from your profile. No money goes to Damus.", comment: "The description for one of the \"Why add Zaps?\" boxes") + ) + } + + func WhyAddZapsBox(iconName: String, heading: String, description: String) -> some View { + VStack(alignment: .leading, spacing: 2) { + Button(action: {}, label: { + Image(iconName) + .resizable() + .frame(width: 25, height: 25) + }) + .buttonStyle(NeutralButtonStyle(padding: EdgeInsets(top: 10, leading: 10, bottom: 10, trailing: 10), cornerRadius: 9999)) + .disabled(true) + + Text(heading) + .font(.title2) + .fontWeight(.bold) + .padding(.bottom, 2) + + Text(description) + .font(.caption) + + Spacer() + } + .frame(maxWidth: 175, minHeight: 175) + .padding(10) + .background(DamusColors.neutral1) + .cornerRadius(15) + .overlay( + RoundedRectangle(cornerRadius: 15) + .stroke(DamusColors.neutral1, lineWidth: 2) + ) + .padding(.top, 20) + } +} + +struct ZapExplainerView_Previews: PreviewProvider { + static var previews: some View { + ZapExplainerView(show_introduction: .constant(true), nav: .init()) + } +} diff --git a/damusTests/WalletConnectTests.swift b/damusTests/WalletConnectTests.swift index 535dc38c..2ea397ce 100644 --- a/damusTests/WalletConnectTests.swift +++ b/damusTests/WalletConnectTests.swift @@ -87,7 +87,7 @@ final class WalletConnectTests: XCTestCase { let pool = RelayPool(ndb: .empty) let box = PostBox(pool: pool) - nwc_pay(url: nwc, pool: pool, post: box, invoice: "invoice") + WalletConnect.pay(url: nwc, pool: pool, post: box, invoice: "invoice") XCTAssertEqual(pool.our_descriptors.count, 0) XCTAssertEqual(pool.all_descriptors.count, 1)