1
0
mirror of git://jb55.com/damus synced 2024-09-30 00:40:45 +00:00

Merge branch 'master' into user-cache

This commit is contained in:
Bryan Montz 2023-05-26 06:46:47 -05:00
commit ea73c5252d
85 changed files with 2379 additions and 299 deletions

2
.envrc
View File

@ -1,4 +1,4 @@
use nix
#use nix
export TODO_FILE=$PWD/TODO

View File

@ -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)

View File

@ -12,6 +12,22 @@
#include <stdlib.h>
#include <string.h>
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;

View File

@ -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
*

View File

@ -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 = "<group>"; };
4C216F352870A9A700040376 /* InputDismissKeyboard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InputDismissKeyboard.swift; sourceTree = "<group>"; };
4C216F372871EDE300040376 /* DirectMessageModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DirectMessageModel.swift; sourceTree = "<group>"; };
4C28595F2A12A2BE004746F7 /* SupporterBadge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SupporterBadge.swift; sourceTree = "<group>"; };
4C2859612A12A7F0004746F7 /* GoldSupportGradient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GoldSupportGradient.swift; sourceTree = "<group>"; };
4C285C8128385570008A31F1 /* CarouselView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CarouselView.swift; sourceTree = "<group>"; };
4C285C8328385690008A31F1 /* CreateAccountView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreateAccountView.swift; sourceTree = "<group>"; };
4C285C85283892E7008A31F1 /* CreateAccountModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreateAccountModel.swift; sourceTree = "<group>"; };
@ -556,13 +567,19 @@
4C75EFB82804A2740006080F /* EventView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EventView.swift; sourceTree = "<group>"; };
4C75EFBA2804A34C0006080F /* ProofOfWork.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProofOfWork.swift; sourceTree = "<group>"; };
4C7D09582A05BEAD00943473 /* KeyboardVisible.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyboardVisible.swift; sourceTree = "<group>"; };
4C7D095C2A098C5D00943473 /* ConnectWalletView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ConnectWalletView.swift; sourceTree = "<group>"; };
4C7D095D2A098C5D00943473 /* WalletView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = WalletView.swift; sourceTree = "<group>"; };
4C7D09612A098D0E00943473 /* WalletConnect.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = WalletConnect.swift; sourceTree = "<group>"; };
4C7D09652A0AE62100943473 /* AlbyButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlbyButton.swift; sourceTree = "<group>"; };
4C7D09672A0AE9B200943473 /* NWCScannerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NWCScannerView.swift; sourceTree = "<group>"; };
4C7D096A2A0AEA0400943473 /* CodeScanner.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CodeScanner.swift; sourceTree = "<group>"; };
4C7D096B2A0AEA0400943473 /* ScannerCoordinator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ScannerCoordinator.swift; sourceTree = "<group>"; };
4C7D096C2A0AEA0400943473 /* ScannerViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ScannerViewController.swift; sourceTree = "<group>"; };
4C7D09712A0AEF5E00943473 /* DamusGradient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DamusGradient.swift; sourceTree = "<group>"; };
4C7D09732A0AEF9000943473 /* AlbyGradient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlbyGradient.swift; sourceTree = "<group>"; };
4C7D09752A0AF19E00943473 /* FillAndStroke.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FillAndStroke.swift; sourceTree = "<group>"; };
4C7D09772A0B0CC900943473 /* WalletModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WalletModel.swift; sourceTree = "<group>"; };
4C7D097D2A0C58B900943473 /* WalletConnectTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = WalletConnectTests.swift; sourceTree = "<group>"; };
4C7FF7D42823313F009601DB /* Mentions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Mentions.swift; sourceTree = "<group>"; };
4C8682862814DE470026224F /* ProfileView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileView.swift; sourceTree = "<group>"; };
4C8D00C729DF791C0036AF10 /* CompatibleAttribute.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CompatibleAttribute.swift; sourceTree = "<group>"; };
@ -692,6 +709,7 @@
501F8C5929FF70F5001AFC1D /* ProfileDatabase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileDatabase.swift; sourceTree = "<group>"; };
50A50A8C29A09E1C00C01BE7 /* RequestTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RequestTests.swift; sourceTree = "<group>"; };
50B5685229F97CB400A23243 /* CredentialHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CredentialHandler.swift; sourceTree = "<group>"; };
50DA11252A16A23F00236234 /* Launch.storyboard */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; path = Launch.storyboard; sourceTree = "<group>"; };
5C42E78B29DB76D90086AAC1 /* EmptyUserSearchView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmptyUserSearchView.swift; sourceTree = "<group>"; };
5C513FB9297F72980072348F /* CustomPicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomPicker.swift; sourceTree = "<group>"; };
5C513FCB2984ACA60072348F /* QRCodeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QRCodeView.swift; sourceTree = "<group>"; };
@ -870,6 +888,7 @@
4C54AA0629A540BA003E4487 /* NotificationsModel.swift */,
4CD348EE29C3659D00497EB2 /* ImageUploadModel.swift */,
3A48E7AF29DFBE9D006E787E /* MutedThreadsManager.swift */,
4C7D09772A0B0CC900943473 /* WalletModel.swift */,
);
path = Models;
sourceTree = "<group>";
@ -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 = "<group>";
};
4C7D095A2A098C5C00943473 /* Wallet */ = {
isa = PBXGroup;
children = (
4C7D095C2A098C5D00943473 /* ConnectWalletView.swift */,
4C7D095D2A098C5D00943473 /* WalletView.swift */,
4C7D09672A0AE9B200943473 /* NWCScannerView.swift */,
);
path = Wallet;
sourceTree = "<group>";
};
4C7D09692A0AEA0400943473 /* CodeScanner */ = {
isa = PBXGroup;
children = (
@ -1027,6 +1058,7 @@
children = (
4C7D09712A0AEF5E00943473 /* DamusGradient.swift */,
4C7D09732A0AEF9000943473 /* AlbyGradient.swift */,
4C2859612A12A7F0004746F7 /* GoldSupportGradient.swift */,
);
path = Gradients;
sourceTree = "<group>";
@ -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 = "<group>";
@ -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 = (

View File

@ -0,0 +1,98 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1420"
version = "1.3">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "4CE6DEE227F7A08100C66700"
BuildableName = "damus.app"
BlueprintName = "damus"
ReferencedContainer = "container:damus.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES">
<Testables>
<TestableReference
skipped = "NO">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "4CE6DEF227F7A08200C66700"
BuildableName = "damusTests.xctest"
BlueprintName = "damusTests"
ReferencedContainer = "container:damus.xcodeproj">
</BuildableReference>
</TestableReference>
<TestableReference
skipped = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "4CE6DEFC27F7A08200C66700"
BuildableName = "damusUITests.xctest"
BlueprintName = "damusUITests"
ReferencedContainer = "container:damus.xcodeproj">
</BuildableReference>
</TestableReference>
</Testables>
</TestAction>
<LaunchAction
buildConfiguration = "Release"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "4CE6DEE227F7A08100C66700"
BuildableName = "damus.app"
BlueprintName = "damus"
ReferencedContainer = "container:damus.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "4CE6DEE227F7A08100C66700"
BuildableName = "damus.app"
BlueprintName = "damus"
ReferencedContainer = "container:damus.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>

View File

@ -0,0 +1,12 @@
{
"images" : [
{
"filename" : "gradient.jpg",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

View File

@ -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 {

View File

@ -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()
}
}

View File

@ -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)
}
}
}

View File

@ -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
}

