diff --git a/.envrc b/.envrc index 72617b2f..ba28b102 100644 --- a/.envrc +++ b/.envrc @@ -1,4 +1,4 @@ -use nix +#use nix export TODO_FILE=$PWD/TODO diff --git a/damus-c/cursor.h b/damus-c/cursor.h index 36907912..0deed48c 100644 --- a/damus-c/cursor.h +++ b/damus-c/cursor.h @@ -110,21 +110,6 @@ static inline int peek_char(struct cursor *cur, int ind) { return *(cur->p + ind); } -static int parse_digit(struct cursor *cur, int *digit) { - int c; - if ((c = peek_char(cur, 0)) == -1) - return 0; - - c -= '0'; - - if (c >= 0 && c <= 9) { - *digit = c; - cur->p++; - return 1; - } - return 0; -} - static inline int pull_byte(struct cursor *cur, u8 *byte) { if (cur->p >= cur->end) diff --git a/damus-c/damus.c b/damus-c/damus.c index 92ff5da9..8c6c60bb 100644 --- a/damus-c/damus.c +++ b/damus-c/damus.c @@ -12,6 +12,22 @@ #include #include +static int parse_digit(struct cursor *cur, int *digit) { + int c; + if ((c = peek_char(cur, 0)) == -1) + return 0; + + c -= '0'; + + if (c >= 0 && c <= 9) { + *digit = c; + cur->p++; + return 1; + } + return 0; +} + + static int parse_mention_index(struct cursor *cur, struct block *block) { int d1, d2, d3, ind; const u8 *start = cur->p; diff --git a/damus-c/hex.h b/damus-c/hex.h index 60d26cf5..117f6a3a 100644 --- a/damus-c/hex.h +++ b/damus-c/hex.h @@ -26,7 +26,7 @@ bool hex_decode(const char *str, size_t slen, void *buf, size_t bufsize); /** * hex_encode - Create a nul-terminated hex string * @buf: the buffer to read the data from - * @bufsize: the length of @buf + * @bufsize: the length of buf * @dest: the string to fill * @destsize: the max size of the string * diff --git a/damus.xcodeproj/project.pbxproj b/damus.xcodeproj/project.pbxproj index 91ddb701..0b78b069 100644 --- a/damus.xcodeproj/project.pbxproj +++ b/damus.xcodeproj/project.pbxproj @@ -53,6 +53,8 @@ 4C216F34286F5ACD00040376 /* DMView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C216F33286F5ACD00040376 /* DMView.swift */; }; 4C216F362870A9A700040376 /* InputDismissKeyboard.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C216F352870A9A700040376 /* InputDismissKeyboard.swift */; }; 4C216F382871EDE300040376 /* DirectMessageModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C216F372871EDE300040376 /* DirectMessageModel.swift */; }; + 4C2859602A12A2BE004746F7 /* SupporterBadge.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C28595F2A12A2BE004746F7 /* SupporterBadge.swift */; }; + 4C2859622A12A7F0004746F7 /* GoldSupportGradient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C2859612A12A7F0004746F7 /* GoldSupportGradient.swift */; }; 4C285C8228385570008A31F1 /* CarouselView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C285C8128385570008A31F1 /* CarouselView.swift */; }; 4C285C8428385690008A31F1 /* CreateAccountView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C285C8328385690008A31F1 /* CreateAccountView.swift */; }; 4C285C86283892E7008A31F1 /* CreateAccountModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C285C85283892E7008A31F1 /* CreateAccountModel.swift */; }; @@ -137,13 +139,19 @@ 4C75EFB92804A2740006080F /* EventView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C75EFB82804A2740006080F /* EventView.swift */; }; 4C75EFBB2804A34C0006080F /* ProofOfWork.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C75EFBA2804A34C0006080F /* ProofOfWork.swift */; }; 4C7D09592A05BEAD00943473 /* KeyboardVisible.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C7D09582A05BEAD00943473 /* KeyboardVisible.swift */; }; + 4C7D095F2A098C5D00943473 /* ConnectWalletView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C7D095C2A098C5D00943473 /* ConnectWalletView.swift */; }; + 4C7D09602A098C5D00943473 /* WalletView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C7D095D2A098C5D00943473 /* WalletView.swift */; }; + 4C7D09622A098D0E00943473 /* WalletConnect.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C7D09612A098D0E00943473 /* WalletConnect.swift */; }; 4C7D09662A0AE62100943473 /* AlbyButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C7D09652A0AE62100943473 /* AlbyButton.swift */; }; + 4C7D09682A0AE9B200943473 /* NWCScannerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C7D09672A0AE9B200943473 /* NWCScannerView.swift */; }; 4C7D096D2A0AEA0400943473 /* CodeScanner.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C7D096A2A0AEA0400943473 /* CodeScanner.swift */; }; 4C7D096E2A0AEA0400943473 /* ScannerCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C7D096B2A0AEA0400943473 /* ScannerCoordinator.swift */; }; 4C7D096F2A0AEA0400943473 /* ScannerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C7D096C2A0AEA0400943473 /* ScannerViewController.swift */; }; 4C7D09722A0AEF5E00943473 /* DamusGradient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C7D09712A0AEF5E00943473 /* DamusGradient.swift */; }; 4C7D09742A0AEF9000943473 /* AlbyGradient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C7D09732A0AEF9000943473 /* AlbyGradient.swift */; }; 4C7D09762A0AF19E00943473 /* FillAndStroke.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C7D09752A0AF19E00943473 /* FillAndStroke.swift */; }; + 4C7D09782A0B0CC900943473 /* WalletModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C7D09772A0B0CC900943473 /* WalletModel.swift */; }; + 4C7D097E2A0C58B900943473 /* WalletConnectTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C7D097D2A0C58B900943473 /* WalletConnectTests.swift */; }; 4C7FF7D52823313F009601DB /* Mentions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C7FF7D42823313F009601DB /* Mentions.swift */; }; 4C8682872814DE470026224F /* ProfileView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C8682862814DE470026224F /* ProfileView.swift */; }; 4C8D00C829DF791C0036AF10 /* CompatibleAttribute.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C8D00C729DF791C0036AF10 /* CompatibleAttribute.swift */; }; @@ -265,6 +273,7 @@ 501F8C5A29FF70F5001AFC1D /* ProfileDatabase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 501F8C5929FF70F5001AFC1D /* ProfileDatabase.swift */; }; 50A50A8D29A09E1C00C01BE7 /* RequestTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50A50A8C29A09E1C00C01BE7 /* RequestTests.swift */; }; 50B5685329F97CB400A23243 /* CredentialHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50B5685229F97CB400A23243 /* CredentialHandler.swift */; }; + 50DA11262A16A23F00236234 /* Launch.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 50DA11252A16A23F00236234 /* Launch.storyboard */; }; 5C42E78C29DB76D90086AAC1 /* EmptyUserSearchView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C42E78B29DB76D90086AAC1 /* EmptyUserSearchView.swift */; }; 5C513FBA297F72980072348F /* CustomPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C513FB9297F72980072348F /* CustomPicker.swift */; }; 5C513FCC2984ACA60072348F /* QRCodeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C513FCB2984ACA60072348F /* QRCodeView.swift */; }; @@ -442,6 +451,8 @@ 4C216F33286F5ACD00040376 /* DMView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DMView.swift; sourceTree = ""; }; 4C216F352870A9A700040376 /* InputDismissKeyboard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InputDismissKeyboard.swift; sourceTree = ""; }; 4C216F372871EDE300040376 /* DirectMessageModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DirectMessageModel.swift; sourceTree = ""; }; + 4C28595F2A12A2BE004746F7 /* SupporterBadge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SupporterBadge.swift; sourceTree = ""; }; + 4C2859612A12A7F0004746F7 /* GoldSupportGradient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GoldSupportGradient.swift; sourceTree = ""; }; 4C285C8128385570008A31F1 /* CarouselView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CarouselView.swift; sourceTree = ""; }; 4C285C8328385690008A31F1 /* CreateAccountView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreateAccountView.swift; sourceTree = ""; }; 4C285C85283892E7008A31F1 /* CreateAccountModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreateAccountModel.swift; sourceTree = ""; }; @@ -556,13 +567,19 @@ 4C75EFB82804A2740006080F /* EventView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EventView.swift; sourceTree = ""; }; 4C75EFBA2804A34C0006080F /* ProofOfWork.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProofOfWork.swift; sourceTree = ""; }; 4C7D09582A05BEAD00943473 /* KeyboardVisible.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyboardVisible.swift; sourceTree = ""; }; + 4C7D095C2A098C5D00943473 /* ConnectWalletView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ConnectWalletView.swift; sourceTree = ""; }; + 4C7D095D2A098C5D00943473 /* WalletView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = WalletView.swift; sourceTree = ""; }; + 4C7D09612A098D0E00943473 /* WalletConnect.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = WalletConnect.swift; sourceTree = ""; }; 4C7D09652A0AE62100943473 /* AlbyButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlbyButton.swift; sourceTree = ""; }; + 4C7D09672A0AE9B200943473 /* NWCScannerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NWCScannerView.swift; sourceTree = ""; }; 4C7D096A2A0AEA0400943473 /* CodeScanner.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CodeScanner.swift; sourceTree = ""; }; 4C7D096B2A0AEA0400943473 /* ScannerCoordinator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ScannerCoordinator.swift; sourceTree = ""; }; 4C7D096C2A0AEA0400943473 /* ScannerViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ScannerViewController.swift; sourceTree = ""; }; 4C7D09712A0AEF5E00943473 /* DamusGradient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DamusGradient.swift; sourceTree = ""; }; 4C7D09732A0AEF9000943473 /* AlbyGradient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlbyGradient.swift; sourceTree = ""; }; 4C7D09752A0AF19E00943473 /* FillAndStroke.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FillAndStroke.swift; sourceTree = ""; }; + 4C7D09772A0B0CC900943473 /* WalletModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WalletModel.swift; sourceTree = ""; }; + 4C7D097D2A0C58B900943473 /* WalletConnectTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = WalletConnectTests.swift; sourceTree = ""; }; 4C7FF7D42823313F009601DB /* Mentions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Mentions.swift; sourceTree = ""; }; 4C8682862814DE470026224F /* ProfileView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileView.swift; sourceTree = ""; }; 4C8D00C729DF791C0036AF10 /* CompatibleAttribute.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CompatibleAttribute.swift; sourceTree = ""; }; @@ -692,6 +709,7 @@ 501F8C5929FF70F5001AFC1D /* ProfileDatabase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileDatabase.swift; sourceTree = ""; }; 50A50A8C29A09E1C00C01BE7 /* RequestTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RequestTests.swift; sourceTree = ""; }; 50B5685229F97CB400A23243 /* CredentialHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CredentialHandler.swift; sourceTree = ""; }; + 50DA11252A16A23F00236234 /* Launch.storyboard */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; path = Launch.storyboard; sourceTree = ""; }; 5C42E78B29DB76D90086AAC1 /* EmptyUserSearchView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmptyUserSearchView.swift; sourceTree = ""; }; 5C513FB9297F72980072348F /* CustomPicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomPicker.swift; sourceTree = ""; }; 5C513FCB2984ACA60072348F /* QRCodeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QRCodeView.swift; sourceTree = ""; }; @@ -870,6 +888,7 @@ 4C54AA0629A540BA003E4487 /* NotificationsModel.swift */, 4CD348EE29C3659D00497EB2 /* ImageUploadModel.swift */, 3A48E7AF29DFBE9D006E787E /* MutedThreadsManager.swift */, + 4C7D09772A0B0CC900943473 /* WalletModel.swift */, ); path = Models; sourceTree = ""; @@ -929,6 +948,7 @@ isa = PBXGroup; children = ( 4C7D09692A0AEA0400943473 /* CodeScanner */, + 4C7D095A2A098C5C00943473 /* Wallet */, 4C8D1A6D29F31E4100ACDF75 /* Buttons */, 4C1A9A1B29DDCF8B00516EAC /* Settings */, 4CFF8F6129CC9A80008DB934 /* Images */, @@ -984,6 +1004,7 @@ 4CF0ABD529817F5B00D66079 /* ReportView.swift */, 4CF0ABE42981EE0C00D66079 /* EULAView.swift */, 3AA247FE297E3D900090C62D /* RepostsView.swift */, + 50DA11252A16A23F00236234 /* Launch.storyboard */, 5C513FCB2984ACA60072348F /* QRCodeView.swift */, 643EA5C7296B764E005081BB /* RelayFilterView.swift */, ); @@ -1012,6 +1033,16 @@ path = Nostr; sourceTree = ""; }; + 4C7D095A2A098C5C00943473 /* Wallet */ = { + isa = PBXGroup; + children = ( + 4C7D095C2A098C5D00943473 /* ConnectWalletView.swift */, + 4C7D095D2A098C5D00943473 /* WalletView.swift */, + 4C7D09672A0AE9B200943473 /* NWCScannerView.swift */, + ); + path = Wallet; + sourceTree = ""; + }; 4C7D09692A0AEA0400943473 /* CodeScanner */ = { isa = PBXGroup; children = ( @@ -1027,6 +1058,7 @@ children = ( 4C7D09712A0AEF5E00943473 /* DamusGradient.swift */, 4C7D09732A0AEF9000943473 /* AlbyGradient.swift */, + 4C2859612A12A7F0004746F7 /* GoldSupportGradient.swift */, ); path = Gradients; sourceTree = ""; @@ -1034,6 +1066,7 @@ 4C7FF7D628233637009601DB /* Util */ = { isa = PBXGroup; children = ( + 4C7D09612A098D0E00943473 /* WalletConnect.swift */, 4C198DF329F88D23004C165C /* Images */, 4C198DEA29F88C6B004C165C /* BlurHash */, 4CE4F0F329D779B5005914DB /* PostBox.swift */, @@ -1203,6 +1236,7 @@ 4CE4F0F729DB7399005914DB /* ThiccDivider.swift */, 4C1A9A2229DDDB8100516EAC /* IconLabel.swift */, 4C8D00C929DF80350036AF10 /* TruncatedText.swift */, + 4C28595F2A12A2BE004746F7 /* SupporterBadge.swift */, ); path = Components; sourceTree = ""; @@ -1262,6 +1296,7 @@ 4CE6DEF627F7A08200C66700 /* damusTests */ = { isa = PBXGroup; children = ( + 4C7D097D2A0C58B900943473 /* WalletConnectTests.swift */, F944F56C29EA9CB20067B3BF /* Models */, 50A50A8C29A09E1C00C01BE7 /* RequestTests.swift */, DD597CBC2963D85A00C64D32 /* MarkdownTests.swift */, @@ -1546,6 +1581,7 @@ 3ACB685F297633BC00C46468 /* Localizable.strings in Resources */, 4CE6DEEE27F7A08200C66700 /* Preview Assets.xcassets in Resources */, 3ACB685C297633BC00C46468 /* InfoPlist.strings in Resources */, + 50DA11262A16A23F00236234 /* Launch.storyboard in Resources */, 4CE6DEEB27F7A08200C66700 /* Assets.xcassets in Resources */, 4C198DF129F88C6B004C165C /* License.txt in Resources */, 4C198DF029F88C6B004C165C /* Readme.md in Resources */, @@ -1639,6 +1675,7 @@ F7F0BA25297892BD009531F3 /* SwipeToDismiss.swift in Sources */, 4C8D00CA29DF80350036AF10 /* TruncatedText.swift in Sources */, 4C9BB83429C12D9900FC4E37 /* EventProfileName.swift in Sources */, + 4C7D09602A098C5D00943473 /* WalletView.swift in Sources */, 4CB8838F296F781C00DC99E7 /* ReactionsView.swift in Sources */, 4C75EFB328049D640006080F /* NostrEvent.swift in Sources */, 4CA2EFA0280E37AC0044ACD8 /* TimelineView.swift in Sources */, @@ -1682,6 +1719,7 @@ 4C3AC79F2833115300E1F516 /* FollowButtonView.swift in Sources */, 4CC7AAE7297EFA7B00430951 /* Zap.swift in Sources */, 4C3BEFD22819DB9B00B3DE84 /* ProfileModel.swift in Sources */, + 4C7D09682A0AE9B200943473 /* NWCScannerView.swift in Sources */, 4C0A3F93280F66F5000448DE /* ReplyMap.swift in Sources */, 7C95CAEE299DCEF1009DCB67 /* KFOptionSetter+.swift in Sources */, 4C7D09722A0AEF5E00943473 /* DamusGradient.swift in Sources */, @@ -1708,10 +1746,12 @@ 9609F058296E220800069BF3 /* BannerImageView.swift in Sources */, 4C363A94282704FA006E126D /* Post.swift in Sources */, 4C216F32286E388800040376 /* DMChatView.swift in Sources */, + 4C7D09782A0B0CC900943473 /* WalletModel.swift in Sources */, 4C7D09662A0AE62100943473 /* AlbyButton.swift in Sources */, 4CAAD8AD298851D000060CEA /* AccountDeletion.swift in Sources */, 4CFF8F6329CC9AD7008DB934 /* ImageContextMenuModifier.swift in Sources */, 4C54AA0A29A55429003E4487 /* EventGroup.swift in Sources */, + 4C7D09622A098D0E00943473 /* WalletConnect.swift in Sources */, 4C3EA67928FF7ABF00C48A62 /* list.c in Sources */, 4C64987E286D082C00EAE2B3 /* DirectMessagesModel.swift in Sources */, 9CA876E229A00CEA0003B9A3 /* AttachMediaUtility.swift in Sources */, @@ -1721,12 +1761,14 @@ 4C06670628FCB08600038D2A /* ImageCarousel.swift in Sources */, F79C7FAD29D5E9620000F946 /* EditProfilePictureControl.swift in Sources */, 4C9F18E229AA9B6C008C55EC /* CustomizeZapView.swift in Sources */, + 4C2859602A12A2BE004746F7 /* SupporterBadge.swift in Sources */, 4C75EFAF28049D350006080F /* NostrFilter.swift in Sources */, 4C3EA64C28FF59AC00C48A62 /* bech32_util.c in Sources */, 4CE1399029F0661A00AC6A0B /* RepostAction.swift in Sources */, 4CE1399229F0666100AC6A0B /* ShareActionButton.swift in Sources */, 4C42812C298C848200DBF26F /* TranslateView.swift in Sources */, 4C363A9C282838B9006E126D /* EventRef.swift in Sources */, + 4C7D095F2A098C5D00943473 /* ConnectWalletView.swift in Sources */, 3AA24802297E3DC20090C62D /* RepostView.swift in Sources */, 4CD7641B28A1641400B6928F /* EndBlock.swift in Sources */, 4C3EA66528FF5F6800C48A62 /* mem.c in Sources */, @@ -1829,6 +1871,7 @@ 3169CAED294FCCFC00EE4006 /* Constants.swift in Sources */, 4CB9D4A72992D02B00A9A7E4 /* ProfileNameView.swift in Sources */, 4CE4F0F429D779B5005914DB /* PostBox.swift in Sources */, + 4C2859622A12A7F0004746F7 /* GoldSupportGradient.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -1847,6 +1890,7 @@ 3A3040EF29A8FEE9008A0F29 /* EventDetailBarTests.swift in Sources */, 4C3EA67B28FF7B3900C48A62 /* InvoiceTests.swift in Sources */, 4C363A9E2828A822006E126D /* ReplyTests.swift in Sources */, + 4C7D097E2A0C58B900943473 /* WalletConnectTests.swift in Sources */, 4CB883AA297612FF00DC99E7 /* ZapTests.swift in Sources */, 4CB8839A297322D200DC99E7 /* DMTests.swift in Sources */, F944F56E29EA9CCC0067B3BF /* DamusParseContentTests.swift in Sources */, @@ -2120,7 +2164,7 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = damus/damus.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 24; + CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_ASSET_PATHS = "\"damus/Preview Content\""; DEVELOPMENT_TEAM = XK7H4JAB3D; ENABLE_PREVIEWS = YES; @@ -2135,6 +2179,7 @@ INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UILaunchStoryboardName = Launch.storyboard; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; LD_RUNPATH_SEARCH_PATHS = ( @@ -2167,7 +2212,7 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = damus/damus.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 24; + CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_ASSET_PATHS = "\"damus/Preview Content\""; DEVELOPMENT_TEAM = XK7H4JAB3D; ENABLE_PREVIEWS = YES; @@ -2182,6 +2227,7 @@ INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UILaunchStoryboardName = Launch.storyboard; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; LD_RUNPATH_SEARCH_PATHS = ( diff --git a/damus.xcodeproj/xcshareddata/xcschemes/Release.xcscheme b/damus.xcodeproj/xcshareddata/xcschemes/Release.xcscheme new file mode 100644 index 00000000..d33ee7b5 --- /dev/null +++ b/damus.xcodeproj/xcshareddata/xcschemes/Release.xcscheme @@ -0,0 +1,98 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/damus/Assets.xcassets/gradient.imageset/Contents.json b/damus/Assets.xcassets/gradient.imageset/Contents.json new file mode 100644 index 00000000..52666f80 --- /dev/null +++ b/damus/Assets.xcassets/gradient.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "gradient.jpg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/damus/Assets.xcassets/gradient.imageset/gradient.jpg b/damus/Assets.xcassets/gradient.imageset/gradient.jpg new file mode 100644 index 00000000..908466e5 Binary files /dev/null and b/damus/Assets.xcassets/gradient.imageset/gradient.jpg differ diff --git a/damus/Components/Gradients/DamusGradient.swift b/damus/Components/Gradients/DamusGradient.swift index 90434a91..1f2d3b86 100644 --- a/damus/Components/Gradients/DamusGradient.swift +++ b/damus/Components/Gradients/DamusGradient.swift @@ -14,9 +14,13 @@ fileprivate let damus_grad = [damus_grad_c1, damus_grad_c2, damus_grad_c3] struct DamusGradient: View { var body: some View { - LinearGradient(colors: damus_grad, startPoint: .bottomLeading, endPoint: .topTrailing) + DamusGradient.gradient .edgesIgnoringSafeArea([.top,.bottom]) } + + static var gradient: LinearGradient { + LinearGradient(colors: damus_grad, startPoint: .bottomLeading, endPoint: .topTrailing) + } } struct DamusGradient_Previews: PreviewProvider { diff --git a/damus/Components/Gradients/GoldSupportGradient.swift b/damus/Components/Gradients/GoldSupportGradient.swift new file mode 100644 index 00000000..3e255cd1 --- /dev/null +++ b/damus/Components/Gradients/GoldSupportGradient.swift @@ -0,0 +1,29 @@ +// +// GoldSupportGradient.swift +// damus +// +// Created by William Casarin on 2023-05-15. +// + +import SwiftUI + +fileprivate let gold_grad_c1 = hex_col(r: 226, g: 168, b: 0) +fileprivate let gold_grad_c2 = hex_col(r: 249, g: 243, b: 100) + +fileprivate let gold_grad = [gold_grad_c2, gold_grad_c1] + +let GoldGradient: LinearGradient = + LinearGradient(colors: gold_grad, startPoint: .bottomLeading, endPoint: .topTrailing) + +struct GoldGradientView: View { + var body: some View { + GoldGradient + .edgesIgnoringSafeArea([.top,.bottom]) + } +} + +struct GoldGradientView_Previews: PreviewProvider { + static var previews: some View { + GoldGradientView() + } +} diff --git a/damus/Components/SupporterBadge.swift b/damus/Components/SupporterBadge.swift new file mode 100644 index 00000000..1af67eee --- /dev/null +++ b/damus/Components/SupporterBadge.swift @@ -0,0 +1,73 @@ +// +// SupporterBadge.swift +// damus +// +// Created by William Casarin on 2023-05-15. +// + +import SwiftUI + +struct SupporterBadge: View { + let percent: Int + + let size: CGFloat = 17 + + var body: some View { + if percent < 100 { + Image("star.fill") + .resizable() + .frame(width:size, height:size) + .foregroundColor(support_level_color(percent)) + } else { + Image("star.fill") + .resizable() + .frame(width:size, height:size) + .foregroundStyle(GoldGradient) + } + } +} + +func support_level_color(_ percent: Int) -> Color { + if percent == 0 { + return .gray + } + + let percent_f = Double(percent) / 100.0 + let cutoff = 0.5 + let h = cutoff + (percent_f * cutoff); // Hue (note 0.2 = Green, see huge chart below) + let s = 0.9; // Saturation + let b = 0.9; // Brightness + + return Color(hue: h, saturation: s, brightness: b) +} + +struct SupporterBadge_Previews: PreviewProvider { + static func Level(_ p: Int) -> some View { + HStack(alignment: .center) { + SupporterBadge(percent: p) + .frame(width: 50) + Text(verbatim: p.formatted()) + .frame(width: 50) + } + } + + static var previews: some View { + VStack(spacing: 0) { + VStack(spacing: 0) { + Level(1) + Level(10) + Level(20) + Level(30) + Level(40) + Level(50) + } + Level(60) + Level(70) + Level(80) + Level(90) + Level(100) + } + } +} + + diff --git a/damus/Components/ZapButton.swift b/damus/Components/ZapButton.swift index 18b70be7..31cc50f3 100644 --- a/damus/Components/ZapButton.swift +++ b/damus/Components/ZapButton.swift @@ -23,45 +23,97 @@ struct ZappingEvent { let event: NostrEvent } +class ZapButtonModel: ObservableObject { + var invoice: String? = nil + @Published var zapping: String = "" + @Published var showing_select_wallet: Bool = false + @Published var showing_zap_customizer: Bool = false +} + struct ZapButton: View { let damus_state: DamusState let event: NostrEvent let lnurl: String - @ObservedObject var bar: ActionBarModel + @ObservedObject var zaps: ZapsDataModel + @StateObject var button: ZapButtonModel = ZapButtonModel() - @State var zapping: Bool = false - @State var invoice: String = "" - @State var showing_select_wallet: Bool = false - @State var showing_zap_customizer: Bool = false - @State var is_charging: Bool = false - - var zap_img: String { - if bar.zapped { - return "bolt.fill" - } - - if !zapping { - return "bolt" - } - - return "bolt.fill" + var our_zap: Zapping? { + zaps.zaps.first(where: { z in z.request.pubkey == damus_state.pubkey }) } - var zap_color: Color? { - if bar.zapped { - return Color.orange + var zap_img: String { + switch our_zap { + case .none: + return "bolt" + case .zap: + return "bolt.fill" + case .pending: + return "bolt.fill" + } + } + + var zap_color: Color { + if our_zap == nil { + return Color.gray } - if is_charging { + // always orange ! + return Color.orange + /* + if our_zap.is_paid { + return Color.orange + } else { return Color.yellow } - - if !zapping { - return nil + */ + } + + func tap() { + guard let our_zap else { + send_zap(damus_state: damus_state, event: event, lnurl: lnurl, is_custom: false, comment: nil, amount_sats: nil, zap_type: damus_state.settings.default_zap_type) + return } - return Color.yellow + // we've tapped and we have a zap already... cancel if we can + switch our_zap { + case .zap: + // can't undo a zap we've already sent + // if we want to send more zaps we will need to long-press + print("cancel_zap: we already have a real zap, can't cancel") + break + case .pending(let pzap): + guard let res = cancel_zap(zap: pzap, box: damus_state.postbox, zapcache: damus_state.zaps, evcache: damus_state.events) else { + + UIImpactFeedbackGenerator(style: .soft).impactOccurred() + return + } + + UIImpactFeedbackGenerator(style: .rigid).impactOccurred() + + switch res { + case .send_err(let cancel_err): + switch cancel_err { + case .nothing_to_cancel: + print("cancel_zap: got nothing_to_cancel in pending") + break + case .not_delayed: + print("cancel_zap: got not_delayed in pending") + break + case .too_late: + print("cancel_zap: got too_late in pending") + break + } + case .already_confirmed: + print("cancel_zap: got already_confirmed in pending") + break + case .not_nwc: + print("cancel_zap: got not_nwc in pending") + break + } + } + + } var body: some View { @@ -69,37 +121,28 @@ struct ZapButton: View { Button(action: { }, label: { Image(systemName: zap_img) - .foregroundColor(zap_color == nil ? Color.gray : zap_color!) + .foregroundColor(zap_color) .font(.footnote.weight(.medium)) }) - .simultaneousGesture(LongPressGesture().onEnded {_ in - guard !zapping else { - return - } - - self.showing_zap_customizer = true - }) - .highPriorityGesture(TapGesture().onEnded {_ in - guard !zapping else { - return - } - - send_zap(damus_state: damus_state, event: event, lnurl: lnurl, is_custom: false, comment: nil, amount_sats: nil, zap_type: damus_state.settings.default_zap_type) - self.zapping = true - }) - .accessibilityLabel(NSLocalizedString("Zap", comment: "Accessibility label for zap button")) - if bar.zap_total > 0 { - Text(verbatim: format_msats_abbrev(bar.zap_total)) + if zaps.zap_total > 0 { + Text(verbatim: format_msats_abbrev(zaps.zap_total)) .font(.footnote) - .foregroundColor(bar.zapped ? Color.orange : Color.gray) + .foregroundColor(zap_color) } } - .sheet(isPresented: $showing_zap_customizer) { + .accessibilityLabel(NSLocalizedString("Zap", comment: "Accessibility label for zap button")) + .simultaneousGesture(LongPressGesture().onEnded {_ in + button.showing_zap_customizer = true + }) + .highPriorityGesture(TapGesture().onEnded { + tap() + }) + .sheet(isPresented: $button.showing_zap_customizer) { CustomizeZapView(state: damus_state, event: event, lnurl: lnurl) } - .sheet(isPresented: $showing_select_wallet, onDismiss: {showing_select_wallet = false}) { - SelectWalletView(default_wallet: damus_state.settings.default_wallet, showingSelectWallet: $showing_select_wallet, our_pubkey: damus_state.pubkey, invoice: invoice) + .sheet(isPresented: $button.showing_select_wallet, onDismiss: {button.showing_select_wallet = false}) { + SelectWalletView(default_wallet: damus_state.settings.default_wallet, showingSelectWallet: $button.showing_select_wallet, our_pubkey: damus_state.pubkey, invoice: button.invoice ?? "") } .onReceive(handle_notify(.zapping)) { notif in let zap_ev = notif.object as! ZappingEvent @@ -117,15 +160,13 @@ struct ZapButton: View { break case .got_zap_invoice(let inv): if damus_state.settings.show_wallet_selector { - self.invoice = inv - self.showing_select_wallet = true + self.button.invoice = inv + self.button.showing_select_wallet = true } else { let wallet = damus_state.settings.default_wallet.model open_with_wallet(wallet: wallet, invoice: inv) } } - - self.zapping = false } } } @@ -133,13 +174,25 @@ struct ZapButton: View { struct ZapButton_Previews: PreviewProvider { static var previews: some View { - let bar = ActionBarModel(likes: 0, boosts: 0, zaps: 10, zap_total: 15623414, replies: 2, our_like: nil, our_boost: nil, our_zap: nil, our_reply: nil) - ZapButton(damus_state: test_damus_state(), event: test_event, lnurl: "lnurl", bar: bar) + let pending_zap = PendingZap(amount_msat: 1000, target: ZapTarget.note(id: "noteid", author: "author"), request: .normal(test_zap_request), type: .pub, state: .external(.init(state: .fetching_invoice))) + let zaps = ZapsDataModel([.pending(pending_zap)]) + + ZapButton(damus_state: test_damus_state(), event: test_event, lnurl: "lnurl", zaps: zaps) } } +func initial_pending_zap_state(settings: UserSettingsStore) -> PendingZapState { + if let url = settings.nostr_wallet_connect, + let nwc = WalletConnectURL(str: url) + { + return .nwc(NWCPendingZapState(state: .fetching_invoice, url: nwc)) + } + + return .external(ExtPendingZapState(state: .fetching_invoice)) +} + func send_zap(damus_state: DamusState, event: NostrEvent, lnurl: String, is_custom: Bool, comment: String?, amount_sats: Int?, zap_type: ZapType) { guard let keypair = damus_state.keypair.to_full() else { return @@ -150,7 +203,19 @@ func send_zap(damus_state: DamusState, event: NostrEvent, lnurl: String, is_cust let target = ZapTarget.note(id: event.id, author: event.pubkey) let content = comment ?? "" - let zapreq = make_zap_request_event(keypair: keypair, content: content, relays: relays, target: target, zap_type: zap_type) + guard let mzapreq = make_zap_request_event(keypair: keypair, content: content, relays: relays, target: target, zap_type: zap_type) else { + // this should never happen + return + } + + let amount_msat = Int64(amount_sats ?? damus_state.settings.default_zap_amount) * 1000 + let pending_zap_state = initial_pending_zap_state(settings: damus_state.settings) + let pending_zap = PendingZap(amount_msat: amount_msat, target: target, request: mzapreq, type: zap_type, state: pending_zap_state) + let zapreq = mzapreq.potentially_anon_outer_request.ev + let reqid = ZapRequestId(from_makezap: mzapreq) + + UIImpactFeedbackGenerator(style: .heavy).impactOccurred() + damus_state.add_zap(zap: .pending(pending_zap)) Task { var mpayreq = damus_state.lnurls.lookup(target.pubkey) @@ -161,6 +226,7 @@ func send_zap(damus_state: DamusState, event: NostrEvent, lnurl: String, is_cust guard let payreq = mpayreq else { // TODO: show error DispatchQueue.main.async { + remove_zap(reqid: reqid, zapcache: damus_state.zaps, evcache: damus_state.events) let typ = ZappingEventType.failed(.bad_lnurl) let ev = ZappingEvent(is_custom: is_custom, type: typ, event: event) notify(.zapping, ev) @@ -172,10 +238,9 @@ func send_zap(damus_state: DamusState, event: NostrEvent, lnurl: String, is_cust damus_state.lnurls.endpoints[target.pubkey] = payreq } - let zap_amount = amount_sats ?? damus_state.settings.default_zap_amount - - guard let inv = await fetch_zap_invoice(payreq, zapreq: zapreq, sats: zap_amount, zap_type: zap_type, comment: comment) else { + guard let inv = await fetch_zap_invoice(payreq, zapreq: zapreq, msats: amount_msat, zap_type: zap_type, comment: comment) else { DispatchQueue.main.async { + remove_zap(reqid: reqid, zapcache: damus_state.zaps, evcache: damus_state.events) let typ = ZappingEventType.failed(.fetching_invoice) let ev = ZappingEvent(is_custom: is_custom, type: typ, event: event) notify(.zapping, ev) @@ -184,10 +249,87 @@ func send_zap(damus_state: DamusState, event: NostrEvent, lnurl: String, is_cust } DispatchQueue.main.async { - let ev = ZappingEvent(is_custom: is_custom, type: .got_zap_invoice(inv), event: event) - notify(.zapping, ev) + + switch pending_zap_state { + case .nwc(let nwc_state): + // don't both continuing, user has canceled + if case .cancel_fetching_invoice = nwc_state.state { + remove_zap(reqid: reqid, zapcache: damus_state.zaps, evcache: damus_state.events) + return + } + + var flusher: OnFlush? = nil + // Don't donate on custom zaps + if !is_custom && damus_state.settings.donation_percent > 0 { + flusher = .once({ pe in + // send donation zap when the pending zap is flushed, this allows user to cancel and not send a donation + Task.init { @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) + } + }) + } + + let nwc_req = nwc_pay(url: nwc_state.url, pool: damus_state.pool, post: damus_state.postbox, invoice: inv, 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)") + return + } + + print("nwc: sending request \(nwc_req.id) zap_req_id \(reqid.reqid)") + + if pzap_state.update_state(state: .postbox_pending(nwc_req)) { + // we don't need to trigger a ZapsDataModel update here + } + case .external(let pending_ext): + pending_ext.state = .done + let ev = ZappingEvent(is_custom: is_custom, type: .got_zap_invoice(inv), event: event) + notify(.zapping, ev) + } } } return } + +enum CancelZapErr { + case send_err(CancelSendErr) + case already_confirmed + case not_nwc +} + +func cancel_zap(zap: PendingZap, box: PostBox, zapcache: Zaps, evcache: EventCache) -> CancelZapErr? { + guard case .nwc(let nwc_state) = zap.state else { + return .not_nwc + } + + switch nwc_state.state { + case .fetching_invoice: + if nwc_state.update_state(state: .cancel_fetching_invoice) { + // we don't need to update the ZapsDataModel here + } + // let the code that retrieves the invoice remove the zap, because + // it still needs access to this pending zap to know to cancel + + case .cancel_fetching_invoice: + // already cancelling? + print("cancel_zap: already cancelling") + return nil + + case .confirmed: + return .already_confirmed + + case .postbox_pending(let nwc_req): + if let err = box.cancel_send(evid: nwc_req.id) { + return .send_err(err) + } + let reqid = ZapRequestId(from_pending: zap) + remove_zap(reqid: reqid, zapcache: zapcache, evcache: evcache) + + case .failed: + let reqid = ZapRequestId(from_pending: zap) + remove_zap(reqid: reqid, zapcache: zapcache, evcache: evcache) + } + + return nil +} diff --git a/damus/ContentView.swift b/damus/ContentView.swift index 09b88edb..42c078ad 100644 --- a/damus/ContentView.swift +++ b/damus/ContentView.swift @@ -66,6 +66,8 @@ struct ContentView: View { @State var profile_open: Bool = false @State var thread_open: Bool = false @State var search_open: Bool = false + @State var wallet_open: Bool = false + @State var active_nwc: WalletConnectURL? = nil @State var muting: String? = nil @State var confirm_mute: Bool = false @State var user_muted_confirm: Bool = false @@ -78,6 +80,9 @@ struct ContentView: View { @Environment(\.colorScheme) var colorScheme + // connect retry timer + let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect() + var mystery: some View { Text("Are you lost?", comment: "Text asking the user if they are lost in the app.") .id("what") @@ -131,6 +136,7 @@ struct ContentView: View { profile_open = false thread_open = false search_open = false + wallet_open = false isSideBarOpened = false } @@ -141,6 +147,9 @@ struct ContentView: View { func MainContent(damus: DamusState) -> some View { VStack { + NavigationLink(destination: WalletView(damus_state: damus, model: damus_state!.wallet), isActive: $wallet_open) { + EmptyView() + } NavigationLink(destination: MaybeProfileView, isActive: $profile_open) { EmptyView() } @@ -231,16 +240,24 @@ struct ContentView: View { } func open_event(ev: NostrEvent) { + popToRoot() self.active_event = ev self.thread_open = true } + func open_wallet(nwc: WalletConnectURL) { + self.damus_state!.wallet.new(nwc) + self.wallet_open = true + } + func open_profile(id: String) { + popToRoot() self.active_profile = id self.profile_open = true } func open_search(filt: NostrFilter) { + popToRoot() self.active_search = filt self.search_open = true } @@ -320,34 +337,25 @@ struct ContentView: View { } } .onOpenURL { url in - guard let link = decode_nostr_uri(url.absoluteString) else { - return - } - - switch link { - case .ref(let ref): - if ref.key == "p" { - active_profile = ref.ref_id - profile_open = true - } else if ref.key == "e" { - find_event(state: damus_state!, evid: ref.ref_id, search_type: .event, find_from: nil) { ev in - if let ev { - open_event(ev: ev) - } - } + on_open_url(state: damus_state!, url: url) { res in + guard let res else { + return } - case .filter(let filt): - active_search = filt - search_open = true - break - // TODO: handle filter searches? + + switch res { + case .filter(let filt): self.open_search(filt: filt) + case .profile(let id): self.open_profile(id: id) + case .event(let ev): self.open_event(ev: ev) + case .wallet_connect(let nwc): self.open_wallet(nwc: nwc)} } - } .onReceive(handle_notify(.compose)) { notif in let action = notif.object as! PostAction self.active_sheet = .post(action) } + .onReceive(timer) { n in + self.damus_state?.postbox.try_flushing_events() + } .onReceive(handle_notify(.deleted_account)) { notif in self.is_deleted_account = true } @@ -360,13 +368,36 @@ struct ContentView: View { self.muting = pubkey self.confirm_mute = true } + .onReceive(handle_notify(.attached_wallet)) { notif in + // update the lightning address on our profile when we attach a + // wallet with an associated + let nwc = notif.object as! WalletConnectURL + guard let ds = self.damus_state, + let lud16 = nwc.lud16, + let keypair = ds.keypair.to_full(), + let profile = ds.profiles.lookup(id: ds.pubkey), + lud16 != profile.lud16 + else { + return + } + + // clear zapper cache for old lud16 + if profile.lud16 != nil { + // TODO: should this be somewhere else, where we process profile events!? + invalidate_zapper_cache(pubkey: keypair.pubkey, profiles: ds.profiles, lnurl: ds.lnurls) + } + + profile.lud16 = lud16 + let ev = make_metadata_event(keypair: keypair, metadata: profile) + ds.postbox.send(ev) + } .onReceive(handle_notify(.broadcast_event)) { obj in let ev = obj.object as! NostrEvent guard let ds = self.damus_state else { return } ds.postbox.send(ev) - if let profile = ds.profiles.profiles[ev.pubkey] { + if let profile = ds.profiles.lookup_with_timestamp(id: ev.pubkey) { ds.postbox.send(profile.event) } } @@ -559,7 +590,8 @@ struct ContentView: View { let new_relay_filters = load_relay_filters(pubkey) == nil for relay in bootstrap_relays { if let url = RelayURL(relay) { - add_new_relay(relay_filters: relay_filters, metadatas: metadatas, pool: pool, url: url, info: .rw, new_relay_filters: new_relay_filters) + let descriptor = RelayDescriptor(url: url, info: .rw) + add_new_relay(relay_filters: relay_filters, metadatas: metadatas, pool: pool, descriptor: descriptor, new_relay_filters: new_relay_filters) } } @@ -570,6 +602,11 @@ struct ContentView: View { let settings = UserSettingsStore() UserSettingsStore.shared = settings + if let nwc_str = settings.nostr_wallet_connect, + let nwc = WalletConnectURL(str: nwc_str) { + try? pool.add_relay(.nwc(url: nwc.relay)) + } + self.damus_state = DamusState(pool: pool, keypair: keypair, likes: EventCounter(our_pubkey: pubkey), @@ -589,7 +626,8 @@ struct ContentView: View { postbox: PostBox(pool: pool), bootstrap_relays: bootstrap_relays, replies: ReplyCounter(our_pubkey: pubkey), - muted_threads: MutedThreadsManager(keypair: keypair) + muted_threads: MutedThreadsManager(keypair: keypair), + wallet: WalletModel(settings: settings) ) home.damus_state = self.damus_state! @@ -839,3 +877,40 @@ func handle_post_notification(keypair: FullKeypair, postbox: PostBox, events: Ev return false } } + + +enum OpenResult { + case profile(String) + case filter(NostrFilter) + case event(NostrEvent) + case wallet_connect(WalletConnectURL) +} + +func on_open_url(state: DamusState, url: URL, result: @escaping (OpenResult?) -> Void) { + if let nwc = WalletConnectURL(str: url.absoluteString) { + result(.wallet_connect(nwc)) + return + } + + guard let link = decode_nostr_uri(url.absoluteString) else { + result(nil) + return + } + + switch link { + case .ref(let ref): + if ref.key == "p" { + result(.profile(ref.ref_id)) + } else if ref.key == "e" { + find_event(state: state, evid: ref.ref_id, search_type: .event, find_from: nil) { ev in + if let ev { + result(.event(ev)) + } + } + } + case .filter(let filt): + result(.filter(filt)) + break + // TODO: handle filter searches? + } +} diff --git a/damus/Info.plist b/damus/Info.plist index a0d03416..69de9d88 100644 --- a/damus/Info.plist +++ b/damus/Info.plist @@ -24,6 +24,26 @@ damus + + CFBundleTypeRole + Viewer + CFBundleURLName + io.damus.nwc + CFBundleURLSchemes + + nostrwalletconnect + + + + CFBundleTypeRole + Viewer + CFBundleURLName + io.damus.nwcp + CFBundleURLSchemes + + nostr+walletconnect + + LSApplicationQueriesSchemes diff --git a/damus/Models/ActionBarModel.swift b/damus/Models/ActionBarModel.swift index 4320d2a5..7594c74c 100644 --- a/damus/Models/ActionBarModel.swift +++ b/damus/Models/ActionBarModel.swift @@ -7,12 +7,17 @@ import Foundation +enum Zapped { + case not_zapped + case pending + case zapped +} class ActionBarModel: ObservableObject { @Published var our_like: NostrEvent? @Published var our_boost: NostrEvent? @Published var our_reply: NostrEvent? - @Published var our_zap: Zap? + @Published var our_zap: Zapping? @Published var likes: Int @Published var boosts: Int @Published var zaps: Int @@ -35,7 +40,7 @@ class ActionBarModel: ObservableObject { self.replies = 0 } - init(likes: Int, boosts: Int, zaps: Int, zap_total: Int64, replies: Int, our_like: NostrEvent?, our_boost: NostrEvent?, our_zap: Zap?, our_reply: NostrEvent?) { + init(likes: Int, boosts: Int, zaps: Int, zap_total: Int64, replies: Int, our_like: NostrEvent?, our_boost: NostrEvent?, our_zap: Zapping?, our_reply: NostrEvent?) { self.likes = likes self.boosts = boosts self.zaps = zaps @@ -64,10 +69,6 @@ class ActionBarModel: ObservableObject { return likes == 0 && boosts == 0 && zaps == 0 } - var zapped: Bool { - return our_zap != nil - } - var liked: Bool { return our_like != nil } diff --git a/damus/Models/DamusState.swift b/damus/Models/DamusState.swift index 39ea26a0..2607cd9f 100644 --- a/damus/Models/DamusState.swift +++ b/damus/Models/DamusState.swift @@ -29,9 +29,10 @@ struct DamusState { let bootstrap_relays: [String] let replies: ReplyCounter let muted_threads: MutedThreadsManager + let wallet: WalletModel @discardableResult - func add_zap(zap: Zap) -> Bool { + func add_zap(zap: Zapping) -> Bool { // store generic zap mapping self.zaps.add_zap(zap: zap) // associate with events as well @@ -47,5 +48,5 @@ struct DamusState { } static var empty: DamusState { - return DamusState.init(pool: RelayPool(), keypair: Keypair(pubkey: "", privkey: ""), likes: EventCounter(our_pubkey: ""), boosts: EventCounter(our_pubkey: ""), contacts: Contacts(our_pubkey: ""), profiles: Profiles(), dms: DirectMessagesModel(our_pubkey: ""), previews: PreviewCache(), zaps: Zaps(our_pubkey: ""), lnurls: LNUrls(), settings: UserSettingsStore(), relay_filters: RelayFilters(our_pubkey: ""), relay_metadata: RelayMetadatas(), drafts: Drafts(), events: EventCache(), bookmarks: BookmarksManager(pubkey: ""), postbox: PostBox(pool: RelayPool()), bootstrap_relays: [], replies: ReplyCounter(our_pubkey: ""), muted_threads: MutedThreadsManager(keypair: Keypair(pubkey: "", privkey: nil))) } + return DamusState.init(pool: RelayPool(), keypair: Keypair(pubkey: "", privkey: ""), likes: EventCounter(our_pubkey: ""), boosts: EventCounter(our_pubkey: ""), contacts: Contacts(our_pubkey: ""), profiles: Profiles(), dms: DirectMessagesModel(our_pubkey: ""), previews: PreviewCache(), zaps: Zaps(our_pubkey: ""), lnurls: LNUrls(), settings: UserSettingsStore(), relay_filters: RelayFilters(our_pubkey: ""), relay_metadata: RelayMetadatas(), drafts: Drafts(), events: EventCache(), bookmarks: BookmarksManager(pubkey: ""), postbox: PostBox(pool: RelayPool()), bootstrap_relays: [], replies: ReplyCounter(our_pubkey: ""), muted_threads: MutedThreadsManager(keypair: Keypair(pubkey: "", privkey: nil)), wallet: WalletModel(settings: UserSettingsStore())) } } diff --git a/damus/Models/HomeModel.swift b/damus/Models/HomeModel.swift index 4aee02b8..b89d0017 100644 --- a/damus/Models/HomeModel.swift +++ b/damus/Models/HomeModel.swift @@ -129,21 +129,54 @@ class HomeModel: ObservableObject { handle_zap_event(ev) case .zap_request: break + case .nwc_request: + break + case .nwc_response: + handle_nwc_response(ev, relay: relay_id) + } + } + + func handle_nwc_response(_ ev: NostrEvent, relay: String) { + Task { @MainActor in + // 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 { + return + } + + // since command results are not returned for ephemeral events, + // remove the request from the postbox which is likely failing over and over + if damus_state.postbox.remove_relayer(relay_id: nwc.relay.id, event_id: resp.req_id) { + print("nwc: got response, removed \(resp.req_id) from the postbox [\(relay)]") + } else { + print("nwc: \(resp.req_id) not found in the postbox, nothing to remove [\(relay)]") + } + + guard let err = resp.response.error else { + print("nwc success: \(resp.response.result.debugDescription) [\(relay)]") + nwc_success(state: self.damus_state, resp: resp) + return + } + + print("nwc error: \(resp.response)") + nwc_error(zapcache: self.damus_state.zaps, evcache: self.damus_state.events, resp: resp) } } func handle_zap_event_with_zapper(profiles: Profiles, ev: NostrEvent, our_keypair: Keypair, zapper: String) { + guard let zap = Zap.from_zap_event(zap_ev: ev, zapper: zapper, our_privkey: our_keypair.privkey) else { return } - damus_state.add_zap(zap: zap) + damus_state.add_zap(zap: .zap(zap)) guard zap.target.pubkey == our_keypair.pubkey else { return } - if !notifications.insert_zap(zap) { + if !notifications.insert_zap(.zap(zap)) { return } @@ -301,6 +334,16 @@ class HomeModel: ObservableObject { //remove_bootstrap_nodes(damus_state) send_home_filters(relay_id: relay_id) } + + // connect to nwc relays when connected + if let nwc_str = damus_state.settings.nostr_wallet_connect, + let r = pool.get_relay(relay_id), + r.descriptor.variant == .nwc, + let nwc = WalletConnectURL(str: nwc_str), + nwc.relay.id == relay_id + { + subscribe_to_nwc(url: nwc, pool: pool) + } case .error(let merr): let desc = String(describing: merr) if desc.contains("Software caused connection abort") { @@ -431,7 +474,7 @@ class HomeModel: ObservableObject { print_filters(relay_id: relay_id, filters: [home_filters, contacts_filters, notifications_filters, dms_filters]) - if let relay_id = relay_id { + if let relay_id { pool.send(.subscribe(.init(filters: home_filters, sub_id: home_subid)), to: [relay_id]) pool.send(.subscribe(.init(filters: contacts_filters, sub_id: contacts_subid)), to: [relay_id]) pool.send(.subscribe(.init(filters: notifications_filters, sub_id: notifications_subid)), to: [relay_id]) @@ -691,7 +734,7 @@ func process_metadata_profile(our_pubkey: String, profiles: Profiles, profile: P var old_nip05: String? = nil if let mprof = profiles.lookup_with_timestamp(id: ev.pubkey) { old_nip05 = mprof.profile.nip05 - if mprof.timestamp > ev.created_at { + if mprof.event.created_at > ev.created_at { // skip if we already have an newer profile return } @@ -708,7 +751,7 @@ func process_metadata_profile(our_pubkey: String, profiles: Profiles, profile: P print("validated nip05 for '\(nip05)'") } - DispatchQueue.main.async { + Task { @MainActor in profiles.validated[ev.pubkey] = validated profiles.nip05_pubkey[nip05] = ev.pubkey notify(.profile_updated, ProfileUpdate(pubkey: ev.pubkey, profile: profile)) @@ -836,7 +879,8 @@ func load_our_relays(state: DamusState, m_old_ev: NostrEvent?, ev: NostrEvent) { changed = true if new.contains(d) { if let url = RelayURL(d) { - add_new_relay(relay_filters: state.relay_filters, metadatas: state.relay_metadata, pool: state.pool, url: url, info: decoded[d] ?? .rw, new_relay_filters: new_relay_filters) + let descriptor = RelayDescriptor(url: url, info: decoded[d] ?? .rw) + add_new_relay(relay_filters: state.relay_filters, metadatas: state.relay_metadata, pool: state.pool, descriptor: descriptor, new_relay_filters: new_relay_filters) } } else { state.pool.remove_relay(d) @@ -849,8 +893,9 @@ func load_our_relays(state: DamusState, m_old_ev: NostrEvent?, ev: NostrEvent) { } } -func add_new_relay(relay_filters: RelayFilters, metadatas: RelayMetadatas, pool: RelayPool, url: RelayURL, info: RelayInfo, new_relay_filters: Bool) { - try? pool.add_relay(url, info: info) +func add_new_relay(relay_filters: RelayFilters, metadatas: RelayMetadatas, pool: RelayPool, descriptor: RelayDescriptor, new_relay_filters: Bool) { + try? pool.add_relay(descriptor) + let url = descriptor.url let relay_id = url.id guard metadatas.lookup(relay_id: relay_id) == nil else { @@ -1157,7 +1202,7 @@ func create_local_notification(profiles: Profiles, notify: LocalNotification) { title = String(format: NSLocalizedString("Reposted by %@", comment: "Reposted by heading in local notification"), displayName) identifier = "myBoostNotification" case .like: - title = String(format: NSLocalizedString("%@ reacted with %@", comment: "Reacted by heading in local notification"), displayName, notify.event.content) + title = String(format: NSLocalizedString("%@ reacted with %@", comment: "Reacted by heading in local notification"), displayName, to_reaction_emoji(ev: notify.event) ?? "") identifier = "myLikeNotification" case .dm: title = displayName diff --git a/damus/Models/Notifications/ZapGroup.swift b/damus/Models/Notifications/ZapGroup.swift index aa655acc..15ca1a59 100644 --- a/damus/Models/Notifications/ZapGroup.swift +++ b/damus/Models/Notifications/ZapGroup.swift @@ -8,7 +8,7 @@ import Foundation class ZapGroup { - var zaps: [Zap] + var zaps: [Zapping] var msat_total: Int64 var zappers: Set @@ -17,22 +17,16 @@ class ZapGroup { return 0 } - return first.event.created_at + return first.created_at } func zap_requests() -> [NostrEvent] { - zaps.map { z in - if let priv = z.private_request { - return priv - } else { - return z.request.ev - } - } + zaps.map { z in z.request } } func would_filter(_ isIncluded: (NostrEvent) -> Bool) -> Bool { for zap in zaps { - if !isIncluded(zap.request_ev) { + if !isIncluded(zap.request) { return true } } @@ -41,7 +35,7 @@ class ZapGroup { } func filter(_ isIncluded: (NostrEvent) -> Bool) -> ZapGroup? { - let new_zaps = zaps.filter { isIncluded($0.request_ev) } + let new_zaps = zaps.filter { isIncluded($0.request) } guard new_zaps.count > 0 else { return nil } @@ -59,15 +53,15 @@ class ZapGroup { } @discardableResult - func insert(_ zap: Zap) -> Bool { + func insert(_ zap: Zapping) -> Bool { if !insert_uniq_sorted_zap_by_created(zaps: &zaps, new_zap: zap) { return false } - msat_total += zap.invoice.amount + msat_total += zap.amount - if !zappers.contains(zap.request.ev.pubkey) { - zappers.insert(zap.request.ev.pubkey) + if !zappers.contains(zap.request.pubkey) { + zappers.insert(zap.request.pubkey) } return true diff --git a/damus/Models/NotificationsModel.swift b/damus/Models/NotificationsModel.swift index bbee48d0..85ebf544 100644 --- a/damus/Models/NotificationsModel.swift +++ b/damus/Models/NotificationsModel.swift @@ -99,7 +99,7 @@ enum NotificationItem { } class NotificationsModel: ObservableObject, ScrollQueue { - var incoming_zaps: [Zap] + var incoming_zaps: [Zapping] var incoming_events: [NostrEvent] var should_queue: Bool @@ -150,7 +150,7 @@ class NotificationsModel: ObservableObject, ScrollQueue { } for zap in incoming_zaps { - pks.insert(zap.request.ev.pubkey) + pks.insert(zap.request.pubkey) } return Array(pks) @@ -249,7 +249,7 @@ class NotificationsModel: ObservableObject, ScrollQueue { return false } - private func insert_zap_immediate(_ zap: Zap) -> Bool { + private func insert_zap_immediate(_ zap: Zapping) -> Bool { switch zap.target { case .note(let notezt): let id = notezt.note_id @@ -285,7 +285,7 @@ class NotificationsModel: ObservableObject, ScrollQueue { return false } - func insert_zap(_ zap: Zap) -> Bool { + func insert_zap(_ zap: Zapping) -> Bool { if should_queue { return insert_uniq_sorted_zap_by_created(zaps: &incoming_zaps, new_zap: zap) } @@ -307,7 +307,7 @@ class NotificationsModel: ObservableObject, ScrollQueue { changed = changed || incoming_events.count != count count = profile_zaps.zaps.count - profile_zaps.zaps = profile_zaps.zaps.filter { zap in isIncluded(zap.request.ev) } + profile_zaps.zaps = profile_zaps.zaps.filter { zap in isIncluded(zap.request) } changed = changed || profile_zaps.zaps.count != count for el in reactions { @@ -325,7 +325,7 @@ class NotificationsModel: ObservableObject, ScrollQueue { for el in zaps { count = el.value.zaps.count el.value.zaps = el.value.zaps.filter { - isIncluded($0.request.ev) + isIncluded($0.request) } changed = changed || el.value.zaps.count != count } diff --git a/damus/Models/UserSettingsStore.swift b/damus/Models/UserSettingsStore.swift index abeeb5e9..29962a6a 100644 --- a/damus/Models/UserSettingsStore.swift +++ b/damus/Models/UserSettingsStore.swift @@ -19,8 +19,11 @@ let fallback_zap_amount = 1000 if let loaded = UserDefaults.standard.object(forKey: self.key) as? T { self.value = loaded } else if let loaded = UserDefaults.standard.object(forKey: key) as? T { - // try to load from deprecated non-pubkey-keyed setting + // If pubkey-scoped setting does not exist but the deprecated non-pubkey-scoped setting does, + // migrate the deprecated setting into the pubkey-scoped one and delete the deprecated one. self.value = loaded + UserDefaults.standard.set(loaded, forKey: self.key) + UserDefaults.standard.removeObject(forKey: key) } else { self.value = default_value } @@ -48,8 +51,11 @@ let fallback_zap_amount = 1000 if let loaded = UserDefaults.standard.string(forKey: self.key), let val = T.init(from: loaded) { self.value = val } else if let loaded = UserDefaults.standard.string(forKey: key), let val = T.init(from: loaded) { - // try to load from deprecated non-pubkey-keyed setting + // If pubkey-scoped setting does not exist but the deprecated non-pubkey-scoped setting does, + // migrate the deprecated setting into the pubkey-scoped one and delete the deprecated one. self.value = val + UserDefaults.standard.set(val.to_string(), forKey: self.key) + UserDefaults.standard.removeObject(forKey: key) } else { self.value = default_value } @@ -137,6 +143,9 @@ class UserSettingsStore: ObservableObject { @Setting(key: "disable_animation", default_value: UIAccessibility.isReduceMotionEnabled) var disable_animation: Bool + + @Setting(key: "donation_percent", default_value: 0) + var donation_percent: Int // Helper for inverse of disable_animation. // disable_animation was introduced as a setting first, but it's more natural for the settings UI to show the inverse. @@ -201,6 +210,9 @@ class UserSettingsStore: ObservableObject { @KeychainStorage(account: "libretranslate_apikey") var internal_libretranslate_api_key: String? + + @KeychainStorage(account: "nostr_wallet_connect") + var nostr_wallet_connect: String? // TODO: strongly type this to WalletConnectURL var can_translate: Bool { switch translation_service { diff --git a/damus/Models/WalletModel.swift b/damus/Models/WalletModel.swift new file mode 100644 index 00000000..db6797be --- /dev/null +++ b/damus/Models/WalletModel.swift @@ -0,0 +1,64 @@ +// +// WalletModel.swift +// damus +// +// Created by William Casarin on 2023-05-09. +// + +import Foundation + +enum WalletConnectState { + case new(WalletConnectURL) + case existing(WalletConnectURL) + case none +} + +class WalletModel: ObservableObject { + var settings: UserSettingsStore + private(set) var previous_state: WalletConnectState + var inital_percent: Int + + @Published private(set) var connect_state: WalletConnectState + + init(state: WalletConnectState, settings: UserSettingsStore) { + self.connect_state = state + self.previous_state = .none + self.settings = settings + self.inital_percent = settings.donation_percent + } + + init(settings: UserSettingsStore) { + self.settings = settings + if let str = settings.nostr_wallet_connect, + let nwc = WalletConnectURL(str: str) { + self.previous_state = .existing(nwc) + self.connect_state = .existing(nwc) + } else { + self.previous_state = .none + self.connect_state = .none + } + self.inital_percent = settings.donation_percent + } + + func cancel() { + self.connect_state = previous_state + self.objectWillChange.send() + } + + func disconnect() { + self.settings.nostr_wallet_connect = nil + self.connect_state = .none + self.previous_state = .none + } + + func new(_ nwc: WalletConnectURL) { + self.connect_state = .new(nwc) + } + + func connect(_ nwc: WalletConnectURL) { + self.settings.nostr_wallet_connect = nwc.to_url().absoluteString + notify(.attached_wallet, nwc) + self.connect_state = .existing(nwc) + self.previous_state = .existing(nwc) + } +} diff --git a/damus/Models/ZapsModel.swift b/damus/Models/ZapsModel.swift index ff908464..a3c04f26 100644 --- a/damus/Models/ZapsModel.swift +++ b/damus/Models/ZapsModel.swift @@ -19,7 +19,7 @@ class ZapsModel: ObservableObject { self.target = target } - var zaps: [Zap] { + var zaps: [Zapping] { return state.events.lookup_zaps(target: target) } @@ -53,7 +53,7 @@ class ZapsModel: ObservableObject { case .notice: break case .eose: - let events = state.events.lookup_zaps(target: target).map { $0.request_ev } + let events = state.events.lookup_zaps(target: target).map { $0.request } load_profiles(profiles_subid: profiles_subid, relay_id: relay_id, load: .from_events(events), damus_state: state) case .event(_, let ev): guard ev.kind == 9735 else { @@ -61,22 +61,19 @@ class ZapsModel: ObservableObject { } if let zap = state.zaps.zaps[ev.id] { - if state.events.store_zap(zap: zap) { - objectWillChange.send() - } - } else { - guard let zapper = state.profiles.lookup_zapper(pubkey: target.pubkey) else { - return - } - - guard let zap = Zap.from_zap_event(zap_ev: ev, zapper: zapper, our_privkey: state.keypair.privkey) else { - return - } - - if self.state.add_zap(zap: zap) { - objectWillChange.send() - } + state.events.store_zap(zap: zap) + return } + + guard let zapper = state.profiles.lookup_zapper(pubkey: target.pubkey) else { + return + } + + guard let zap = Zap.from_zap_event(zap_ev: ev, zapper: zapper, our_privkey: state.keypair.privkey) else { + return + } + + self.state.add_zap(zap: .zap(zap)) } diff --git a/damus/Nostr/Nostr.swift b/damus/Nostr/Nostr.swift index 81077c5e..b1cba94b 100644 --- a/damus/Nostr/Nostr.swift +++ b/damus/Nostr/Nostr.swift @@ -10,7 +10,7 @@ import Foundation class Profile: Codable { var value: [String: AnyCodable] - init (name: String?, display_name: String?, about: String?, picture: String?, banner: String?, website: String?, lud06: String?, lud16: String?, nip05: String?) { + init (name: String?, display_name: String?, about: String?, picture: String?, banner: String?, website: String?, lud06: String?, lud16: String?, nip05: String?, damus_donation: Int?) { self.value = [:] self.name = name self.display_name = display_name @@ -21,6 +21,7 @@ class Profile: Codable { self.lud06 = lud06 self.lud16 = lud16 self.nip05 = nip05 + self.damus_donation = damus_donation } convenience init(persisted_profile: PersistedProfile) { @@ -39,6 +40,10 @@ class Profile: Codable { return get_val(str) } + private func int(_ key: String) -> Int? { + return get_val(key) + } + private func get_val(_ v: String) -> T? { guard let val = self.value[v] else{ return nil @@ -64,6 +69,10 @@ class Profile: Codable { set_val(key, val) } + private func set_int(_ key: String, _ val: Int?) { + set_val(key, val) + } + var reactions: Bool? { get { return get_val("reactions"); } set(s) { set_val("reactions", s) } @@ -89,6 +98,11 @@ class Profile: Codable { set(s) { set_str("about", s) } } + var damus_donation: Int? { + get { return int("damus_donation"); } + set(s) { set_int("damus_donation", s) } + } + var picture: String? { get { return str("picture"); } set(s) { set_str("picture", s) } @@ -192,7 +206,7 @@ class Profile: Codable { } func make_test_profile() -> Profile { - return Profile(name: "jb55", display_name: "Will", about: "Its a me", picture: "https://cdn.jb55.com/img/red-me.jpg", banner: "https://pbs.twimg.com/profile_banners/9918032/1531711830/600x200", website: "jb55.com", lud06: "jb55@jb55.com", lud16: nil, nip05: "jb55@jb55.com") + return Profile(name: "jb55", display_name: "Will", about: "Its a me", picture: "https://cdn.jb55.com/img/red-me.jpg", banner: "https://pbs.twimg.com/profile_banners/9918032/1531711830/600x200", website: "jb55.com", lud06: "jb55@jb55.com", lud16: nil, nip05: "jb55@jb55.com", damus_donation: 1) } func make_ln_url(_ str: String?) -> URL? { diff --git a/damus/Nostr/NostrEvent.swift b/damus/Nostr/NostrEvent.swift index 5243fd67..2879ddef 100644 --- a/damus/Nostr/NostrEvent.swift +++ b/damus/Nostr/NostrEvent.swift @@ -492,11 +492,11 @@ func make_boost_event(pubkey: String, privkey: String, boosted: NostrEvent) -> N return ev } -func make_like_event(pubkey: String, privkey: String, liked: NostrEvent) -> NostrEvent { +func make_like_event(pubkey: String, privkey: String, liked: NostrEvent, content: String = "🤙") -> NostrEvent { var tags: [[String]] = liked.tags.filter { tag in tag.count >= 2 && (tag[0] == "e" || tag[0] == "p") } tags.append(["e", liked.id]) tags.append(["p", liked.pubkey]) - let ev = NostrEvent(content: "🤙", pubkey: pubkey, kind: 7, tags: tags) + let ev = NostrEvent(content: content, pubkey: pubkey, kind: 7, tags: tags) ev.calculate_id() ev.sign(privkey: privkey) @@ -512,7 +512,12 @@ func zap_target_to_tags(_ target: ZapTarget) -> [[String]] { } } -func make_private_zap_request_event(identity: FullKeypair, enc_key: FullKeypair, target: ZapTarget, message: String) -> String? { +struct PrivateZapRequest { + let req: ZapRequest + let enc: String +} + +func make_private_zap_request_event(identity: FullKeypair, enc_key: FullKeypair, target: ZapTarget, message: String) -> PrivateZapRequest? { // target tags must be the same as zap request target tags let tags = zap_target_to_tags(target) @@ -520,10 +525,13 @@ func make_private_zap_request_event(identity: FullKeypair, enc_key: FullKeypair, note.id = calculate_event_id(ev: note) note.sig = sign_event(privkey: identity.privkey, ev: note) - guard let note_json = encode_json(note) else { + guard let note_json = encode_json(note), + let enc = encrypt_message(message: note_json, privkey: enc_key.privkey, to_pk: target.pubkey, encoding: .bech32) + else { return nil } - return encrypt_message(message: note_json, privkey: enc_key.privkey, to_pk: target.pubkey, encoding: .bech32) + + return PrivateZapRequest(req: ZapRequest(ev: note), enc: enc) } func decrypt_private_zap(our_privkey: String, zapreq: NostrEvent, target: ZapTarget) -> NostrEvent? { @@ -587,7 +595,30 @@ func generate_private_keypair(our_privkey: String, id: String, created_at: Int64 return FullKeypair(pubkey: pubkey, privkey: privkey) } -func make_zap_request_event(keypair: FullKeypair, content: String, relays: [RelayDescriptor], target: ZapTarget, zap_type: ZapType) -> NostrEvent? { +enum MakeZapRequest { + case priv(ZapRequest, PrivateZapRequest) + case normal(ZapRequest) + + var private_inner_request: ZapRequest { + switch self { + case .priv(_, let pzr): + return pzr.req + case .normal(let zr): + return zr + } + } + + var potentially_anon_outer_request: ZapRequest { + switch self { + case .priv(let zr, _): + return zr + case .normal(let zr): + return zr + } + } +} + +func make_zap_request_event(keypair: FullKeypair, content: String, relays: [RelayDescriptor], target: ZapTarget, zap_type: ZapType) -> MakeZapRequest? { var tags = zap_target_to_tags(target) var relay_tag = ["relays"] relay_tag.append(contentsOf: relays.map { $0.url.id }) @@ -597,6 +628,8 @@ func make_zap_request_event(keypair: FullKeypair, content: String, relays: [Rela let now = Int64(Date().timeIntervalSince1970) + var privzap_req: PrivateZapRequest? + var message = content switch zap_type { case .pub: @@ -614,14 +647,20 @@ func make_zap_request_event(keypair: FullKeypair, content: String, relays: [Rela guard let privreq = make_private_zap_request_event(identity: keypair, enc_key: kp, target: target, message: message) else { return nil } - tags.append(["anon", privreq]) + tags.append(["anon", privreq.enc]) message = "" + privzap_req = privreq } let ev = NostrEvent(content: message, pubkey: kp.pubkey, kind: 9734, tags: tags, createdAt: now) ev.id = calculate_event_id(ev: ev) ev.sig = sign_event(privkey: kp.privkey, ev: ev) - return ev + let zapreq = ZapRequest(ev: ev) + if let privzap_req { + return .priv(zapreq, privzap_req) + } else { + return .normal(zapreq) + } } func uniq(_ xs: [T]) -> [T] { @@ -927,6 +966,28 @@ func first_eref_mention(ev: NostrEvent, privkey: String?) -> Mention? { return nil } +/** + Transforms a `NostrEvent` of known kind `NostrKind.like`to a human-readable emoji. + If the known kind is not a `NostrKind.like`, it will return `nil`. + If the event content is an empty string or `+`, it will map that to a heart ❤️ emoji. + If the event content is a "-", it will map that to a dislike 👎 emoji. + Otherwise, it will return the event content at face value without transforming it. + */ +func to_reaction_emoji(ev: NostrEvent) -> String? { + guard ev.known_kind == NostrKind.like else { + return nil + } + + switch ev.content { + case "", "+": + return "❤️" + case "-": + return "👎" + default: + return ev.content + } +} + extension [ReferencedId] { var pRefs: [ReferencedId] { get { diff --git a/damus/Nostr/NostrKind.swift b/damus/Nostr/NostrKind.swift index 3d02ca87..3ee200b4 100644 --- a/damus/Nostr/NostrKind.swift +++ b/damus/Nostr/NostrKind.swift @@ -22,4 +22,6 @@ enum NostrKind: Int { case list = 30000 case zap = 9735 case zap_request = 9734 + case nwc_request = 23194 + case nwc_response = 23195 } diff --git a/damus/Nostr/Profiles.swift b/damus/Nostr/Profiles.swift index 05f914db..e5452739 100644 --- a/damus/Nostr/Profiles.swift +++ b/damus/Nostr/Profiles.swift @@ -17,7 +17,7 @@ class Profiles { qos: .userInteractive, attributes: .concurrent) - var profiles: [String: TimestampedProfile] = [:] + private var profiles: [String: TimestampedProfile] = [:] var validated: [String: NIP05] = [:] var nip05_pubkey: [String: String] = [:] var zappers: [String: String] = [:] @@ -28,6 +28,12 @@ class Profiles { validated[pk] } + func enumerated() -> EnumeratedSequence<[String: TimestampedProfile]> { + return queue.sync { + return profiles.enumerated() + } + } + func lookup_zapper(pubkey: String) -> String? { zappers[pubkey] } @@ -77,3 +83,9 @@ class Profiles { return Date.now.timeIntervalSince(pull_date) < Profiles.db_freshness_threshold } } + + +func invalidate_zapper_cache(pubkey: String, profiles: Profiles, lnurl: LNUrls) { + profiles.zappers.removeValue(forKey: pubkey) + lnurl.endpoints.removeValue(forKey: pubkey) +} diff --git a/damus/Nostr/Relay.swift b/damus/Nostr/Relay.swift index 2aa159de..110922bf 100644 --- a/damus/Nostr/Relay.swift +++ b/damus/Nostr/Relay.swift @@ -10,21 +10,46 @@ import Foundation public struct RelayInfo: Codable { let read: Bool? let write: Bool? - let ephemeral: Bool? - init(read: Bool, write: Bool, ephemeral: Bool = false) { + init(read: Bool, write: Bool) { self.read = read self.write = write - self.ephemeral = ephemeral } - static let rw = RelayInfo(read: true, write: true, ephemeral: false) - static let ephemeral = RelayInfo(read: true, write: true, ephemeral: true) + static let rw = RelayInfo(read: true, write: true) +} + +enum RelayVariant { + case regular + case ephemeral + case nwc } public struct RelayDescriptor { - public let url: RelayURL - public let info: RelayInfo + let url: RelayURL + let info: RelayInfo + let variant: RelayVariant + + init(url: RelayURL, info: RelayInfo, variant: RelayVariant = .regular) { + self.url = url + self.info = info + self.variant = variant + } + + var ephemeral: Bool { + switch variant { + case .regular: + return false + case .ephemeral: + return true + case .nwc: + return true + } + } + + static func nwc(url: RelayURL) -> RelayDescriptor { + return RelayDescriptor(url: url, info: .rw, variant: .nwc) + } } enum RelayFlags: Int { diff --git a/damus/Nostr/RelayPool.swift b/damus/Nostr/RelayPool.swift index 20ab9490..72230eec 100644 --- a/damus/Nostr/RelayPool.swift +++ b/damus/Nostr/RelayPool.swift @@ -43,7 +43,7 @@ class RelayPool { } var our_descriptors: [RelayDescriptor] { - return all_descriptors.filter { d in !(d.info.ephemeral ?? false) } + return all_descriptors.filter { d in !d.ephemeral } } var all_descriptors: [RelayDescriptor] { @@ -91,7 +91,8 @@ class RelayPool { } } - func add_relay(_ url: RelayURL, info: RelayInfo) throws { + func add_relay(_ desc: RelayDescriptor) throws { + let url = desc.url let relay_id = get_relay_id(url) if get_relay(relay_id) != nil { throw RelayError.RelayAlreadyExists @@ -99,8 +100,7 @@ class RelayPool { let conn = RelayConnection(url: url) { event in self.handle_event(relay_id: relay_id, event: event) } - let descriptor = RelayDescriptor(url: url, info: info) - let relay = Relay(descriptor: descriptor, connection: conn) + let relay = Relay(descriptor: desc, connection: conn) self.relays.append(relay) } @@ -196,7 +196,7 @@ class RelayPool { continue } - if (relay.descriptor.info.ephemeral ?? false) && skip_ephemeral { + if relay.descriptor.ephemeral && skip_ephemeral { continue } @@ -266,7 +266,7 @@ func add_rw_relay(_ pool: RelayPool, _ url: String) { guard let url = RelayURL(url) else { return } - try? pool.add_relay(url, info: RelayInfo.rw) + try? pool.add_relay(RelayDescriptor(url: url, info: .rw)) } diff --git a/damus/Util/EventCache.swift b/damus/Util/EventCache.swift index 6ff6a34a..4f5e01ab 100644 --- a/damus/Util/EventCache.swift +++ b/damus/Util/EventCache.swift @@ -55,11 +55,46 @@ class PreviewModel: ObservableObject { } class ZapsDataModel: ObservableObject { - @Published var zaps: [Zap] + @Published var zaps: [Zapping] - init(_ zaps: [Zap]) { + init(_ zaps: [Zapping]) { self.zaps = zaps } + + func confirm_nwc(reqid: String) { + guard let zap = zaps.first(where: { z in z.request.id == reqid }), + case .pending(let pzap) = zap + else { + return + } + + switch pzap.state { + case .external: + break + case .nwc(let nwc_state): + if nwc_state.update_state(state: .confirmed) { + self.objectWillChange.send() + } + } + } + + var zap_total: Int64 { + zaps.reduce(0) { total, zap in total + zap.amount } + } + + func from(_ pubkey: String) -> [Zapping] { + return self.zaps.filter { z in z.request.pubkey == pubkey } + } + + @discardableResult + func remove(reqid: String) -> Bool { + guard zaps.first(where: { z in z.request.id == reqid }) != nil else { + return false + } + + self.zaps = zaps.filter { z in z.request.id != reqid } + return true + } } class RelativeTimeModel: ObservableObject { @@ -86,7 +121,7 @@ class EventData { return preview_model.state } - init(zaps: [Zap] = []) { + init(zaps: [Zapping] = []) { self.translations_model = .init(state: .havent_tried) self.artifacts_model = .init(state: .not_loaded) self.zaps_model = .init(zaps) @@ -131,12 +166,23 @@ class EventCache { } @discardableResult - func store_zap(zap: Zap) -> Bool { + func store_zap(zap: Zapping) -> Bool { let data = get_cache_data(zap.target.id).zaps_model return insert_uniq_sorted_zap_by_amount(zaps: &data.zaps, new_zap: zap) } - func lookup_zaps(target: ZapTarget) -> [Zap] { + func remove_zap(zap: Zapping) { + switch zap.target { + case .note(let note_target): + let zaps = get_cache_data(note_target.note_id).zaps_model + zaps.remove(reqid: zap.request.id) + case .profile: + // these aren't stored anywhere yet + break + } + } + + func lookup_zaps(target: ZapTarget) -> [Zapping] { return get_cache_data(target.id).zaps_model.zaps } diff --git a/damus/Util/InsertSort.swift b/damus/Util/InsertSort.swift index 613344b6..b6249c4e 100644 --- a/damus/Util/InsertSort.swift +++ b/damus/Util/InsertSort.swift @@ -7,12 +7,18 @@ import Foundation -func insert_uniq_sorted_zap(zaps: inout [Zap], new_zap: Zap, cmp: (Zap, Zap) -> Bool) -> Bool { +func insert_uniq_sorted_zap(zaps: inout [Zapping], new_zap: Zapping, cmp: (Zapping, Zapping) -> Bool) -> Bool { var i: Int = 0 for zap in zaps { - // don't insert duplicate events - if new_zap.event.id == zap.event.id { + if new_zap.request.id == zap.request.id { + // replace pending + if !new_zap.is_pending && zap.is_pending { + print("nwc: replacing pending with real zap \(new_zap.request.id)") + zaps[i] = new_zap + return true + } + // don't insert duplicate events return false } @@ -28,16 +34,16 @@ func insert_uniq_sorted_zap(zaps: inout [Zap], new_zap: Zap, cmp: (Zap, Zap) -> } @discardableResult -func insert_uniq_sorted_zap_by_created(zaps: inout [Zap], new_zap: Zap) -> Bool { +func insert_uniq_sorted_zap_by_created(zaps: inout [Zapping], new_zap: Zapping) -> Bool { return insert_uniq_sorted_zap(zaps: &zaps, new_zap: new_zap) { (a, b) in - a.event.created_at > b.event.created_at + a.created_at > b.created_at } } @discardableResult -func insert_uniq_sorted_zap_by_amount(zaps: inout [Zap], new_zap: Zap) -> Bool { +func insert_uniq_sorted_zap_by_amount(zaps: inout [Zapping], new_zap: Zapping) -> Bool { return insert_uniq_sorted_zap(zaps: &zaps, new_zap: new_zap) { (a, b) in - a.invoice.amount > b.invoice.amount + a.amount > b.amount } } diff --git a/damus/Util/Keys.swift b/damus/Util/Keys.swift index 23e5c67e..5530b502 100644 --- a/damus/Util/Keys.swift +++ b/damus/Util/Keys.swift @@ -10,7 +10,7 @@ import secp256k1 let PUBKEY_HRP = "npub" -struct FullKeypair { +struct FullKeypair: Equatable { let pubkey: String let privkey: String } diff --git a/damus/Util/Notifications.swift b/damus/Util/Notifications.swift index 11021b51..d23011fc 100644 --- a/damus/Util/Notifications.swift +++ b/damus/Util/Notifications.swift @@ -92,6 +92,9 @@ extension Notification.Name { static var onlyzaps_mode: Notification.Name { return Notification.Name("hide_reactions") } + static var attached_wallet: Notification.Name { + return Notification.Name("attached_wallet") + } } func handle_notify(_ name: Notification.Name) -> NotificationCenter.Publisher { diff --git a/damus/Util/PostBox.swift b/damus/Util/PostBox.swift index 902af1a6..a7d77634 100644 --- a/damus/Util/PostBox.swift +++ b/damus/Util/PostBox.swift @@ -22,20 +22,37 @@ class Relayer { } } +enum OnFlush { + case once((PostedEvent) -> Void) + case all((PostedEvent) -> Void) +} + class PostedEvent { let event: NostrEvent let skip_ephemeral: Bool var remaining: [Relayer] + let flush_after: Date? + var flushed_once: Bool + let on_flush: OnFlush? - init(event: NostrEvent, remaining: [String], skip_ephemeral: Bool) { + init(event: NostrEvent, remaining: [String], skip_ephemeral: Bool, flush_after: Date?, on_flush: OnFlush?) { self.event = event self.skip_ephemeral = skip_ephemeral + self.flush_after = flush_after + self.on_flush = on_flush + self.flushed_once = false self.remaining = remaining.map { - Relayer(relay: $0, attempts: 0, retry_after: 2.0) + Relayer(relay: $0, attempts: 0, retry_after: 10.0) } } } +enum CancelSendErr { + case nothing_to_cancel + case not_delayed + case too_late +} + class PostBox { let pool: RelayPool var events: [String: PostedEvent] @@ -46,12 +63,37 @@ class PostBox { pool.register_handler(sub_id: "postbox", handler: handle_event) } + // only works reliably on delay-sent events + func cancel_send(evid: String) -> CancelSendErr? { + guard let ev = events[evid] else { + return .nothing_to_cancel + } + + guard let after = ev.flush_after else { + return .not_delayed + } + + guard Date.now < after else { + return .too_late + } + + events.removeValue(forKey: evid) + return nil + } + func try_flushing_events() { let now = Int64(Date().timeIntervalSince1970) for kv in events { let event = kv.value + + // some are delayed + if let after = event.flush_after, Date.now.timeIntervalSince1970 < after.timeIntervalSince1970 { + continue + } + for relayer in event.remaining { - if relayer.last_attempt == nil || (now >= (relayer.last_attempt! + Int64(relayer.retry_after))) { + if relayer.last_attempt == nil || + (now >= (relayer.last_attempt! + Int64(relayer.retry_after))) { print("attempt #\(relayer.attempts) to flush event '\(event.event.content)' to \(relayer.relay) after \(relayer.retry_after) seconds") flush_event(event, to_relay: relayer) } @@ -60,8 +102,6 @@ class PostBox { } func handle_event(relay_id: String, _ ev: NostrConnectionEvent) { - try_flushing_events() - guard case .nostr_event(let resp) = ev else { return } @@ -73,16 +113,31 @@ class PostBox { remove_relayer(relay_id: relay_id, event_id: cr.event_id) } - func remove_relayer(relay_id: String, event_id: String) { + @discardableResult + func remove_relayer(relay_id: String, event_id: String) -> Bool { guard let ev = self.events[event_id] else { - return + return false } - ev.remaining = ev.remaining.filter { - $0.relay != relay_id + + if let on_flush = ev.on_flush { + switch on_flush { + case .once(let cb): + if !ev.flushed_once { + ev.flushed_once = true + cb(ev) + } + case .all(let cb): + cb(ev) + } } + + let prev_count = ev.remaining.count + ev.remaining = ev.remaining.filter { $0.relay != relay_id } + let after_count = ev.remaining.count if ev.remaining.count == 0 { self.events.removeValue(forKey: event_id) } + return prev_count != after_count } private func flush_event(_ event: PostedEvent, to_relay: Relayer? = nil) { @@ -95,20 +150,31 @@ class PostBox { relayer.attempts += 1 relayer.last_attempt = Int64(Date().timeIntervalSince1970) relayer.retry_after *= 1.5 + if let relay = pool.get_relay(relayer.relay) { + print("flushing event \(event.event.id) to \(relayer.relay)") + } else { + print("could not find relay when flushing: \(relayer.relay)") + } pool.send(.event(event.event), to: [relayer.relay], skip_ephemeral: event.skip_ephemeral) } } - func send(_ event: NostrEvent, to: [String]? = nil, skip_ephemeral: Bool = true) { + func send(_ event: NostrEvent, to: [String]? = nil, skip_ephemeral: Bool = true, delay: TimeInterval? = nil, on_flush: OnFlush? = nil) { // Don't add event if we already have it if events[event.id] != nil { return } let remaining = to ?? pool.our_descriptors.map { $0.url.id } - let posted_ev = PostedEvent(event: event, remaining: remaining, skip_ephemeral: skip_ephemeral) + let after = delay.map { d in Date.now.addingTimeInterval(d) } + let posted_ev = PostedEvent(event: event, remaining: remaining, skip_ephemeral: skip_ephemeral, flush_after: after, on_flush: on_flush) + events[event.id] = posted_ev - flush_event(posted_ev) + if after == nil { + flush_event(posted_ev) + } } } + + diff --git a/damus/Util/WalletConnect.swift b/damus/Util/WalletConnect.swift new file mode 100644 index 00000000..f54094a4 --- /dev/null +++ b/damus/Util/WalletConnect.swift @@ -0,0 +1,260 @@ +// +// 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: String + let lud16: String? + + func to_url() -> URL { + var urlComponents = URLComponents() + urlComponents.scheme = "nostrwalletconnect" + urlComponents.host = pubkey + urlComponents.queryItems = [ + URLQueryItem(name: "relay", value: relay.id), + URLQueryItem(name: "secret", value: keypair.privkey) + ] + + if let lud16 { + urlComponents.queryItems?.append(URLQueryItem(name: "lud16", value: lud16)) + } + + return urlComponents.url! + } + + init?(str: String) { + guard let url = URL(string: str), + url.scheme == "nostrwalletconnect" || url.scheme == "nostr+walletconnect", + let pk = url.host, pk.utf8.count == 64, + let components = URLComponents(url: url, resolvingAgainstBaseURL: true), + 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 our_pk = privkey_to_pubkey(privkey: secret) + else { + return nil + } + + let lud16 = items.first(where: { qi in qi.name == "lud16" })?.value + let keypair = FullKeypair(pubkey: our_pk, privkey: secret) + self = WalletConnectURL(pubkey: pk, relay: relay_url, keypair: keypair, lud16: lud16) + } + + init(pubkey: String, 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: String + let response: WalletResponse + + init?(from: NostrEvent, nwc: WalletConnectURL) async { + guard let req_id = from.referenced_ids.first else { + return nil + } + + self.req_id = req_id.ref_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) + } + } +} + +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: String, keypair: FullKeypair) -> NostrEvent? { + let tags = [["p", to_pk]] + let created_at = Int64(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 = .filter_kinds([NostrKind.nwc_response.rawValue]) + filter.authors = [url.pubkey] + filter.limit = 0 + let sub = NostrSubscribe(filters: [filter], sub_id: "nwc") + + pool.send(.subscribe(sub), to: [url.relay.id], 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.id], 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(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/Zap.swift b/damus/Util/Zap.swift index a466b3dd..f8c6291b 100644 --- a/damus/Util/Zap.swift +++ b/damus/Util/Zap.swift @@ -7,7 +7,7 @@ import Foundation -public struct NoteZapTarget: Equatable { +public struct NoteZapTarget: Equatable, Hashable { public let note_id: String public let author: String } @@ -41,6 +41,200 @@ public enum ZapTarget: Equatable { struct ZapRequest { let ev: NostrEvent + +} + +enum ExtPendingZapStateType { + case fetching_invoice + case done +} + +class ExtPendingZapState: Equatable { + static func == (lhs: ExtPendingZapState, rhs: ExtPendingZapState) -> Bool { + return lhs.state == rhs.state + } + + var state: ExtPendingZapStateType + + init(state: ExtPendingZapStateType) { + self.state = state + } +} + +enum PendingZapState: Equatable { + case nwc(NWCPendingZapState) + case external(ExtPendingZapState) +} + + +enum NWCStateType: Equatable { + case fetching_invoice + case cancel_fetching_invoice + case postbox_pending(NostrEvent) + case confirmed + case failed +} + +class NWCPendingZapState: Equatable { + private(set) var state: NWCStateType + let url: WalletConnectURL + + init(state: NWCStateType, url: WalletConnectURL) { + self.state = state + self.url = url + } + + //@discardableResult -- not discardable, the ZapsDataModel may need to send objectWillChange but we don't force it + func update_state(state: NWCStateType) -> Bool { + guard state != self.state else { + return false + } + self.state = state + return true + } + + static func == (lhs: NWCPendingZapState, rhs: NWCPendingZapState) -> Bool { + return lhs.state == rhs.state && lhs.url == rhs.url + } +} + +class PendingZap { + let amount_msat: Int64 + let target: ZapTarget + let request: ZapRequest + let type: ZapType + private(set) var state: PendingZapState + + init(amount_msat: Int64, target: ZapTarget, request: MakeZapRequest, type: ZapType, state: PendingZapState) { + self.amount_msat = amount_msat + self.target = target + self.request = request.private_inner_request + self.type = type + self.state = state + } + + @discardableResult + func update_state(model: ZapsDataModel, state: PendingZapState) -> Bool { + guard self.state != state else { + return false + } + + self.state = state + model.objectWillChange.send() + return true + } +} + +struct ZapRequestId: Equatable { + let reqid: String + + init(from_zap: Zapping) { + self.reqid = from_zap.request.id + } + + init(from_makezap: MakeZapRequest) { + self.reqid = from_makezap.private_inner_request.ev.id + } + + init(from_pending: PendingZap) { + self.reqid = from_pending.request.ev.id + } +} + +enum Zapping { + case zap(Zap) + case pending(PendingZap) + + var is_pending: Bool { + switch self { + case .zap: + return false + case .pending: + return true + } + } + + var is_paid: Bool { + switch self { + case .zap: + // we have a zap so this is proof of payment + return true + case .pending(let pzap): + switch pzap.state { + case .external: + // It could be but we don't know. We have to wait for a zap to know. + return false + case .nwc(let nwc_state): + // nwc confirmed that we have a payment, but we might not have zap yet + return nwc_state.state == .confirmed + } + } + } + + var is_private: Bool { + switch self { + case .zap(let zap): + return zap.private_request != nil + case .pending(let pzap): + return pzap.type == .priv + } + } + + var amount: Int64 { + switch self { + case .zap(let zap): + return zap.invoice.amount + case .pending(let pzap): + return pzap.amount_msat + } + } + + var target: ZapTarget { + switch self { + case .zap(let zap): + return zap.target + case .pending(let pzap): + return pzap.target + } + } + + var request: NostrEvent { + switch self { + case .zap(let zap): + return zap.request_ev + case .pending(let pzap): + return pzap.request.ev + } + } + + var created_at: Int64 { + switch self { + case .zap(let zap): + return zap.event.created_at + case .pending(let pzap): + // pending zaps are created right away + return pzap.request.ev.created_at + } + } + + var event: NostrEvent? { + switch self { + case .zap(let zap): + return zap.event + case .pending: + // pending zaps don't have a zap event + return nil + } + } + + var is_anon: Bool { + switch self { + case .zap(let zap): + return zap.is_anon + case .pending(let pzap): + return pzap.type == .anon + } + } } struct Zap { @@ -246,17 +440,16 @@ func fetch_static_payreq(_ lnurl: String) async -> LNUrlPayRequest? { return endpoint } -func fetch_zap_invoice(_ payreq: LNUrlPayRequest, zapreq: NostrEvent?, sats: Int, zap_type: ZapType, comment: String?) async -> String? { +func fetch_zap_invoice(_ payreq: LNUrlPayRequest, zapreq: NostrEvent?, msats: Int64, zap_type: ZapType, comment: String?) async -> String? { guard var base_url = payreq.callback.flatMap({ URLComponents(string: $0) }) else { return nil } let zappable = payreq.allowsNostr ?? false - let amount: Int64 = Int64(sats) * 1000 - var query = [URLQueryItem(name: "amount", value: "\(amount)")] + var query = [URLQueryItem(name: "amount", value: "\(msats)")] - if let zapreq, zappable && zap_type != .non_zap, let json = encode_json(zapreq) { + if zappable && zap_type != .non_zap, let json = encode_json(zapreq) { print("zapreq json: \(json)") query.append(URLQueryItem(name: "nostr", value: json)) } @@ -293,5 +486,12 @@ func fetch_zap_invoice(_ payreq: LNUrlPayRequest, zapreq: NostrEvent?, sats: Int return nil } + // make sure it's the correct amount + guard let bolt11 = decode_bolt11(result.pr), + .specific(msats) == bolt11.amount + else { + return nil + } + return result.pr } diff --git a/damus/Util/Zaps.swift b/damus/Util/Zaps.swift index 5d6b8e67..f34cfdc9 100644 --- a/damus/Util/Zaps.swift +++ b/damus/Util/Zaps.swift @@ -8,9 +8,9 @@ import Foundation class Zaps { - var zaps: [String: Zap] + var zaps: [String: Zapping] let our_pubkey: String - var our_zaps: [String: [Zap]] + var our_zaps: [String: [Zapping]] var event_counts: [String: Int] var event_totals: [String: Int64] @@ -23,14 +23,41 @@ class Zaps { self.event_totals = [:] } - func add_zap(zap: Zap) { - if zaps[zap.event.id] != nil { + func remove_zap(reqid: String) -> Zapping? { + var res: Zapping? = nil + for kv in our_zaps { + let ours = kv.value + guard let zap = ours.first(where: { z in z.request.id == reqid }) else { + continue + } + + res = zap + + our_zaps[kv.key] = ours.filter { z in z.request.id != reqid } + + if let count = event_counts[zap.target.id] { + event_counts[zap.target.id] = count - 1 + } + if let total = event_totals[zap.target.id] { + event_totals[zap.target.id] = total - zap.amount + } + + // we found the request id, we can stop looking + break + } + + self.zaps.removeValue(forKey: reqid) + return res + } + + func add_zap(zap: Zapping) { + if zaps[zap.request.id] != nil { return } - self.zaps[zap.event.id] = zap + self.zaps[zap.request.id] = zap // record our zaps for an event - if zap.request.ev.pubkey == our_pubkey { + if zap.request.pubkey == our_pubkey { switch zap.target { case .note(let note_target): if our_zaps[note_target.note_id] == nil { @@ -44,7 +71,7 @@ class Zaps { } // don't count tips to self. lame. - guard zap.request.ev.pubkey != zap.target.pubkey else { + guard zap.request.pubkey != zap.target.pubkey else { return } @@ -58,8 +85,15 @@ class Zaps { } event_counts[id] = event_counts[id]! + 1 - event_totals[id] = event_totals[id]! + zap.invoice.amount + event_totals[id] = event_totals[id]! + zap.amount notify(.update_stats, zap.target.id) } } + +func remove_zap(reqid: ZapRequestId, zapcache: Zaps, evcache: EventCache) { + guard let zap = zapcache.remove_zap(reqid: reqid.reqid) else { + return + } + evcache.get_cache_data(zap.target.id).zaps_model.remove(reqid: reqid.reqid) +} diff --git a/damus/Views/ActionBar/EventActionBar.swift b/damus/Views/ActionBar/EventActionBar.swift index f774173a..c0317d72 100644 --- a/damus/Views/ActionBar/EventActionBar.swift +++ b/damus/Views/ActionBar/EventActionBar.swift @@ -88,7 +88,7 @@ struct EventActionBar: View { if let lnurl = self.lnurl { Spacer() - ZapButton(damus_state: damus_state, event: event, lnurl: lnurl, bar: bar) + ZapButton(damus_state: damus_state, event: event, lnurl: lnurl, zaps: self.damus_state.events.get_cache_data(self.event.id).zaps_model) } Spacer() @@ -227,7 +227,7 @@ struct EventActionBar_Previews: PreviewProvider { let likedbar_ours = ActionBarModel(likes: 10, boosts: 0, zaps: 0, zap_total: 0, replies: 0, our_like: test_event, our_boost: nil, our_zap: nil, our_reply: nil) let maxed_bar = ActionBarModel(likes: 999, boosts: 999, zaps: 999, zap_total: 99999999, replies: 999, our_like: test_event, our_boost: test_event, our_zap: nil, our_reply: nil) let extra_max_bar = ActionBarModel(likes: 9999, boosts: 9999, zaps: 9999, zap_total: 99999999, replies: 9999, our_like: test_event, our_boost: test_event, our_zap: nil, our_reply: test_event) - let mega_max_bar = ActionBarModel(likes: 9999999, boosts: 99999, zaps: 9999, zap_total: 99999999, replies: 9999999, our_like: test_event, our_boost: test_event, our_zap: test_zap, our_reply: test_event) + let mega_max_bar = ActionBarModel(likes: 9999999, boosts: 99999, zaps: 9999, zap_total: 99999999, replies: 9999999, our_like: test_event, our_boost: test_event, our_zap: .zap(test_zap), our_reply: test_event) VStack(spacing: 50) { EventActionBar(damus_state: ds, event: ev, bar: bar) diff --git a/damus/Views/Buttons/AlbyButton.swift b/damus/Views/Buttons/AlbyButton.swift index 28c3da28..ad6ffa96 100644 --- a/damus/Views/Buttons/AlbyButton.swift +++ b/damus/Views/Buttons/AlbyButton.swift @@ -23,7 +23,7 @@ struct AlbyButton: View { HStack { Image("alby") - Text("Connect to Alby") + Text("Attach Alby Wallet", comment: "Button to attach an Alby Wallet, a service that provides a Lightning wallet for zapping sats. Alby is the name of the service and should not be translated.") } .offset(x: -25) .frame(minWidth: 300, maxWidth: .infinity, minHeight: 50, maxHeight: 50, alignment: .center) diff --git a/damus/Views/Events/TextEvent.swift b/damus/Views/Events/TextEvent.swift index 1550b8e7..4d5da904 100644 --- a/damus/Views/Events/TextEvent.swift +++ b/damus/Views/Events/TextEvent.swift @@ -181,7 +181,9 @@ struct TextEvent: View { VStack(alignment: .leading) { TopPart(is_anon: is_anon) - ReplyPart + if !options.contains(.no_replying_to) { + ReplyPart + } EvBody(options: self.options) diff --git a/damus/Views/Events/ZapEvent.swift b/damus/Views/Events/ZapEvent.swift index eadbf8c3..e0531e80 100644 --- a/damus/Views/Events/ZapEvent.swift +++ b/damus/Views/Events/ZapEvent.swift @@ -9,30 +9,30 @@ import SwiftUI struct ZapEvent: View { let damus: DamusState - let zap: Zap + let zap: Zapping var body: some View { VStack(alignment: .leading) { HStack(alignment: .center) { - Text("⚡️ \(format_msats(zap.invoice.amount))", comment: "Text indicating the zap amount. i.e. number of satoshis that were tipped to a user") + Text("⚡️ \(format_msats(zap.amount))", comment: "Text indicating the zap amount. i.e. number of satoshis that were tipped to a user") .font(.headline) .padding([.top], 2) - if zap.private_request != nil { + if zap.is_private { Image(systemName: "lock.fill") .foregroundColor(DamusColors.green) .help(NSLocalizedString("Only you can see this message and who sent it.", comment: "Help text on green lock icon that explains that only the current user can see the message of a zap event and who sent the zap.")) } + + if zap.is_pending { + Image(systemName: "clock.arrow.circlepath") + .foregroundColor(zap.is_paid ? Color.orange : DamusColors.yellow) + .help(NSLocalizedString("Only you can see this message and who sent it.", comment: "Help text on green lock icon that explains that only the current user can see the message of a zap event and who sent the zap.")) + } } - if let priv = zap.private_request { - - TextEvent(damus: damus, event: priv, pubkey: priv.pubkey, options: [.no_action_bar, .no_replying_to]) - .padding([.top], 1) - } else { - TextEvent(damus: damus, event: zap.request.ev, pubkey: zap.request.ev.pubkey, options: [.no_action_bar, .no_replying_to]) - .padding([.top], 1) - } + TextEvent(damus: damus, event: zap.request, pubkey: zap.request.pubkey, options: [.no_action_bar, .no_replying_to]) + .padding([.top], 1) } } } @@ -45,12 +45,14 @@ let test_zap = Zap(event: test_event, invoice: test_zap_invoice, zapper: "zapper let test_private_zap = Zap(event: test_event, invoice: test_zap_invoice, zapper: "zapper", target: .profile("pk"), request: test_zap_request, is_anon: false, private_request: test_event) +let test_pending_zap = PendingZap(amount_msat: 10000, target: .note(id: "id", author: "pk"), request: .normal(test_zap_request), type: .pub, state: .external(.init(state: .fetching_invoice))) + struct ZapEvent_Previews: PreviewProvider { static var previews: some View { VStack { - ZapEvent(damus: test_damus_state(), zap: test_zap) + ZapEvent(damus: test_damus_state(), zap: .zap(test_zap)) - ZapEvent(damus: test_damus_state(), zap: test_private_zap) + ZapEvent(damus: test_damus_state(), zap: .zap(test_private_zap)) } } } diff --git a/damus/Views/Launch.storyboard b/damus/Views/Launch.storyboard new file mode 100644 index 00000000..c069ec81 --- /dev/null +++ b/damus/Views/Launch.storyboard @@ -0,0 +1,55 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/damus/Views/Notifications/EventGroupView.swift b/damus/Views/Notifications/EventGroupView.swift index 26f10508..280d244a 100644 --- a/damus/Views/Notifications/EventGroupView.swift +++ b/damus/Views/Notifications/EventGroupView.swift @@ -68,15 +68,11 @@ func event_group_author_name(profiles: Profiles, ind: Int, group: EventGroupType if let zapgrp = group.zap_group { let zap = zapgrp.zaps[ind] - if let privzap = zap.private_request { - return event_author_name(profiles: profiles, pubkey: privzap.pubkey) - } - if zap.is_anon { return NSLocalizedString("Anonymous", comment: "Placeholder author name of the anonymous person who zapped an event.") } - return event_author_name(profiles: profiles, pubkey: zap.request.ev.pubkey) + return event_author_name(profiles: profiles, pubkey: zap.request.pubkey) } else { let ev = group.events[ind] return event_author_name(profiles: profiles, pubkey: ev.pubkey) diff --git a/damus/Views/PostView.swift b/damus/Views/PostView.swift index 5791b375..9e554b2f 100644 --- a/damus/Views/PostView.swift +++ b/damus/Views/PostView.swift @@ -45,6 +45,7 @@ struct PostView: View { @State var references: [ReferencedId] = [] @State var focusWordAttributes: (String?, NSRange?) = (nil, nil) @State var newCursorIndex: Int? + @State var postTextViewCanScroll: Bool = true @State var mediaToUpload: MediaUpload? = nil @@ -203,7 +204,7 @@ struct PostView: View { var TextEntry: some View { ZStack(alignment: .topLeading) { - TextViewWrapper(attributedText: $post, cursorIndex: newCursorIndex, getFocusWordForMention: { word, range in + TextViewWrapper(attributedText: $post, postTextViewCanScroll: $postTextViewCanScroll, cursorIndex: newCursorIndex, getFocusWordForMention: { word, range in focusWordAttributes = (word, range) self.newCursorIndex = nil }) @@ -335,7 +336,7 @@ struct PostView: View { // This if-block observes @ for tagging if let searching { - UserSearch(damus_state: damus_state, search: searching, focusWordAttributes: $focusWordAttributes, newCursorIndex: $newCursorIndex, post: $post) + UserSearch(damus_state: damus_state, search: searching, focusWordAttributes: $focusWordAttributes, newCursorIndex: $newCursorIndex, postTextViewCanScroll: $postTextViewCanScroll, post: $post) .frame(maxHeight: .infinity) } else { Divider() diff --git a/damus/Views/Posting/UserSearch.swift b/damus/Views/Posting/UserSearch.swift index 74a91d95..8873aa09 100644 --- a/damus/Views/Posting/UserSearch.swift +++ b/damus/Views/Posting/UserSearch.swift @@ -22,6 +22,7 @@ struct UserSearch: View { let search: String @Binding var focusWordAttributes: (String?, NSRange?) @Binding var newCursorIndex: Int? + @Binding var postTextViewCanScroll: Bool @Binding var post: NSMutableAttributedString @@ -92,7 +93,14 @@ struct UserSearch: View { .padding() } } + .onAppear() { + postTextViewCanScroll = false + } + .onDisappear() { + postTextViewCanScroll = true + } } + } struct UserSearch_Previews: PreviewProvider { @@ -100,9 +108,10 @@ struct UserSearch_Previews: PreviewProvider { @State static var post: NSMutableAttributedString = NSMutableAttributedString(string: "some @jb55") @State static var word: (String?, NSRange?) = (nil, nil) @State static var newCursorIndex: Int? + @State static var postTextViewCanScroll: Bool = false static var previews: some View { - UserSearch(damus_state: test_damus_state(), search: search, focusWordAttributes: $word, newCursorIndex: $newCursorIndex, post: $post) + UserSearch(damus_state: test_damus_state(), search: search, focusWordAttributes: $word, newCursorIndex: $newCursorIndex, postTextViewCanScroll: $postTextViewCanScroll, post: $post) } } @@ -140,7 +149,7 @@ func search_users_for_autocomplete(profiles: Profiles, tags: [[String]], search } // search profile cache as well - for tup in profiles.profiles.enumerated() { + for tup in profiles.enumerated() { let pk = tup.element.key let prof = tup.element.value.profile diff --git a/damus/Views/Profile/EventProfileName.swift b/damus/Views/Profile/EventProfileName.swift index 38c08f44..0527fdeb 100644 --- a/damus/Views/Profile/EventProfileName.swift +++ b/damus/Views/Profile/EventProfileName.swift @@ -15,6 +15,7 @@ struct EventProfileName: View { @State var display_name: DisplayName? @State var nip05: NIP05? + @State var donation: Int? let size: EventViewKind @@ -23,6 +24,7 @@ struct EventProfileName: View { self.pubkey = pubkey self.profile = profile self.size = size + self._donation = State(wrappedValue: profile?.damus_donation) } var friend_type: FriendType? { @@ -45,6 +47,15 @@ struct EventProfileName: View { return profile.reactions == false } + var supporter: Int? { + guard let donation, donation > 0 + else { + return nil + } + + return donation + } + var body: some View { HStack(spacing: 2) { switch current_display_name { @@ -73,6 +84,10 @@ struct EventProfileName: View { Image("zap-hashtag") .frame(width: 14, height: 14) } + + if let supporter { + SupporterBadge(percent: supporter) + } } .onReceive(handle_notify(.profile_updated)) { notif in let update = notif.object as! ProfileUpdate @@ -81,6 +96,7 @@ struct EventProfileName: View { } display_name = Profile.displayName(profile: update.profile, pubkey: pubkey) nip05 = damus_state.profiles.is_validated(pubkey) + donation = update.profile.damus_donation } } } diff --git a/damus/Views/Profile/ProfileName.swift b/damus/Views/Profile/ProfileName.swift index 8ae5b34a..c0b8b204 100644 --- a/damus/Views/Profile/ProfileName.swift +++ b/damus/Views/Profile/ProfileName.swift @@ -34,6 +34,7 @@ struct ProfileName: View { @State var display_name: DisplayName? @State var nip05: NIP05? + @State var donation: Int? init(pubkey: String, profile: Profile?, damus: DamusState, show_nip5_domain: Bool = true) { self.pubkey = pubkey @@ -75,6 +76,17 @@ struct ProfileName: View { return profile.reactions == false } + var supporter: Int? { + guard let profile, + let donation = profile.damus_donation, + donation > 0 + else { + return nil + } + + return donation + } + var body: some View { HStack(spacing: 2) { Text(verbatim: "\(prefix)\(name_choice)") @@ -90,6 +102,9 @@ struct ProfileName: View { Image("zap-hashtag") .frame(width: 14, height: 14) } + if let supporter { + SupporterBadge(percent: supporter) + } } .onReceive(handle_notify(.profile_updated)) { notif in let update = notif.object as! ProfileUpdate @@ -98,6 +113,7 @@ struct ProfileName: View { } display_name = Profile.displayName(profile: update.profile, pubkey: pubkey) nip05 = damus_state.profiles.is_validated(pubkey) + donation = profile?.damus_donation } } } diff --git a/damus/Views/Profile/ProfilePicView.swift b/damus/Views/Profile/ProfilePicView.swift index 63b1e767..1b340d7d 100644 --- a/damus/Views/Profile/ProfilePicView.swift +++ b/damus/Views/Profile/ProfilePicView.swift @@ -177,7 +177,7 @@ func get_profile_url(picture: String?, pubkey: String, profiles: Profiles) -> UR func make_preview_profiles(_ pubkey: String) -> Profiles { let profiles = Profiles() let picture = "http://cdn.jb55.com/img/red-me.jpg" - let profile = Profile(name: "jb55", display_name: "William Casarin", about: "It's me", picture: picture, banner: "", website: "https://jb55.com", lud06: nil, lud16: nil, nip05: "jb55.com") + let profile = Profile(name: "jb55", display_name: "William Casarin", about: "It's me", picture: picture, banner: "", website: "https://jb55.com", lud06: nil, lud16: nil, nip05: "jb55.com", damus_donation: nil) let ts_profile = TimestampedProfile(profile: profile, timestamp: 0, event: test_event) profiles.add(id: pubkey, profile: ts_profile) return profiles diff --git a/damus/Views/Profile/ProfileView.swift b/damus/Views/Profile/ProfileView.swift index e56e4a54..d6fb47d7 100644 --- a/damus/Views/Profile/ProfileView.swift +++ b/damus/Views/Profile/ProfileView.swift @@ -496,8 +496,11 @@ struct ProfileView_Previews: PreviewProvider { func test_damus_state() -> DamusState { let pubkey = "3efdaebb1d8923ebd99c9e7ace3b4194ab45512e2be79c1b7d68d9243e0d2681" let damus = DamusState.empty + let settings = UserSettingsStore() + settings.donation_percent = 100 + settings.default_zap_amount = 1971 - let prof = Profile(name: "damus", display_name: "damus", about: "iOS app!", picture: "https://damus.io/img/logo.png", banner: "", website: "https://damus.io", lud06: nil, lud16: "jb55@sendsats.lol", nip05: "damus.io") + let prof = Profile(name: "damus", display_name: "damus", about: "iOS app!", picture: "https://damus.io/img/logo.png", banner: "", website: "https://damus.io", lud06: nil, lud16: "jb55@sendsats.lol", nip05: "damus.io", damus_donation: nil) let tsprof = TimestampedProfile(profile: prof, timestamp: 0, event: test_event) damus.profiles.add(id: pubkey, profile: tsprof) return damus diff --git a/damus/Views/Reactions/ReactionView.swift b/damus/Views/Reactions/ReactionView.swift index 32ab33c2..6b99fc79 100644 --- a/damus/Views/Reactions/ReactionView.swift +++ b/damus/Views/Reactions/ReactionView.swift @@ -12,10 +12,7 @@ struct ReactionView: View { let reaction: NostrEvent var content: String { - if reaction.content == "" || reaction.content == "+" { - return "❤️" - } - return reaction.content + return to_reaction_emoji(ev: reaction) ?? "" } var body: some View { diff --git a/damus/Views/Relays/RelayConfigView.swift b/damus/Views/Relays/RelayConfigView.swift index dd5c335a..59503f63 100644 --- a/damus/Views/Relays/RelayConfigView.swift +++ b/damus/Views/Relays/RelayConfigView.swift @@ -88,8 +88,8 @@ struct RelayConfigView: View { } let info = RelayInfo.rw - - guard (try? state.pool.add_relay(url, info: info)) != nil else { + let descriptor = RelayDescriptor(url: url, info: info) + guard (try? state.pool.add_relay(descriptor)) != nil else { return } diff --git a/damus/Views/Relays/RelayDetailView.swift b/damus/Views/Relays/RelayDetailView.swift index 5814a539..6a7b6c91 100644 --- a/damus/Views/Relays/RelayDetailView.swift +++ b/damus/Views/Relays/RelayDetailView.swift @@ -24,7 +24,11 @@ struct RelayDetailView: View { } func FieldText(_ str: String?) -> some View { - Text(str ?? "No data available") + if let s = str { + return Text(verbatim: s) + } else { + return Text("No data available", comment: "Text indicating that there is no data available to show for specific metadata about a relay server.") + } } var body: some View { diff --git a/damus/Views/SaveKeysView.swift b/damus/Views/SaveKeysView.swift index 4090e641..b0c0c5a1 100644 --- a/damus/Views/SaveKeysView.swift +++ b/damus/Views/SaveKeysView.swift @@ -224,5 +224,5 @@ struct SaveKeysView_Previews: PreviewProvider { } func create_account_to_metadata(_ model: CreateAccountModel) -> Profile { - return Profile(name: model.nick_name, display_name: model.real_name, about: model.about, picture: model.profile_image, banner: nil, website: nil, lud06: nil, lud16: nil, nip05: nil) + return Profile(name: model.nick_name, display_name: model.real_name, about: model.about, picture: model.profile_image, banner: nil, website: nil, lud06: nil, lud16: nil, nip05: nil, damus_donation: nil) } diff --git a/damus/Views/SearchResultsView.swift b/damus/Views/SearchResultsView.swift index 13cb09fc..bc18ce39 100644 --- a/damus/Views/SearchResultsView.swift +++ b/damus/Views/SearchResultsView.swift @@ -182,7 +182,7 @@ func make_hashtagable(_ str: String) -> String { func search_profiles(profiles: Profiles, search: String) -> [SearchedUser] { let new = search.lowercased() - return profiles.profiles.enumerated().reduce(into: []) { acc, els in + return profiles.enumerated().reduce(into: []) { acc, els in let pk = els.element.key let prof = els.element.value.profile diff --git a/damus/Views/SideMenuView.swift b/damus/Views/SideMenuView.swift index 1289a19f..2d54e808 100644 --- a/damus/Views/SideMenuView.swift +++ b/damus/Views/SideMenuView.swift @@ -48,11 +48,17 @@ struct SideMenuView: View { navLabel(title: NSLocalizedString("Profile", comment: "Sidebar menu label for Profile view."), systemImage: "person") } - /* - NavigationLink(destination: EmptyView()) { - navLabel(title: NSLocalizedString("Wallet", comment: "Sidebar menu label for Wallet view."), systemImage: "bolt") + NavigationLink(destination: WalletView(damus_state: damus_state, model: damus_state.wallet)) { + HStack { + Image("wallet") + .tint(DamusColors.adaptableBlack) + + Text(NSLocalizedString("Wallet", comment: "Sidebar menu label for Wallet view.")) + .font(.title2) + .foregroundColor(textColor()) + .frame(maxWidth: .infinity, alignment: .leading) + } } - */ NavigationLink(destination: MutelistView(damus_state: damus_state, users: get_mutelist_users(damus_state.contacts.mutelist) )) { navLabel(title: NSLocalizedString("Muted", comment: "Sidebar menu label for muted users view."), systemImage: "exclamationmark.octagon") diff --git a/damus/Views/TextViewWrapper.swift b/damus/Views/TextViewWrapper.swift index 41d4c7c8..7a6d1bc7 100644 --- a/damus/Views/TextViewWrapper.swift +++ b/damus/Views/TextViewWrapper.swift @@ -9,12 +9,14 @@ import SwiftUI struct TextViewWrapper: UIViewRepresentable { @Binding var attributedText: NSMutableAttributedString + @Binding var postTextViewCanScroll: Bool let cursorIndex: Int? var getFocusWordForMention: ((String?, NSRange?) -> Void)? = nil func makeUIView(context: Context) -> UITextView { let textView = UITextView() textView.delegate = context.coordinator + textView.isScrollEnabled = postTextViewCanScroll textView.showsVerticalScrollIndicator = false TextViewWrapper.setTextProperties(textView) return textView @@ -29,6 +31,7 @@ struct TextViewWrapper: UIViewRepresentable { } func updateUIView(_ uiView: UITextView, context: Context) { + uiView.isScrollEnabled = postTextViewCanScroll uiView.attributedText = attributedText TextViewWrapper.setTextProperties(uiView) setCursorPosition(textView: uiView) diff --git a/damus/Views/Wallet/ConnectWalletView.swift b/damus/Views/Wallet/ConnectWalletView.swift new file mode 100644 index 00000000..a926935b --- /dev/null +++ b/damus/Views/Wallet/ConnectWalletView.swift @@ -0,0 +1,104 @@ +// +// ConnectWalletView.swift +// damus +// +// Created by William Casarin on 2023-05-05. +// + +import SwiftUI + +struct ConnectWalletView: View { + @Environment(\.openURL) private var openURL + @ObservedObject var model: WalletModel + + @State var scanning: Bool = false + @State var error: String? = nil + @State var wallet_scan_result: WalletScanResult = .scanning + + var body: some View { + MainContent + .navigationTitle(NSLocalizedString("Attach a Wallet", comment: "Navigation title for attaching Nostr Wallet Connect lightning wallet.")) + .navigationBarTitleDisplayMode(.large) + .padding() + .onChange(of: wallet_scan_result) { res in + scanning = false + + switch res { + case .success(let url): + error = nil + self.model.new(url) + + case .failed: + error = "Invalid nostr wallet connection string" + + case .scanning: + error = nil + } + } + } + + func AreYouSure(nwc: WalletConnectURL) -> some View { + VStack { + Text("Are you sure you want to attach this wallet?", comment: "Prompt to ask user if they want to attach their Nostr Wallet Connect lightning wallet.") + .font(.title) + + Text(nwc.relay.id) + .font(.body) + .foregroundColor(.gray) + + if let lud16 = nwc.lud16 { + Text(lud16) + .font(.body) + .foregroundColor(.gray) + } + + BigButton(NSLocalizedString("Attach", comment: "Text for button to attach Nostr Wallet Connect lightning wallet.")) { + model.connect(nwc) + } + + BigButton(NSLocalizedString("Cancel", comment: "Text for button to cancel out of connecting Nostr Wallet Connect lightning ewallet.")) { + model.cancel() + } + } + } + + var ConnectWallet: some View { + VStack { + NavigationLink(destination: WalletScannerView(result: $wallet_scan_result), isActive: $scanning) { + EmptyView() + } + + AlbyButton() { + openURL(URL(string:"https://nwc.getalby.com/apps/new?c=Damus")!) + } + + BigButton(NSLocalizedString("Attach Wallet", comment: "Text for button to attach Nostr Wallet Connect lightning wallet.")) { + scanning = true + } + + if let err = self.error { + Text(err) + .foregroundColor(.red) + } + } + } + + var MainContent: some View { + Group { + switch model.connect_state { + case .new(let nwc): + AreYouSure(nwc: nwc) + case .existing: + Text(verbatim: "Shouldn't happen") + case .none: + ConnectWallet + } + } + } +} + +struct ConnectWalletView_Previews: PreviewProvider { + static var previews: some View { + ConnectWalletView(model: WalletModel(settings: UserSettingsStore())) + } +} diff --git a/damus/Views/Wallet/NWCScannerView.swift b/damus/Views/Wallet/NWCScannerView.swift new file mode 100644 index 00000000..0ce34efb --- /dev/null +++ b/damus/Views/Wallet/NWCScannerView.swift @@ -0,0 +1,77 @@ +// +// QRScannerView.swift +// damus +// +// Created by William Casarin on 2023-05-09. +// + +import SwiftUI + +enum WalletScanResult: Equatable { + static func == (lhs: WalletScanResult, rhs: WalletScanResult) -> Bool { + switch lhs { + case .success(let a): + switch rhs { + case .success(let b): + return a == b + case .failed: + return false + case .scanning: + return false + } + case .failed: + switch rhs { + case .success: + return false + case .failed: + return true + case .scanning: + return false + } + case .scanning: + switch rhs { + case .success: + return false + case .failed: + return false + case .scanning: + return true + } + } + } + + case success(WalletConnectURL) + case failed + case scanning +} + +struct WalletScannerView: View { + @Binding var result: WalletScanResult + + @Environment(\.dismiss) var dismiss + + var body: some View { + CodeScannerView(codeTypes: [.qr]) { res in + switch res { + case .success(let success): + guard let url = WalletConnectURL(str: success.string) else { + result = .failed + return + } + + result = .success(url) + case .failure: + result = .failed + } + + dismiss() + } + } +} + +struct QRScannerView_Previews: PreviewProvider { + @State static var result: WalletScanResult = .scanning + static var previews: some View { + WalletScannerView(result: $result) + } +} diff --git a/damus/Views/Wallet/WalletView.swift b/damus/Views/Wallet/WalletView.swift new file mode 100644 index 00000000..21fd6b47 --- /dev/null +++ b/damus/Views/Wallet/WalletView.swift @@ -0,0 +1,198 @@ +// +// WalletView.swift +// damus +// +// Created by William Casarin on 2023-05-05. +// + +import SwiftUI + +struct WalletView: View { + let damus_state: DamusState + @ObservedObject var model: WalletModel + @ObservedObject var settings: UserSettingsStore + + init(damus_state: DamusState, model: WalletModel? = nil) { + self.damus_state = damus_state + self._model = ObservedObject(wrappedValue: model ?? damus_state.wallet) + self._settings = ObservedObject(wrappedValue: damus_state.settings) + } + + func MainWalletView(nwc: WalletConnectURL) -> some View { + VStack { + SupportDamus + + Spacer() + + Text(verbatim: nwc.relay.id) + + if let lud16 = nwc.lud16 { + Text(verbatim: lud16) + } + + BigButton(NSLocalizedString("Disconnect Wallet", comment: "Text for button to disconnect from Nostr Wallet Connect lightning wallet.")) { + self.model.disconnect() + } + + } + .navigationTitle(NSLocalizedString("Wallet", comment: "Navigation title for Wallet view")) + .navigationBarTitleDisplayMode(.large) + .padding() + } + + 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, profile: damus_state.profiles.lookup(id: damus_state.pubkey), size: .small) + } + .padding(25) + } + .frame(height: 370) + } + + var body: some View { + switch model.connect_state { + case .new: + ConnectWalletView(model: model) + case .none: + ConnectWalletView(model: model) + case .existing(let nwc): + MainWalletView(nwc: nwc) + .onAppear() { + model.inital_percent = settings.donation_percent + } + .onChange(of: settings.donation_percent) { p in + guard let profile = damus_state.profiles.lookup(id: damus_state.pubkey) else { + return + } + + profile.damus_donation = p + + notify(.profile_updated, ProfileUpdate(pubkey: damus_state.pubkey, profile: profile)) + } + .onDisappear { + guard let keypair = damus_state.keypair.to_full(), + let profile = damus_state.profiles.lookup(id: damus_state.pubkey), + model.inital_percent != profile.damus_donation + else { + return + } + + profile.damus_donation = settings.donation_percent + let meta = make_metadata_event(keypair: keypair, metadata: profile) + let tsprofile = TimestampedProfile(profile: profile, timestamp: meta.created_at, event: meta) + damus_state.profiles.add(id: damus_state.pubkey, profile: tsprofile) + damus_state.postbox.send(meta) + } + } + } +} + +let test_wallet_connect_url = WalletConnectURL(pubkey: "pk", relay: .init("wss://relay.damus.io")!, keypair: test_damus_state().keypair.to_full()!, lud16: "jb55@sendsats.com") + +struct WalletView_Previews: PreviewProvider { + static let tds = test_damus_state() + static var previews: some View { + WalletView(damus_state: tds, model: WalletModel(state: .existing(test_wallet_connect_url), settings: tds.settings)) + } +} diff --git a/damus/Views/Zaps/CustomizeZapView.swift b/damus/Views/Zaps/CustomizeZapView.swift index e06a667f..543465ae 100644 --- a/damus/Views/Zaps/CustomizeZapView.swift +++ b/damus/Views/Zaps/CustomizeZapView.swift @@ -136,7 +136,7 @@ struct CustomizeZapView: View { VStack(alignment: .center, spacing: 0) { TextField("", text: $custom_amount) .placeholder(when: custom_amount.isEmpty, alignment: .center) { - Text(String("0")) + Text(verbatim: 0.formatted()) } .accentColor(.clear) .font(.system(size: 72, weight: .heavy)) diff --git a/damus/Views/Zaps/ZapsView.swift b/damus/Views/Zaps/ZapsView.swift index 1a06ceab..e04d58a9 100644 --- a/damus/Views/Zaps/ZapsView.swift +++ b/damus/Views/Zaps/ZapsView.swift @@ -9,17 +9,20 @@ import SwiftUI struct ZapsView: View { let state: DamusState - @StateObject var model: ZapsModel + var model: ZapsModel + + @ObservedObject var zaps: ZapsDataModel init(state: DamusState, target: ZapTarget) { self.state = state - self._model = StateObject(wrappedValue: ZapsModel(state: state, target: target)) + self.model = ZapsModel(state: state, target: target) + self._zaps = ObservedObject(wrappedValue: state.events.get_cache_data(target.id).zaps_model) } var body: some View { ScrollView { LazyVStack { - ForEach(model.zaps, id: \.event.id) { zap in + ForEach(zaps.zaps, id: \.request.id) { zap in ZapEvent(damus: state, zap: zap) .padding([.horizontal]) } diff --git a/damus/ar.lproj/Localizable.strings b/damus/ar.lproj/Localizable.strings index 6d1936e2..ac88a3fd 100644 Binary files a/damus/ar.lproj/Localizable.strings and b/damus/ar.lproj/Localizable.strings differ diff --git a/damus/cs.lproj/Localizable.strings b/damus/cs.lproj/Localizable.strings index 32c08fa3..6d5ca1b0 100644 Binary files a/damus/cs.lproj/Localizable.strings and b/damus/cs.lproj/Localizable.strings differ diff --git a/damus/de.lproj/Localizable.strings b/damus/de.lproj/Localizable.strings index 66b95182..9beeb069 100644 Binary files a/damus/de.lproj/Localizable.strings and b/damus/de.lproj/Localizable.strings differ diff --git a/damus/el-GR.lproj/Localizable.strings b/damus/el-GR.lproj/Localizable.strings index 749448b9..ccd6d465 100644 Binary files a/damus/el-GR.lproj/Localizable.strings and b/damus/el-GR.lproj/Localizable.strings differ diff --git a/damus/en-US.xcloc/Localized Contents/en-US.xliff b/damus/en-US.xcloc/Localized Contents/en-US.xliff index 46785326..1222dc7e 100644 --- a/damus/en-US.xcloc/Localized Contents/en-US.xliff +++ b/damus/en-US.xcloc/Localized Contents/en-US.xliff @@ -42,11 +42,6 @@ - - %@ - %@ - No comment provided by engineer. - %@ %@ %@ %@ @@ -83,6 +78,11 @@ Sentence composed of 2 variables to describe how many people are following a use %@. Tip your friend's posts and stack sats with Bitcoin⚡️, the native currency of the internet. Explanation of what can be done by users to earn money. There is a heading that precedes this explanation which is a variable to this string. + + %lld%% + %lld%% + Percentage of additional zap that should be sent to support Damus development. + %lld/%lld %lld/%lld @@ -174,6 +174,11 @@ Sentence composed of 2 variables to describe how many people are following a use Always show images Setting to always show and never blur images + + An additional percentage of each zap will be sent to support Damus development + An additional percentage of each zap will be sent to support Damus development + Text indicating that they can contribute zaps to support Damus development. + Animations Animations @@ -201,6 +206,11 @@ Sentence composed of 2 variables to describe how many people are following a use Are you lost? Text asking the user if they are lost in the app. + + Are you sure you want to attach this wallet? + Are you sure you want to attach this wallet? + Prompt to ask user if they want to attach their Nostr Wallet Connect lightning wallet. + Are you sure you want to delete all of your bookmarks? Are you sure you want to delete all of your bookmarks? @@ -216,6 +226,26 @@ Sentence composed of 2 variables to describe how many people are following a use Are you sure you want to upload this media? Alert message asking if the user wants to upload media. + + Attach + Attach + Text for button to attach Nostr Wallet Connect lightning wallet. + + + Attach Alby Wallet + Attach Alby Wallet + Button to attach an Alby Wallet, a service that provides a Lightning wallet for zapping sats. Alby is the name of the service and should not be translated. + + + Attach Wallet + Attach Wallet + Text for button to attach Nostr Wallet Connect lightning wallet. + + + Attach a Wallet + Attach a Wallet + Navigation title for attaching Nostr Wallet Connect lightning wallet. + Automatically translate notes Automatically translate notes @@ -264,7 +294,8 @@ Sentence composed of 2 variables to describe how many people are following a use Button to cancel the upload. Cancel deleting bookmarks. Cancel deleting the user. - Cancel out of logging out the user. + Cancel out of logging out the user. + Text for button to cancel out of connecting Nostr Wallet Connect lightning ewallet. Choose from Library @@ -471,10 +502,10 @@ Sentence composed of 2 variables to describe how many people are following a use Disconnect From Relay Button to disconnect from the relay. - - Dismiss - Dismiss - Button to dismiss a text field alert. + + Disconnect Wallet + Disconnect Wallet + Text for button to disconnect from Nostr Wallet Connect lightning wallet. Display Name @@ -581,6 +612,11 @@ Sentence composed of 2 variables to describe how many people are following a use Get API Key with BTC/Lightning Button to navigate to nokyctranslate website to get a translation API key. + + Help build the future of decentralized communication on the web. + Help build the future of decentralized communication on the web. + Text indicating the goal of developing Damus which the user can help with. + Hide Hide @@ -777,6 +813,11 @@ Sentence composed of 2 variables to describe how many people are following a use No Button to cancel out of posting a note after being alerted that it looks like they might be posting a private key. + + No data available + No data available + Text indicating that there is no data available to show for specific metadata about a relay server. + No mute list found, create a new one? This will overwrite any previous mute lists. No mute list found, create a new one? This will overwrite any previous mute lists. @@ -995,8 +1036,7 @@ Button text to indicate that the zap type is a private zap. Relay Relay - Label to display relay address. - Text field for relay server. Used for testing purposes. + Label to display relay address. Relays @@ -1067,11 +1107,6 @@ Button text to indicate that the zap type is a private zap. Repost Button to repost a note - - Repost Note - Repost Note - Title text to indicate that the buttons below are meant to be used to repost a note to others. - Reposted Reposted @@ -1232,6 +1267,11 @@ Button text to indicate that the zap type is a private zap. Software Label to display relay software. + + Support Damus + Support Damus + Text calling for the user to support Damus through zaps + Supported NIPs Supported NIPs @@ -1358,6 +1398,11 @@ Button text to indicate that the zap type is a private zap. Universe 🛸 Toolbar label for the universal view where posts from all connected relay servers appear. + + Unmute + Unmute + Button to unmute a profile. + Unmute conversation Unmute conversation @@ -1415,7 +1460,8 @@ YOU WILL NO LONGER BE ABLE TO LOG INTO DAMUS USING THIS ACCOUNT KEY. Wallet Wallet - Sidebar menu label for Wallet view. + Navigation title for Wallet view + Sidebar menu label for Wallet view. Title for section in zap settings that controls the Lightning wallet selection. diff --git a/damus/en-US.xcloc/Source Contents/damus/en-US.lproj/Localizable.strings b/damus/en-US.xcloc/Source Contents/damus/en-US.lproj/Localizable.strings index ce232ab0..9b975054 100644 Binary files a/damus/en-US.xcloc/Source Contents/damus/en-US.lproj/Localizable.strings and b/damus/en-US.xcloc/Source Contents/damus/en-US.lproj/Localizable.strings differ diff --git a/damus/es-419.lproj/Localizable.strings b/damus/es-419.lproj/Localizable.strings index 0f682fb3..24629091 100644 Binary files a/damus/es-419.lproj/Localizable.strings and b/damus/es-419.lproj/Localizable.strings differ diff --git a/damus/es-ES.lproj/Localizable.strings b/damus/es-ES.lproj/Localizable.strings index b3c2cf7f..3819d1ee 100644 Binary files a/damus/es-ES.lproj/Localizable.strings and b/damus/es-ES.lproj/Localizable.strings differ diff --git a/damus/es-ES.lproj/Localizable.stringsdict b/damus/es-ES.lproj/Localizable.stringsdict index 770e63ae..146fe434 100644 --- a/damus/es-ES.lproj/Localizable.stringsdict +++ b/damus/es-ES.lproj/Localizable.stringsdict @@ -236,6 +236,24 @@ Republicaciones + sats + + NSStringLocalizedFormatKey + %#@SATS@ + SATS + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + d + one + sat + many + sats + other + sats + + sats_count NSStringLocalizedFormatKey diff --git a/damus/fa.lproj/InfoPlist.strings b/damus/fa.lproj/InfoPlist.strings index f153d01c..c5aa60b2 100644 Binary files a/damus/fa.lproj/InfoPlist.strings and b/damus/fa.lproj/InfoPlist.strings differ diff --git a/damus/fa.lproj/Localizable.strings b/damus/fa.lproj/Localizable.strings index 831059d4..60e6ee97 100644 Binary files a/damus/fa.lproj/Localizable.strings and b/damus/fa.lproj/Localizable.strings differ diff --git a/damus/fa.lproj/Localizable.stringsdict b/damus/fa.lproj/Localizable.stringsdict index 8aadfd27..ca3d594f 100644 --- a/damus/fa.lproj/Localizable.stringsdict +++ b/damus/fa.lproj/Localizable.stringsdict @@ -15,7 +15,7 @@ one ... %d یادداشت دیگر ... other - ... %d یادداشت‌های دیگر ... + ... %d یادداشت های دیگر ... followers_count @@ -63,7 +63,7 @@ one %2$@ و %1$d نفر دیگر به یک مطلب که شما در آن تگ شده‌اید بازخورد داده‌اند other - %2$@ و %1$d نفر دیگر به یک مطلب که شما در آن تگ شده‌اید بازخورد داده‌اند + %2$@ و %1$d نفر دیگر به یک یادداشت که شما در آن تگ شده‌اید واکنش داده‌اند reacted_your_post_3 @@ -79,7 +79,7 @@ one %2$@ و %1$d نفر دیگر به مطلب شما بازخورد داده‌اند other - %2$@ و %1$d نفر دیگر به مطلب شما بازخورد داده‌اند + %2$@ و %1$d نفر دیگر به یادداشت شما واکنش داده‌اند reacted_your_profile_3 @@ -95,7 +95,7 @@ one %2$@ و %1$d نفر دیگر به نمایه‌ی شما بازخورد داده‌اند other - %2$@ و %1$d نفر دیگر به نمایه‌ی شما بازخورد داده‌اند + %2$@ و %1$d نفر دیگر به نمایه شما واکنش داده‌اند reactions_count @@ -111,7 +111,7 @@ one بازخورد other - بازخوردها + واکنش ها relays_count @@ -159,7 +159,7 @@ one %2$@ و %1$d نفر دیگر یک مطلب که شما در آن تگ شده‌اید را بازنشر کرده‌اند other - %2$@ و %1$d نفر دیگر یک مطلب که شما در آن تگ شده‌اید را بازنشر کرده‌اند + %2$@ و %1$d نفر دیگر یک یادداشت که شما در آن تگ شده‌اید را بازنشر کرده‌اند reposted_your_post_3 @@ -175,7 +175,7 @@ one %2$@ و %1$d نفر دیگر مطلب شما را بازنشر کرده‌اند other - %2$@ و %1$d نفر دیگر مطلب شما را بازنشر کرده‌اند + %2$@ و %1$d نفر دیگر یادداشت شما را بازنشر کرده‌اند reposted_your_profile_3 @@ -210,6 +210,22 @@ بازنشرها + sats + + NSStringLocalizedFormatKey + %#@SATS@ + SATS + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + d + one + ساتوشی + other + ساتوشی + + sats_count NSStringLocalizedFormatKey @@ -226,6 +242,38 @@ %2$@ ساتوشی + zap_notification_no_message + + NSStringLocalizedFormatKey + %1$#@NOTIFICATION@ + NOTIFICATION + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + @ + one + %2$@ ساتوشی از %3$@ دریافت کردید + other + %2$@ ساتوشی از %3$@ دریافت کردید + + + zap_notification_with_message + + NSStringLocalizedFormatKey + %1$#@NOTIFICATION@ + NOTIFICATION + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + @ + one + %2$@ ساتوشی از %3$@ دریافت کردید: "%4$@" + other + %2$@ ساتوشی از %3$@ دریافت کردید: "%4$@" + + zapped_tagged_in_3 NSStringLocalizedFormatKey @@ -239,7 +287,7 @@ one %2$@ و %1$d نفر دیگر یک مطلب که شما در آن تگ شده‌اید را زپ کرده‌اند other - %2$@ و %1$d نفر دیگر یک مطلب که شما در آن تگ شده‌اید را زپ کرده‌اند + %2$@ و %1$d نفر دیگر یک یادداشت که شما در آن تگ شده‌اید را زپ کرده‌اند zapped_your_post_3 @@ -255,7 +303,7 @@ one %2$@ و %1$d نفر دیگر مطلب شما را زپ کرده‌اند other - %2$@ و %1$d نفر دیگر مطلب شما را زپ کرده‌اند + %2$@ و %1$d نفر دیگر یادداشت شما را زپ کرده‌اند zapped_your_profile_3 @@ -287,7 +335,7 @@ one Zap other - Zaps + زپ diff --git a/damus/fr.lproj/Localizable.stringsdict b/damus/fr.lproj/Localizable.stringsdict index ba36c7c7..d512c525 100644 --- a/damus/fr.lproj/Localizable.stringsdict +++ b/damus/fr.lproj/Localizable.stringsdict @@ -236,6 +236,24 @@ Republications + sats + + NSStringLocalizedFormatKey + %#@SATS@ + SATS + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + d + one + sat + many + sats + other + sats + + sats_count NSStringLocalizedFormatKey diff --git a/damus/hu-HU.lproj/Localizable.strings b/damus/hu-HU.lproj/Localizable.strings index c70e8fff..9806e9c8 100644 Binary files a/damus/hu-HU.lproj/Localizable.strings and b/damus/hu-HU.lproj/Localizable.strings differ diff --git a/damus/ja.lproj/Localizable.strings b/damus/ja.lproj/Localizable.strings index 8d16014d..ba6d59bf 100644 Binary files a/damus/ja.lproj/Localizable.strings and b/damus/ja.lproj/Localizable.strings differ diff --git a/damus/nl.lproj/Localizable.strings b/damus/nl.lproj/Localizable.strings index 4c2c7c3b..d8e9366c 100644 Binary files a/damus/nl.lproj/Localizable.strings and b/damus/nl.lproj/Localizable.strings differ diff --git a/damus/pl-PL.lproj/Localizable.strings b/damus/pl-PL.lproj/Localizable.strings index 39c86737..e9666bea 100644 Binary files a/damus/pl-PL.lproj/Localizable.strings and b/damus/pl-PL.lproj/Localizable.strings differ diff --git a/damus/sv-SE.lproj/Localizable.strings b/damus/sv-SE.lproj/Localizable.strings index 4049def4..294d8239 100644 Binary files a/damus/sv-SE.lproj/Localizable.strings and b/damus/sv-SE.lproj/Localizable.strings differ diff --git a/damus/zh-CN.lproj/Localizable.strings b/damus/zh-CN.lproj/Localizable.strings index bfce553b..4c5f0f1c 100644 Binary files a/damus/zh-CN.lproj/Localizable.strings and b/damus/zh-CN.lproj/Localizable.strings differ diff --git a/damus/zh-HK.lproj/Localizable.strings b/damus/zh-HK.lproj/Localizable.strings index ffae7208..fddd8b88 100644 Binary files a/damus/zh-HK.lproj/Localizable.strings and b/damus/zh-HK.lproj/Localizable.strings differ diff --git a/damus/zh-TW.lproj/Localizable.strings b/damus/zh-TW.lproj/Localizable.strings index fe2b7039..9ef5d142 100644 Binary files a/damus/zh-TW.lproj/Localizable.strings and b/damus/zh-TW.lproj/Localizable.strings differ diff --git a/damusTests/LikeTests.swift b/damusTests/LikeTests.swift index 9d408ad3..93c5ec98 100644 --- a/damusTests/LikeTests.swift +++ b/damusTests/LikeTests.swift @@ -32,4 +32,26 @@ class LikeTests: XCTestCase { XCTAssertEqual(like_ev.last_refid()!.ref_id, id) } + func testToReactionEmoji() { + let privkey = "0fc2092231f958f8d57d66f5e238bb45b6a2571f44c0ce024bbc6f3a9c8a15fe" + let pubkey = "30c6d1dc7f7c156794fa15055e651b758a61b99f50fcf759de59386050bf6ae2" + let liked = NostrEvent(content: "awesome #[0] post", pubkey: "orig_pk", tags: [["p", "cindy"], ["e", "bob"]]) + liked.calculate_id() + let id = liked.id + + let emptyReaction = make_like_event(pubkey: pubkey, privkey: privkey, liked: liked, content: "") + let plusReaction = make_like_event(pubkey: pubkey, privkey: privkey, liked: liked, content: "+") + let minusReaction = make_like_event(pubkey: pubkey, privkey: privkey, liked: liked, content: "-") + let heartReaction = make_like_event(pubkey: pubkey, privkey: privkey, liked: liked, content: "❤️") + let thumbsUpReaction = make_like_event(pubkey: pubkey, privkey: privkey, liked: liked, content: "👍") + let shakaReaction = make_like_event(pubkey: pubkey, privkey: privkey, liked: liked, content: "🤙") + + XCTAssertEqual(to_reaction_emoji(ev: emptyReaction), "❤️") + XCTAssertEqual(to_reaction_emoji(ev: plusReaction), "❤️") + XCTAssertEqual(to_reaction_emoji(ev: minusReaction), "👎") + XCTAssertEqual(to_reaction_emoji(ev: heartReaction), "❤️") + XCTAssertEqual(to_reaction_emoji(ev: thumbsUpReaction), "👍") + XCTAssertEqual(to_reaction_emoji(ev: shakaReaction), "🤙") + } + } diff --git a/damusTests/WalletConnectTests.swift b/damusTests/WalletConnectTests.swift new file mode 100644 index 00000000..498bed96 --- /dev/null +++ b/damusTests/WalletConnectTests.swift @@ -0,0 +1,84 @@ +// +// WalletConnectTests.swift +// damusTests +// +// Created by William Casarin on 2023-04-02. +// + +import XCTest +@testable import damus + +final class WalletConnectTests: XCTestCase { + + override func setUpWithError() throws { + // Put setup code here. This method is called before the invocation of each test method in the class. + } + + override func tearDownWithError() throws { + // Put teardown code here. This method is called after the invocation of each test method in the class. + } + + func testWalletBalanceRequest() throws { + // This is an example of a functional test case. + // Use XCTAssert and related functions to verify your tests produce the correct results. + // Any test you write for XCTest can be annotated as throws and async. + // Mark your test throws to produce an unexpected failure when your test encounters an uncaught error. + // Mark your test async to allow awaiting for asynchronous code to complete. Check the results with assertions afterwards. + } + + 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)! + } + + func testDoesNWCParse() { + let pk = "9d088f4760422443d4699b485e2ac66e565a2f5da1198c55ddc5679458e3f67a" + let sec = "ff2eefd57196d42089e1b42acc39916d7ecac52e0625bd70597bbd5be14aff18" + let relay = "wss://relay.getalby.com/v1" + let str = "nostrwalletconnect://\(pk)?relay=\(relay)&secret=\(sec)&lud16=jb55@jb55.com" + + let url = WalletConnectURL(str: str) + XCTAssertNotNil(url) + guard let url else { + return + } + XCTAssertEqual(url.pubkey, pk) + XCTAssertEqual(url.keypair.privkey, sec) + XCTAssertEqual(url.keypair.pubkey, privkey_to_pubkey(privkey: sec)) + XCTAssertEqual(url.relay.id, relay) + XCTAssertEqual(url.lud16, "jb55@jb55.com") + } + + func testNWCEphemeralRelay() { + let sec = "8ba3a6b3b57d0f4211bb1ea4d8d1e351a367e9b4ea694746e0a4a452b2bc4d37" + let pk = "89446b900c70d62438dcf66756405eea6225ad94dc61f3856f62f9699111a9a6" + let nwc = WalletConnectURL(str: "nostrwalletconnect://\(pk)?relay=ws://127.0.0.1&secret=\(sec)&lud16=jb55@jb55.com")! + + let pool = RelayPool() + let box = PostBox(pool: pool) + + nwc_pay(url: nwc, pool: pool, post: box, invoice: "invoice") + + XCTAssertEqual(pool.our_descriptors.count, 0) + XCTAssertEqual(pool.all_descriptors.count, 1) + XCTAssertEqual(pool.all_descriptors[0].variant, .nwc) + XCTAssertEqual(pool.all_descriptors[0].url.id, "ws://127.0.0.1") + XCTAssertEqual(box.events.count, 1) + let ev = box.events.first!.value + XCTAssertEqual(ev.skip_ephemeral, false) + XCTAssertEqual(ev.remaining.count, 1) + XCTAssertEqual(ev.remaining[0].relay, "ws://127.0.0.1") + } + + func testPerformanceExample() throws { + // This is an example of a performance test case. + self.measure { + // Put the code you want to measure the time of here. + } + } + +} diff --git a/damusTests/ZapTests.swift b/damusTests/ZapTests.swift index fb7191dd..4199e7dd 100644 --- a/damusTests/ZapTests.swift +++ b/damusTests/ZapTests.swift @@ -24,13 +24,14 @@ final class ZapTests: XCTestCase { let target = ZapTarget.profile(bob.pubkey) let message = "hey bob!" - let zapreq = make_zap_request_event(keypair: alice, content: message, relays: [], target: target, zap_type: .priv) + let mzapreq = make_zap_request_event(keypair: alice, content: message, relays: [], target: target, zap_type: .priv) - XCTAssertNotNil(zapreq) - guard let zapreq else { + XCTAssertNotNil(mzapreq) + guard let mzapreq else { return } + let zapreq = mzapreq.potentially_anon_outer_request.ev let decrypted = decrypt_private_zap(our_privkey: bob.privkey, zapreq: zapreq, target: target) XCTAssertNotNil(decrypted) diff --git a/devtools/fetch-popular-users b/devtools/fetch-popular-users new file mode 100755 index 00000000..f7ab7c48 --- /dev/null +++ b/devtools/fetch-popular-users @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +set -e + +curl $(curl -s 'https://storage.googleapis.com/storage/v1/b/nostrdb-backups/o?prefix=ndjson' | jq -r '.items | last | .mediaLink') > nostr-directory.json + +jq -rc '.data | {url: .profileImageUrl, pk: .hexPubKey, userName: .userName, twitterFollowers: .user.followers_count, nostrFollowers: .nFollowerCount}' nostr-directory.json | jq -cs 'sort_by(.twitterFollowers + .nostrFollowers) | .[]' | tail -n1000 | tac > popular_users.json + +printf "saved popular_users.json\n" >&2