View File

@ -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?
}
}

View File

@ -24,6 +24,26 @@
<string>damus</string>
</array>
</dict>
<dict>
<key>CFBundleTypeRole</key>
<string>Viewer</string>
<key>CFBundleURLName</key>
<string>io.damus.nwc</string>
<key>CFBundleURLSchemes</key>
<array>
<string>nostrwalletconnect</string>
</array>
</dict>
<dict>
<key>CFBundleTypeRole</key>
<string>Viewer</string>
<key>CFBundleURLName</key>
<string>io.damus.nwcp</string>
<key>CFBundleURLSchemes</key>
<array>
<string>nostr+walletconnect</string>
</array>
</dict>
</array>
<key>LSApplicationQueriesSchemes</key>
<array>

View File

@ -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
}

View File

@ -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())) }
}

View File

@ -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

View File

@ -8,7 +8,7 @@
import Foundation
class ZapGroup {
var zaps: [Zap]
var zaps: [Zapping]
var msat_total: Int64
var zappers: Set<String>
@ -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

View File

@ -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
}

View File

@ -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 {

View File

@ -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)
}
}

View File

@ -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))
}

View File

@ -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<T>(_ 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? {

View File

@ -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<T: Hashable>(_ 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 {

View File

@ -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
}

View File

@ -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)
}

View File

@ -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 {

View File

@ -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))
}

View File

@ -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
}

View File

@ -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
}
}

View File

@ -10,7 +10,7 @@ import secp256k1
let PUBKEY_HRP = "npub"
struct FullKeypair {
struct FullKeypair: Equatable {
let pubkey: String
let privkey: String
}

View File

@ -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 {

View File

@ -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)
}
}
}

View File

@ -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<T: Codable>: 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<PayInvoiceRequest> {
let data = PayInvoiceRequest(invoice: invoice)
return WalletRequest(method: "pay_invoice", params: data)
}
func make_wallet_balance_request() -> WalletRequest<EmptyRequest> {
return WalletRequest(method: "get_balance", params: nil)
}
struct EmptyRequest: Codable {
}
struct PayInvoiceRequest: Codable {
let invoice: String
}
func make_wallet_connect_request<T>(req: WalletRequest<T>, 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
}
}
}

View File

@ -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
}

View File

@ -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)
}

View File

@ -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)

View File

@ -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)

View File

@ -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)

View File

@ -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))
}
}
}

View File

@ -0,0 +1,55 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="21507" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES" initialViewController="Y6W-OH-hqX">
<device id="retina6_12" orientation="portrait" appearance="light"/>
<dependencies>
<deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="21505"/>
<capability name="Safe area layout guides" minToolsVersion="9.0"/>
<capability name="System colors in document resources" minToolsVersion="11.0"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
</dependencies>
<scenes>
<!--View Controller-->
<scene sceneID="s0d-6b-0kx">
<objects>
<viewController id="Y6W-OH-hqX" sceneMemberID="viewController">
<view key="view" contentMode="scaleToFill" id="5EZ-qb-Rvc">
<rect key="frame" x="0.0" y="0.0" width="393" height="852"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<subviews>
<imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleToFill" horizontalHuggingPriority="251" verticalHuggingPriority="251" image="gradient" translatesAutoresizingMaskIntoConstraints="NO" id="zoF-av-bOb">
<rect key="frame" x="0.0" y="0.0" width="393" height="852"/>
</imageView>
<imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleAspectFit" horizontalHuggingPriority="251" verticalHuggingPriority="251" image="damus-home" translatesAutoresizingMaskIntoConstraints="NO" id="LOu-EK-R9r">
<rect key="frame" x="153.66666666666666" y="383" width="86" height="86"/>
<constraints>
<constraint firstAttribute="height" constant="86" id="KmA-28-Ngq"/>
<constraint firstAttribute="width" constant="86" id="ShD-nJ-gt9"/>
</constraints>
</imageView>
</subviews>
<viewLayoutGuide key="safeArea" id="vDu-zF-Fre"/>
<color key="backgroundColor" systemColor="systemPurpleColor"/>
<constraints>
<constraint firstItem="LOu-EK-R9r" firstAttribute="centerY" secondItem="5EZ-qb-Rvc" secondAttribute="centerY" id="Y10-Wq-VOp"/>
<constraint firstItem="zoF-av-bOb" firstAttribute="top" secondItem="5EZ-qb-Rvc" secondAttribute="top" id="Y5l-Ax-ViU"/>
<constraint firstItem="zoF-av-bOb" firstAttribute="leading" secondItem="5EZ-qb-Rvc" secondAttribute="leading" id="bvq-6J-kYc"/>
<constraint firstAttribute="bottom" secondItem="zoF-av-bOb" secondAttribute="bottom" id="dfj-BJ-nxB"/>
<constraint firstItem="LOu-EK-R9r" firstAttribute="centerX" secondItem="5EZ-qb-Rvc" secondAttribute="centerX" id="mtD-6Q-d3P"/>
<constraint firstAttribute="right" secondItem="zoF-av-bOb" secondAttribute="right" id="xQW-SS-8nb"/>
</constraints>
</view>
</viewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="Ief-a0-LHa" userLabel="First Responder" customClass="UIResponder" sceneMemberID="firstResponder"/>
</objects>
<point key="canvasLocation" x="-106.10687022900763" y="-29.577464788732396"/>
</scene>
</scenes>
<resources>
<image name="damus-home" width="43.333332061767578" height="43.333332061767578"/>
<image name="gradient" width="1125" height="2400"/>
<systemColor name="systemPurpleColor">
<color red="0.68627450980392157" green="0.32156862745098042" blue="0.87058823529411766" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
</systemColor>
</resources>
</document>

View File

@ -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)

View File

@ -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()

View File

@ -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

View File

@ -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
}
}
}

View File

@ -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
}
}
}

View File

@ -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

View File

@ -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

View File

@ -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 {

View File

@ -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
}

View File

@ -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 {

View File

@ -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)
}

View File

@ -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

View File

@ -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")

View File

@ -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)

View File

@ -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()))
}
}

View File

@ -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)
}
}

View File

@ -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<Double> {
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))
}
}

View File

@ -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))

View File

@ -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])
}

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -42,11 +42,6 @@
<tool tool-id="com.apple.dt.xcode" tool-name="Xcode" tool-version="14.3" build-num="14E222b"/>
</header>
<body>
<trans-unit id="%@" xml:space="preserve">
<source>%@</source>
<target>%@</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="%@ %@" xml:space="preserve">
<source>%@ %@</source>
<target>%@ %@</target>
@ -83,6 +78,11 @@ Sentence composed of 2 variables to describe how many people are following a use
<target>%@. Tip your friend's posts and stack sats with Bitcoin⚡, the native currency of the internet.</target>
<note>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.</note>
</trans-unit>
<trans-unit id="%lld%%" xml:space="preserve">
<source>%lld%%</source>
<target>%lld%%</target>
<note>Percentage of additional zap that should be sent to support Damus development.</note>
</trans-unit>
<trans-unit id="%lld/%lld" xml:space="preserve">
<source>%lld/%lld</source>
<target>%lld/%lld</target>
@ -174,6 +174,11 @@ Sentence composed of 2 variables to describe how many people are following a use
<target>Always show images</target>
<note>Setting to always show and never blur images</note>
</trans-unit>
<trans-unit id="An additional percentage of each zap will be sent to support Damus development" xml:space="preserve">
<source>An additional percentage of each zap will be sent to support Damus development</source>
<target>An additional percentage of each zap will be sent to support Damus development</target>
<note>Text indicating that they can contribute zaps to support Damus development.</note>
</trans-unit>
<trans-unit id="Animations" xml:space="preserve">
<source>Animations</source>
<target>Animations</target>
@ -201,6 +206,11 @@ Sentence composed of 2 variables to describe how many people are following a use
<target>Are you lost?</target>
<note>Text asking the user if they are lost in the app.</note>
</trans-unit>
<trans-unit id="Are you sure you want to attach this wallet?" xml:space="preserve">
<source>Are you sure you want to attach this wallet?</source>
<target>Are you sure you want to attach this wallet?</target>
<note>Prompt to ask user if they want to attach their Nostr Wallet Connect lightning wallet.</note>
</trans-unit>
<trans-unit id="Are you sure you want to delete all of your bookmarks?" xml:space="preserve">
<source>Are you sure you want to delete all of your bookmarks?</source>
<target>Are you sure you want to delete all of your bookmarks?</target>
@ -216,6 +226,26 @@ Sentence composed of 2 variables to describe how many people are following a use
<target>Are you sure you want to upload this media?</target>
<note>Alert message asking if the user wants to upload media.</note>
</trans-unit>
<trans-unit id="Attach" xml:space="preserve">
<source>Attach</source>
<target>Attach</target>
<note>Text for button to attach Nostr Wallet Connect lightning wallet.</note>
</trans-unit>
<trans-unit id="Attach Alby Wallet" xml:space="preserve">
<source>Attach Alby Wallet</source>
<target>Attach Alby Wallet</target>
<note>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.</note>
</trans-unit>
<trans-unit id="Attach Wallet" xml:space="preserve">
<source>Attach Wallet</source>
<target>Attach Wallet</target>
<note>Text for button to attach Nostr Wallet Connect lightning wallet.</note>
</trans-unit>
<trans-unit id="Attach a Wallet" xml:space="preserve">
<source>Attach a Wallet</source>
<target>Attach a Wallet</target>
<note>Navigation title for attaching Nostr Wallet Connect lightning wallet.</note>
</trans-unit>
<trans-unit id="Automatically translate notes" xml:space="preserve">
<source>Automatically translate notes</source>
<target>Automatically translate notes</target>
@ -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.</note>
Cancel out of logging out the user.
Text for button to cancel out of connecting Nostr Wallet Connect lightning ewallet.</note>
</trans-unit>
<trans-unit id="Choose from Library" xml:space="preserve">
<source>Choose from Library</source>
@ -471,10 +502,10 @@ Sentence composed of 2 variables to describe how many people are following a use
<target>Disconnect From Relay</target>
<note>Button to disconnect from the relay.</note>
</trans-unit>
<trans-unit id="Dismiss" xml:space="preserve">
<source>Dismiss</source>
<target>Dismiss</target>
<note>Button to dismiss a text field alert.</note>
<trans-unit id="Disconnect Wallet" xml:space="preserve">
<source>Disconnect Wallet</source>
<target>Disconnect Wallet</target>
<note>Text for button to disconnect from Nostr Wallet Connect lightning wallet.</note>
</trans-unit>
<trans-unit id="Display Name" xml:space="preserve">
<source>Display Name</source>
@ -581,6 +612,11 @@ Sentence composed of 2 variables to describe how many people are following a use
<target>Get API Key with BTC/Lightning</target>
<note>Button to navigate to nokyctranslate website to get a translation API key.</note>
</trans-unit>
<trans-unit id="Help build the future of decentralized communication on the web." xml:space="preserve">
<source>Help build the future of decentralized communication on the web.</source>
<target>Help build the future of decentralized communication on the web.</target>
<note>Text indicating the goal of developing Damus which the user can help with.</note>
</trans-unit>
<trans-unit id="Hide" xml:space="preserve">
<source>Hide</source>
<target>Hide</target>
@ -777,6 +813,11 @@ Sentence composed of 2 variables to describe how many people are following a use
<target>No</target>
<note>Button to cancel out of posting a note after being alerted that it looks like they might be posting a private key.</note>
</trans-unit>
<trans-unit id="No data available" xml:space="preserve">
<source>No data available</source>
<target>No data available</target>
<note>Text indicating that there is no data available to show for specific metadata about a relay server.</note>
</trans-unit>
<trans-unit id="No mute list found, create a new one? This will overwrite any previous mute lists." xml:space="preserve">
<source>No mute list found, create a new one? This will overwrite any previous mute lists.</source>
<target>No mute list found, create a new one? This will overwrite any previous mute lists.</target>
@ -995,8 +1036,7 @@ Button text to indicate that the zap type is a private zap.</note>
<trans-unit id="Relay" xml:space="preserve">
<source>Relay</source>
<target>Relay</target>
<note>Label to display relay address.
Text field for relay server. Used for testing purposes.</note>
<note>Label to display relay address.</note>
</trans-unit>
<trans-unit id="Relays" xml:space="preserve">
<source>Relays</source>
@ -1067,11 +1107,6 @@ Button text to indicate that the zap type is a private zap.</note>
<target>Repost</target>
<note>Button to repost a note</note>
</trans-unit>
<trans-unit id="Repost Note" xml:space="preserve">
<source>Repost Note</source>
<target>Repost Note</target>
<note>Title text to indicate that the buttons below are meant to be used to repost a note to others.</note>
</trans-unit>
<trans-unit id="Reposted" xml:space="preserve">
<source>Reposted</source>
<target>Reposted</target>
@ -1232,6 +1267,11 @@ Button text to indicate that the zap type is a private zap.</note>
<target>Software</target>
<note>Label to display relay software.</note>
</trans-unit>
<trans-unit id="Support Damus" xml:space="preserve">
<source>Support Damus</source>
<target>Support Damus</target>
<note>Text calling for the user to support Damus through zaps</note>
</trans-unit>
<trans-unit id="Supported NIPs" xml:space="preserve">
<source>Supported NIPs</source>
<target>Supported NIPs</target>
@ -1358,6 +1398,11 @@ Button text to indicate that the zap type is a private zap.</note>
<target>Universe 🛸</target>
<note>Toolbar label for the universal view where posts from all connected relay servers appear.</note>
</trans-unit>
<trans-unit id="Unmute" xml:space="preserve">
<source>Unmute</source>
<target>Unmute</target>
<note>Button to unmute a profile.</note>
</trans-unit>
<trans-unit id="Unmute conversation" xml:space="preserve">
<source>Unmute conversation</source>
<target>Unmute conversation</target>
@ -1415,7 +1460,8 @@ YOU WILL NO LONGER BE ABLE TO LOG INTO DAMUS USING THIS ACCOUNT KEY.
<trans-unit id="Wallet" xml:space="preserve">
<source>Wallet</source>
<target>Wallet</target>
<note>Sidebar menu label for Wallet view.
<note>Navigation title for Wallet view
Sidebar menu label for Wallet view.
Title for section in zap settings that controls the Lightning wallet selection.</note>
</trans-unit>
<trans-unit id="Website" xml:space="preserve">

View File

@ -236,6 +236,24 @@
<string>Republicaciones</string>
</dict>
</dict>
<key>sats</key>
<dict>
<key>NSStringLocalizedFormatKey</key>
<string>%#@SATS@</string>
<key>SATS</key>
<dict>
<key>NSStringFormatSpecTypeKey</key>
<string>NSStringPluralRuleType</string>
<key>NSStringFormatValueTypeKey</key>
<string>d</string>
<key>one</key>
<string>sat</string>
<key>many</key>
<string>sats</string>
<key>other</key>
<string>sats</string>
</dict>
</dict>
<key>sats_count</key>
<dict>
<key>NSStringLocalizedFormatKey</key>

Binary file not shown.

Binary file not shown.

View File

@ -15,7 +15,7 @@
<key>one</key>
<string>... %d یادداشت دیگر ...</string>
<key>other</key>
<string>... %d یادداشتهای دیگر ...</string>
<string>... %d یادداشت های دیگر ...</string>
</dict>
</dict>
<key>followers_count</key>
@ -63,7 +63,7 @@
<key>one</key>
<string>%2$@ و %1$d نفر دیگر به یک مطلب که شما در آن تگ شده‌اید بازخورد داده‌اند</string>
<key>other</key>
<string>%2$@ و %1$d نفر دیگر به یک مطلب که شما در آن تگ شده‌اید بازخورد داده‌اند</string>
<string>%2$@ و %1$d نفر دیگر به یک یادداشت که شما در آن تگ شده‌اید واکنش داده‌اند</string>
</dict>
</dict>
<key>reacted_your_post_3</key>
@ -79,7 +79,7 @@
<key>one</key>
<string>%2$@ و %1$d نفر دیگر به مطلب شما بازخورد داده‌اند</string>
<key>other</key>
<string>%2$@ و %1$d نفر دیگر به مطلب شما بازخورد داده‌اند</string>
<string>%2$@ و %1$d نفر دیگر به یادداشت شما واکنش داده‌اند</string>
</dict>
</dict>
<key>reacted_your_profile_3</key>
@ -95,7 +95,7 @@
<key>one</key>
<string>%2$@ و %1$d نفر دیگر به نمایه‌ی شما بازخورد داده‌اند</string>
<key>other</key>
<string>%2$@ و %1$d نفر دیگر به نمایه‌ی شما بازخورد داده‌اند</string>
<string>%2$@ و %1$d نفر دیگر به نمایه شما واکنش داده‌اند</string>
</dict>
</dict>
<key>reactions_count</key>
@ -111,7 +111,7 @@
<key>one</key>
<string>بازخورد</string>
<key>other</key>
<string>بازخوردها</string>
<string>واکنش ها</string>
</dict>
</dict>
<key>relays_count</key>
@ -159,7 +159,7 @@
<key>one</key>
<string>%2$@ و %1$d نفر دیگر یک مطلب که شما در آن تگ شده‌اید را بازنشر کرده‌اند</string>
<key>other</key>
<string>%2$@ و %1$d نفر دیگر یک مطلب که شما در آن تگ شده‌اید را بازنشر کرده‌اند</string>
<string>%2$@ و %1$d نفر دیگر یک یادداشت که شما در آن تگ شده‌اید را بازنشر کرده‌اند</string>
</dict>
</dict>
<key>reposted_your_post_3</key>
@ -175,7 +175,7 @@
<key>one</key>
<string>%2$@ و %1$d نفر دیگر مطلب شما را بازنشر کرده‌اند</string>
<key>other</key>
<string>%2$@ و %1$d نفر دیگر مطلب شما را بازنشر کرده‌اند</string>
<string>%2$@ و %1$d نفر دیگر یادداشت شما را بازنشر کرده‌اند</string>
</dict>
</dict>
<key>reposted_your_profile_3</key>
@ -210,6 +210,22 @@
<string>بازنشرها</string>
</dict>
</dict>
<key>sats</key>
<dict>
<key>NSStringLocalizedFormatKey</key>
<string>%#@SATS@</string>
<key>SATS</key>
<dict>
<key>NSStringFormatSpecTypeKey</key>
<string>NSStringPluralRuleType</string>
<key>NSStringFormatValueTypeKey</key>
<string>d</string>
<key>one</key>
<string>ساتوشی</string>
<key>other</key>
<string>ساتوشی</string>
</dict>
</dict>
<key>sats_count</key>
<dict>
<key>NSStringLocalizedFormatKey</key>
@ -226,6 +242,38 @@
<string>%2$@ ساتوشی</string>
</dict>
</dict>
<key>zap_notification_no_message</key>
<dict>
<key>NSStringLocalizedFormatKey</key>
<string>%1$#@NOTIFICATION@</string>
<key>NOTIFICATION</key>
<dict>
<key>NSStringFormatSpecTypeKey</key>
<string>NSStringPluralRuleType</string>
<key>NSStringFormatValueTypeKey</key>
<string>@</string>
<key>one</key>
<string>%2$@ ساتوشی از %3$@ دریافت کردید</string>
<key>other</key>
<string>%2$@ ساتوشی از %3$@ دریافت کردید</string>
</dict>
</dict>
<key>zap_notification_with_message</key>
<dict>
<key>NSStringLocalizedFormatKey</key>
<string>%1$#@NOTIFICATION@</string>
<key>NOTIFICATION</key>
<dict>
<key>NSStringFormatSpecTypeKey</key>
<string>NSStringPluralRuleType</string>
<key>NSStringFormatValueTypeKey</key>
<string>@</string>
<key>one</key>
<string>%2$@ ساتوشی از %3$@ دریافت کردید: "%4$@"</string>
<key>other</key>
<string>%2$@ ساتوشی از %3$@ دریافت کردید: "%4$@"</string>
</dict>
</dict>
<key>zapped_tagged_in_3</key>
<dict>
<key>NSStringLocalizedFormatKey</key>
@ -239,7 +287,7 @@
<key>one</key>
<string>%2$@ و %1$d نفر دیگر یک مطلب که شما در آن تگ شده‌اید را زپ کرده‌اند</string>
<key>other</key>
<string>%2$@ و %1$d نفر دیگر یک مطلب که شما در آن تگ شده‌اید را زپ کرده‌اند</string>
<string>%2$@ و %1$d نفر دیگر یک یادداشت که شما در آن تگ شده‌اید را زپ کرده‌اند</string>
</dict>
</dict>
<key>zapped_your_post_3</key>
@ -255,7 +303,7 @@
<key>one</key>
<string>%2$@ و %1$d نفر دیگر مطلب شما را زپ کرده‌اند</string>
<key>other</key>
<string>%2$@ و %1$d نفر دیگر مطلب شما را زپ کرده‌اند</string>
<string>%2$@ و %1$d نفر دیگر یادداشت شما را زپ کرده‌اند</string>
</dict>
</dict>
<key>zapped_your_profile_3</key>
@ -287,7 +335,7 @@
<key>one</key>
<string>Zap</string>
<key>other</key>
<string>Zaps</string>
<string>زپ</string>
</dict>
</dict>
</dict>

View File

@ -236,6 +236,24 @@
<string>Republications</string>
</dict>
</dict>
<key>sats</key>
<dict>
<key>NSStringLocalizedFormatKey</key>
<string>%#@SATS@</string>
<key>SATS</key>
<dict>
<key>NSStringFormatSpecTypeKey</key>
<string>NSStringPluralRuleType</string>
<key>NSStringFormatValueTypeKey</key>
<string>d</string>
<key>one</key>
<string>sat</string>
<key>many</key>
<string>sats</string>
<key>other</key>
<string>sats</string>
</dict>
</dict>
<key>sats_count</key>
<dict>
<key>NSStringLocalizedFormatKey</key>

Binary file not shown.

Binary file not shown.

View File

@ -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), "🤙")
}
}

View File

@ -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.
}
}
}

View File

@ -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)

8
devtools/fetch-popular-users Executable file
View File

@ -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