From ca2bf20eb7dc05a4a7fca12ea41a936259a26688 Mon Sep 17 00:00:00 2001 From: William Casarin Date: Tue, 9 May 2023 18:37:31 -0700 Subject: [PATCH 01/51] info: add nostrwalletconnect uri handlers We will need this to handle nostrwalletconnect:// links --- damus/Info.plist | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/damus/Info.plist b/damus/Info.plist index a0d03416..69de9d88 100644 --- a/damus/Info.plist +++ b/damus/Info.plist @@ -24,6 +24,26 @@ damus + + CFBundleTypeRole + Viewer + CFBundleURLName + io.damus.nwc + CFBundleURLSchemes + + nostrwalletconnect + + + + CFBundleTypeRole + Viewer + CFBundleURLName + io.damus.nwcp + CFBundleURLSchemes + + nostr+walletconnect + + LSApplicationQueriesSchemes From 4d2b79057d131b3ced2679036cfc9510e3ee6440 Mon Sep 17 00:00:00 2001 From: William Casarin Date: Tue, 9 May 2023 18:25:08 -0700 Subject: [PATCH 02/51] nwc: add Nostr Wallet Connect logic - WalletConnectURL parses nostrwalletconnect:// urls - Add support for parsing and creating NWC requests --- damus/Util/Keys.swift | 2 +- damus/Util/WalletConnect.swift | 79 ++++++++++++++++++++++++++++++++++ 2 files changed, 80 insertions(+), 1 deletion(-) create mode 100644 damus/Util/WalletConnect.swift diff --git a/damus/Util/Keys.swift b/damus/Util/Keys.swift index 23e5c67e..5530b502 100644 --- a/damus/Util/Keys.swift +++ b/damus/Util/Keys.swift @@ -10,7 +10,7 @@ import secp256k1 let PUBKEY_HRP = "npub" -struct FullKeypair { +struct FullKeypair: Equatable { let pubkey: String let privkey: String } diff --git a/damus/Util/WalletConnect.swift b/damus/Util/WalletConnect.swift new file mode 100644 index 00000000..9cd96102 --- /dev/null +++ b/damus/Util/WalletConnect.swift @@ -0,0 +1,79 @@ +// +// 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 + + func to_url() -> URL { + let urlstr = "nostrwalletconnect://\(pubkey)?relay=\(relay.id)&secret=\(keypair.privkey)" + return URL(string: urlstr)! + } + + init?(str: String) { + guard let url = URL(string: str), url.scheme == "nostrwalletconnect", + 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 keypair = FullKeypair(pubkey: our_pk, privkey: secret) + self = WalletConnectURL(pubkey: pk, relay: relay_url, keypair: keypair) + } + + init(pubkey: String, relay: RelayURL, keypair: FullKeypair) { + self.pubkey = pubkey + self.relay = relay + self.keypair = keypair + } +} + +struct WalletRequest: Codable { + let method: String + let params: T? +} + +func make_wallet_pay_invoice_request(invoice: String) -> WalletRequest { + let data = PayInvoiceRequest(invoice: invoice) + return WalletRequest(method: "pay_invoice", params: data) +} + +func make_wallet_balance_request() -> WalletRequest { + return WalletRequest(method: "get_balance", params: nil) +} + +struct EmptyRequest: Codable { +} + +struct PayInvoiceRequest: Codable { + let invoice: String +} + +func make_wallet_connect_request(req: WalletRequest, to_pk: String, keypair: FullKeypair) -> NostrEvent? { + let tags = [["p", to_pk]] + let created_at = Int64(Date().timeIntervalSince1970) + guard let content = encode_json(req) else { + return nil + } + return create_encrypted_event(content, to_pk: to_pk, tags: tags, keypair: keypair, created_at: created_at, kind: 23194) +} From d0216bbce6438a55643e78f4650b5c940c332a5f Mon Sep 17 00:00:00 2001 From: William Casarin Date: Tue, 9 May 2023 18:26:12 -0700 Subject: [PATCH 03/51] nwc: Add WalletModel This model will be used for controlling the logic in the Wallet views --- damus.xcodeproj/project.pbxproj | 4 +++ damus/Models/WalletModel.swift | 59 +++++++++++++++++++++++++++++++++ 2 files changed, 63 insertions(+) create mode 100644 damus/Models/WalletModel.swift diff --git a/damus.xcodeproj/project.pbxproj b/damus.xcodeproj/project.pbxproj index b7c8de47..700ed38c 100644 --- a/damus.xcodeproj/project.pbxproj +++ b/damus.xcodeproj/project.pbxproj @@ -144,6 +144,7 @@ 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 */; }; 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 */; }; @@ -559,6 +560,7 @@ 4C7D09712A0AEF5E00943473 /* DamusGradient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DamusGradient.swift; sourceTree = ""; }; 4C7D09732A0AEF9000943473 /* AlbyGradient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlbyGradient.swift; sourceTree = ""; }; 4C7D09752A0AF19E00943473 /* FillAndStroke.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FillAndStroke.swift; sourceTree = ""; }; + 4C7D09772A0B0CC900943473 /* WalletModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WalletModel.swift; sourceTree = ""; }; 4C7FF7D42823313F009601DB /* Mentions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Mentions.swift; sourceTree = ""; }; 4C8682862814DE470026224F /* ProfileView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileView.swift; sourceTree = ""; }; 4C8D00C729DF791C0036AF10 /* CompatibleAttribute.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CompatibleAttribute.swift; sourceTree = ""; }; @@ -862,6 +864,7 @@ 4C54AA0629A540BA003E4487 /* NotificationsModel.swift */, 4CD348EE29C3659D00497EB2 /* ImageUploadModel.swift */, 3A48E7AF29DFBE9D006E787E /* MutedThreadsManager.swift */, + 4C7D09772A0B0CC900943473 /* WalletModel.swift */, ); path = Models; sourceTree = ""; @@ -1687,6 +1690,7 @@ 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 */, diff --git a/damus/Models/WalletModel.swift b/damus/Models/WalletModel.swift new file mode 100644 index 00000000..c44e4cb9 --- /dev/null +++ b/damus/Models/WalletModel.swift @@ -0,0 +1,59 @@ +// +// 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 { + let settings: UserSettingsStore? + private(set) var previous_state: WalletConnectState + @Published private(set) var connect_state: WalletConnectState + + init() { + self.connect_state = .none + self.previous_state = .none + self.settings = nil + } + + 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 + } + } + + 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 + self.connect_state = .existing(nwc) + self.previous_state = .existing(nwc) + } +} From 996312cf1c7ba02df3431f94aa0b787fb0af5a3c Mon Sep 17 00:00:00 2001 From: William Casarin Date: Tue, 9 May 2023 18:45:11 -0700 Subject: [PATCH 04/51] settings: Add nostr_wallet_connect setting Stored in the keychain for security --- damus/Models/UserSettingsStore.swift | 3 +++ 1 file changed, 3 insertions(+) diff --git a/damus/Models/UserSettingsStore.swift b/damus/Models/UserSettingsStore.swift index abeeb5e9..bc77dd22 100644 --- a/damus/Models/UserSettingsStore.swift +++ b/damus/Models/UserSettingsStore.swift @@ -201,6 +201,9 @@ class UserSettingsStore: ObservableObject { @KeychainStorage(account: "libretranslate_apikey") var internal_libretranslate_api_key: String? + + @KeychainStorage(account: "nostr_wallet_connect") + var nostr_wallet_connect: String? var can_translate: Bool { switch translation_service { From fe3d976cdbff19ec61efba322d5d39769e8e595d Mon Sep 17 00:00:00 2001 From: William Casarin Date: Tue, 9 May 2023 18:49:34 -0700 Subject: [PATCH 05/51] nwc: pay with nwc if we have it configured --- damus/Components/ZapButton.swift | 9 +++++++-- damus/Util/WalletConnect.swift | 10 ++++++++++ 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/damus/Components/ZapButton.swift b/damus/Components/ZapButton.swift index 18b70be7..57d2955a 100644 --- a/damus/Components/ZapButton.swift +++ b/damus/Components/ZapButton.swift @@ -184,8 +184,13 @@ 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) + if let url = damus_state.settings.nostr_wallet_connect, + let nwc = WalletConnectURL(str: url) { + nwc_pay(url: nwc, pool: damus_state.pool, post: damus_state.postbox, invoice: inv) + } else { + let ev = ZappingEvent(is_custom: is_custom, type: .got_zap_invoice(inv), event: event) + notify(.zapping, ev) + } } } diff --git a/damus/Util/WalletConnect.swift b/damus/Util/WalletConnect.swift index 9cd96102..c5f0227b 100644 --- a/damus/Util/WalletConnect.swift +++ b/damus/Util/WalletConnect.swift @@ -77,3 +77,13 @@ func make_wallet_connect_request(req: WalletRequest, to_pk: String, keypai } return create_encrypted_event(content, to_pk: to_pk, tags: tags, keypair: keypair, created_at: created_at, kind: 23194) } + +func nwc_pay(url: WalletConnectURL, pool: RelayPool, post: PostBox, invoice: String) { + 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 + } + + try? pool.add_relay(url.relay, info: .ephemeral) + post.send(ev, to: [url.relay.id], skip_ephemeral: false) +} From 370a5feb4e4484f24a8f3f23794f29cb5e29c62b Mon Sep 17 00:00:00 2001 From: William Casarin Date: Tue, 9 May 2023 18:50:08 -0700 Subject: [PATCH 06/51] ui: add Nostr Wallet Connect views --- damus.xcodeproj/project.pbxproj | 24 ++++++ damus/ContentView.swift | 81 +++++++++++++----- damus/Models/DamusState.swift | 3 +- damus/Views/SideMenuView.swift | 14 +++- damus/Views/Wallet/ConnectWalletView.swift | 98 ++++++++++++++++++++++ damus/Views/Wallet/NWCScannerView.swift | 77 +++++++++++++++++ damus/Views/Wallet/WalletView.swift | 40 +++++++++ 7 files changed, 310 insertions(+), 27 deletions(-) create mode 100644 damus/Views/Wallet/ConnectWalletView.swift create mode 100644 damus/Views/Wallet/NWCScannerView.swift create mode 100644 damus/Views/Wallet/WalletView.swift diff --git a/damus.xcodeproj/project.pbxproj b/damus.xcodeproj/project.pbxproj index 700ed38c..6315f120 100644 --- a/damus.xcodeproj/project.pbxproj +++ b/damus.xcodeproj/project.pbxproj @@ -137,7 +137,11 @@ 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 */; }; @@ -553,7 +557,11 @@ 4C75EFB82804A2740006080F /* EventView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EventView.swift; sourceTree = ""; }; 4C75EFBA2804A34C0006080F /* ProofOfWork.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProofOfWork.swift; sourceTree = ""; }; 4C7D09582A05BEAD00943473 /* KeyboardVisible.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyboardVisible.swift; sourceTree = ""; }; + 4C7D095C2A098C5D00943473 /* ConnectWalletView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ConnectWalletView.swift; sourceTree = ""; }; + 4C7D095D2A098C5D00943473 /* WalletView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = WalletView.swift; sourceTree = ""; }; + 4C7D09612A098D0E00943473 /* WalletConnect.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = WalletConnect.swift; sourceTree = ""; }; 4C7D09652A0AE62100943473 /* AlbyButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlbyButton.swift; sourceTree = ""; }; + 4C7D09672A0AE9B200943473 /* NWCScannerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NWCScannerView.swift; sourceTree = ""; }; 4C7D096A2A0AEA0400943473 /* CodeScanner.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CodeScanner.swift; sourceTree = ""; }; 4C7D096B2A0AEA0400943473 /* ScannerCoordinator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ScannerCoordinator.swift; sourceTree = ""; }; 4C7D096C2A0AEA0400943473 /* ScannerViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ScannerViewController.swift; sourceTree = ""; }; @@ -924,6 +932,7 @@ isa = PBXGroup; children = ( 4C7D09692A0AEA0400943473 /* CodeScanner */, + 4C7D095A2A098C5C00943473 /* Wallet */, 4C8D1A6D29F31E4100ACDF75 /* Buttons */, 4C1A9A1B29DDCF8B00516EAC /* Settings */, 4CFF8F6129CC9A80008DB934 /* Images */, @@ -1005,6 +1014,16 @@ path = Nostr; sourceTree = ""; }; + 4C7D095A2A098C5C00943473 /* Wallet */ = { + isa = PBXGroup; + children = ( + 4C7D095C2A098C5D00943473 /* ConnectWalletView.swift */, + 4C7D095D2A098C5D00943473 /* WalletView.swift */, + 4C7D09672A0AE9B200943473 /* NWCScannerView.swift */, + ); + path = Wallet; + sourceTree = ""; + }; 4C7D09692A0AEA0400943473 /* CodeScanner */ = { isa = PBXGroup; children = ( @@ -1027,6 +1046,7 @@ 4C7FF7D628233637009601DB /* Util */ = { isa = PBXGroup; children = ( + 4C7D09612A098D0E00943473 /* WalletConnect.swift */, 4C198DF329F88D23004C165C /* Images */, 4C198DEA29F88C6B004C165C /* BlurHash */, 4CE4F0F329D779B5005914DB /* PostBox.swift */, @@ -1622,6 +1642,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 */, @@ -1664,6 +1685,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 */, @@ -1695,6 +1717,7 @@ 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 */, @@ -1710,6 +1733,7 @@ 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 */, diff --git a/damus/ContentView.swift b/damus/ContentView.swift index 09b88edb..2049c67c 100644 --- a/damus/ContentView.swift +++ b/damus/ContentView.swift @@ -66,6 +66,8 @@ struct ContentView: View { @State var profile_open: Bool = false @State var thread_open: Bool = false @State var search_open: Bool = false + @State var wallet_open: Bool = false + @State var active_nwc: WalletConnectURL? = nil @State var muting: String? = nil @State var confirm_mute: Bool = false @State var user_muted_confirm: Bool = false @@ -131,6 +133,7 @@ struct ContentView: View { profile_open = false thread_open = false search_open = false + wallet_open = false isSideBarOpened = false } @@ -141,6 +144,9 @@ struct ContentView: View { func MainContent(damus: DamusState) -> some View { VStack { + NavigationLink(destination: WalletView(model: damus_state!.wallet), isActive: $wallet_open) { + EmptyView() + } NavigationLink(destination: MaybeProfileView, isActive: $profile_open) { EmptyView() } @@ -235,6 +241,11 @@ struct ContentView: View { self.thread_open = true } + func open_wallet(nwc: WalletConnectURL) { + self.damus_state!.wallet.new(nwc) + self.wallet_open = true + } + func open_profile(id: String) { self.active_profile = id self.profile_open = true @@ -320,29 +331,17 @@ 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 @@ -589,7 +588,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 +839,40 @@ func handle_post_notification(keypair: FullKeypair, postbox: PostBox, events: Ev return false } } + + +enum OpenResult { + case profile(String) + case filter(NostrFilter) + case event(NostrEvent) + case wallet_connect(WalletConnectURL) +} + +func on_open_url(state: DamusState, url: URL, result: @escaping (OpenResult?) -> Void) { + if let nwc = WalletConnectURL(str: url.absoluteString) { + result(.wallet_connect(nwc)) + return + } + + guard let link = decode_nostr_uri(url.absoluteString) else { + result(nil) + return + } + + switch link { + case .ref(let ref): + if ref.key == "p" { + result(.profile(ref.ref_id)) + } else if ref.key == "e" { + find_event(state: state, evid: ref.ref_id, search_type: .event, find_from: nil) { ev in + if let ev { + result(.event(ev)) + } + } + } + case .filter(let filt): + result(.filter(filt)) + break + // TODO: handle filter searches? + } +} diff --git a/damus/Models/DamusState.swift b/damus/Models/DamusState.swift index 39ea26a0..0a6eb335 100644 --- a/damus/Models/DamusState.swift +++ b/damus/Models/DamusState.swift @@ -29,6 +29,7 @@ struct DamusState { let bootstrap_relays: [String] let replies: ReplyCounter let muted_threads: MutedThreadsManager + let wallet: WalletModel @discardableResult func add_zap(zap: Zap) -> Bool { @@ -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()) } } diff --git a/damus/Views/SideMenuView.swift b/damus/Views/SideMenuView.swift index 1289a19f..14615564 100644 --- a/damus/Views/SideMenuView.swift +++ b/damus/Views/SideMenuView.swift @@ -48,11 +48,17 @@ struct SideMenuView: View { navLabel(title: NSLocalizedString("Profile", comment: "Sidebar menu label for Profile view."), systemImage: "person") } - /* - NavigationLink(destination: EmptyView()) { - navLabel(title: NSLocalizedString("Wallet", comment: "Sidebar menu label for Wallet view."), systemImage: "bolt") + NavigationLink(destination: WalletView(model: damus_state.wallet)) { + HStack { + Image("wallet") + .tint(DamusColors.adaptableBlack) + + Text(NSLocalizedString("Wallet", comment: "Sidebar menu label for Wallet view.")) + .font(.title2) + .foregroundColor(textColor()) + .frame(maxWidth: .infinity, alignment: .leading) + } } - */ NavigationLink(destination: MutelistView(damus_state: damus_state, users: get_mutelist_users(damus_state.contacts.mutelist) )) { navLabel(title: NSLocalizedString("Muted", comment: "Sidebar menu label for muted users view."), systemImage: "exclamationmark.octagon") diff --git a/damus/Views/Wallet/ConnectWalletView.swift b/damus/Views/Wallet/ConnectWalletView.swift new file mode 100644 index 00000000..c77e98a3 --- /dev/null +++ b/damus/Views/Wallet/ConnectWalletView.swift @@ -0,0 +1,98 @@ +// +// 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("Attach a 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?") + .font(.title) + + Text(nwc.relay.id) + .font(.body) + .foregroundColor(.gray) + + BigButton("Attach") { + model.connect(nwc) + } + + BigButton("Cancel") { + 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("Attach 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("Shouldn't happen") + case .none: + ConnectWallet + } + } + } +} + +struct ConnectWalletView_Previews: PreviewProvider { + static var previews: some View { + ConnectWalletView(model: WalletModel()) + } +} diff --git a/damus/Views/Wallet/NWCScannerView.swift b/damus/Views/Wallet/NWCScannerView.swift new file mode 100644 index 00000000..0ce34efb --- /dev/null +++ b/damus/Views/Wallet/NWCScannerView.swift @@ -0,0 +1,77 @@ +// +// QRScannerView.swift +// damus +// +// Created by William Casarin on 2023-05-09. +// + +import SwiftUI + +enum WalletScanResult: Equatable { + static func == (lhs: WalletScanResult, rhs: WalletScanResult) -> Bool { + switch lhs { + case .success(let a): + switch rhs { + case .success(let b): + return a == b + case .failed: + return false + case .scanning: + return false + } + case .failed: + switch rhs { + case .success: + return false + case .failed: + return true + case .scanning: + return false + } + case .scanning: + switch rhs { + case .success: + return false + case .failed: + return false + case .scanning: + return true + } + } + } + + case success(WalletConnectURL) + case failed + case scanning +} + +struct WalletScannerView: View { + @Binding var result: WalletScanResult + + @Environment(\.dismiss) var dismiss + + var body: some View { + CodeScannerView(codeTypes: [.qr]) { res in + switch res { + case .success(let success): + guard let url = WalletConnectURL(str: success.string) else { + result = .failed + return + } + + result = .success(url) + case .failure: + result = .failed + } + + dismiss() + } + } +} + +struct QRScannerView_Previews: PreviewProvider { + @State static var result: WalletScanResult = .scanning + static var previews: some View { + WalletScannerView(result: $result) + } +} diff --git a/damus/Views/Wallet/WalletView.swift b/damus/Views/Wallet/WalletView.swift new file mode 100644 index 00000000..a8ac858c --- /dev/null +++ b/damus/Views/Wallet/WalletView.swift @@ -0,0 +1,40 @@ +// +// WalletView.swift +// damus +// +// Created by William Casarin on 2023-05-05. +// + +import SwiftUI + +struct WalletView: View { + @ObservedObject var model: WalletModel + + func MainWalletView(nwc: WalletConnectURL) -> some View { + VStack { + Text("\(nwc.relay.id)") + + BigButton("Disconnect Wallet") { + self.model.disconnect() + } + } + .padding() + } + + 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) + } + } +} + +struct WalletView_Previews: PreviewProvider { + static var previews: some View { + WalletView(model: WalletModel()) + } +} From a5726d4650de4f6a8cf2a9a84aeb7c5560537b45 Mon Sep 17 00:00:00 2001 From: William Casarin Date: Wed, 10 May 2023 11:10:43 -0700 Subject: [PATCH 07/51] nwc: add lud16 parameter This will be used for auto-setting up the lightning wallet See https://github.com/nostr-protocol/nips/pull/513 --- damus/Util/WalletConnect.swift | 22 ++++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/damus/Util/WalletConnect.swift b/damus/Util/WalletConnect.swift index c5f0227b..2410d253 100644 --- a/damus/Util/WalletConnect.swift +++ b/damus/Util/WalletConnect.swift @@ -17,10 +17,22 @@ struct WalletConnectURL: Equatable { let relay: RelayURL let keypair: FullKeypair let pubkey: String + let lud16: String? func to_url() -> URL { - let urlstr = "nostrwalletconnect://\(pubkey)?relay=\(relay.id)&secret=\(keypair.privkey)" - return URL(string: urlstr)! + 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) { @@ -37,14 +49,16 @@ struct WalletConnectURL: Equatable { 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) + self = WalletConnectURL(pubkey: pk, relay: relay_url, keypair: keypair, lud16: lud16) } - init(pubkey: String, relay: RelayURL, keypair: FullKeypair) { + init(pubkey: String, relay: RelayURL, keypair: FullKeypair, lud16: String?) { self.pubkey = pubkey self.relay = relay self.keypair = keypair + self.lud16 = lud16 } } From 5cce18c8b6725ae28e8220746c611806e1a8fff0 Mon Sep 17 00:00:00 2001 From: William Casarin Date: Wed, 10 May 2023 11:41:38 -0700 Subject: [PATCH 08/51] nwc: attach lud16 to profile when attaching wallet --- damus/ContentView.swift | 17 +++++++++++++++++ damus/Models/WalletModel.swift | 1 + damus/Util/Notifications.swift | 3 +++ 3 files changed, 21 insertions(+) diff --git a/damus/ContentView.swift b/damus/ContentView.swift index 2049c67c..d336143b 100644 --- a/damus/ContentView.swift +++ b/damus/ContentView.swift @@ -359,6 +359,23 @@ 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 + } + + 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 { diff --git a/damus/Models/WalletModel.swift b/damus/Models/WalletModel.swift index c44e4cb9..206dab63 100644 --- a/damus/Models/WalletModel.swift +++ b/damus/Models/WalletModel.swift @@ -53,6 +53,7 @@ class WalletModel: ObservableObject { func connect(_ nwc: WalletConnectURL) { self.settings?.nostr_wallet_connect = nwc.to_url().absoluteString + notify(.attached_wallet, nwc) self.connect_state = .existing(nwc) self.previous_state = .existing(nwc) } diff --git a/damus/Util/Notifications.swift b/damus/Util/Notifications.swift index 11021b51..d23011fc 100644 --- a/damus/Util/Notifications.swift +++ b/damus/Util/Notifications.swift @@ -92,6 +92,9 @@ extension Notification.Name { static var onlyzaps_mode: Notification.Name { return Notification.Name("hide_reactions") } + static var attached_wallet: Notification.Name { + return Notification.Name("attached_wallet") + } } func handle_notify(_ name: Notification.Name) -> NotificationCenter.Publisher { From f77a7bcb29d50b8a73121c4a12441bb1c6806306 Mon Sep 17 00:00:00 2001 From: William Casarin Date: Wed, 10 May 2023 11:57:50 -0700 Subject: [PATCH 09/51] ui: show lud16 in attach wallet ui --- damus/Views/Wallet/ConnectWalletView.swift | 6 ++++++ damus/Views/Wallet/WalletView.swift | 4 ++++ 2 files changed, 10 insertions(+) diff --git a/damus/Views/Wallet/ConnectWalletView.swift b/damus/Views/Wallet/ConnectWalletView.swift index c77e98a3..e3b0e6de 100644 --- a/damus/Views/Wallet/ConnectWalletView.swift +++ b/damus/Views/Wallet/ConnectWalletView.swift @@ -46,6 +46,12 @@ struct ConnectWalletView: View { .font(.body) .foregroundColor(.gray) + if let lud16 = nwc.lud16 { + Text(lud16) + .font(.body) + .foregroundColor(.gray) + } + BigButton("Attach") { model.connect(nwc) } diff --git a/damus/Views/Wallet/WalletView.swift b/damus/Views/Wallet/WalletView.swift index a8ac858c..b80924ef 100644 --- a/damus/Views/Wallet/WalletView.swift +++ b/damus/Views/Wallet/WalletView.swift @@ -14,6 +14,10 @@ struct WalletView: View { VStack { Text("\(nwc.relay.id)") + if let lud16 = nwc.lud16 { + Text("\(lud16)") + } + BigButton("Disconnect Wallet") { self.model.disconnect() } From bb32d72903dead180dbafab22248435202fd649e Mon Sep 17 00:00:00 2001 From: William Casarin Date: Wed, 10 May 2023 13:23:56 -0700 Subject: [PATCH 10/51] nwc: clear the zapper cache for our pubkey when we attach a new wallet --- damus/ContentView.swift | 6 ++++++ damus/Nostr/Profiles.swift | 6 ++++++ 2 files changed, 12 insertions(+) diff --git a/damus/ContentView.swift b/damus/ContentView.swift index d336143b..03df09ec 100644 --- a/damus/ContentView.swift +++ b/damus/ContentView.swift @@ -372,6 +372,12 @@ struct ContentView: View { 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) diff --git a/damus/Nostr/Profiles.swift b/damus/Nostr/Profiles.swift index fa3f06d3..cf30d1fa 100644 --- a/damus/Nostr/Profiles.swift +++ b/damus/Nostr/Profiles.swift @@ -52,3 +52,9 @@ class Profiles { } } } + + +func invalidate_zapper_cache(pubkey: String, profiles: Profiles, lnurl: LNUrls) { + profiles.zappers.removeValue(forKey: pubkey) + lnurl.endpoints.removeValue(forKey: pubkey) +} From dafa1ba4debbe8055a1ee7e6900079fbc9a4224c Mon Sep 17 00:00:00 2001 From: William Casarin Date: Wed, 10 May 2023 16:23:54 -0700 Subject: [PATCH 11/51] test: add nwc and ephemeral relay test This ensures that when paying with nwc, we get an ephemeral relay added and a request queued in the postbox. --- damus.xcodeproj/project.pbxproj | 4 ++ damusTests/WalletConnectTests.swift | 84 +++++++++++++++++++++++++++++ 2 files changed, 88 insertions(+) create mode 100644 damusTests/WalletConnectTests.swift diff --git a/damus.xcodeproj/project.pbxproj b/damus.xcodeproj/project.pbxproj index 6315f120..418379a5 100644 --- a/damus.xcodeproj/project.pbxproj +++ b/damus.xcodeproj/project.pbxproj @@ -149,6 +149,7 @@ 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 */; }; @@ -569,6 +570,7 @@ 4C7D09732A0AEF9000943473 /* AlbyGradient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlbyGradient.swift; sourceTree = ""; }; 4C7D09752A0AF19E00943473 /* FillAndStroke.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FillAndStroke.swift; sourceTree = ""; }; 4C7D09772A0B0CC900943473 /* WalletModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WalletModel.swift; sourceTree = ""; }; + 4C7D097D2A0C58B900943473 /* WalletConnectTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = WalletConnectTests.swift; sourceTree = ""; }; 4C7FF7D42823313F009601DB /* Mentions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Mentions.swift; sourceTree = ""; }; 4C8682862814DE470026224F /* ProfileView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileView.swift; sourceTree = ""; }; 4C8D00C729DF791C0036AF10 /* CompatibleAttribute.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CompatibleAttribute.swift; sourceTree = ""; }; @@ -1275,6 +1277,7 @@ 4CE6DEF627F7A08200C66700 /* damusTests */ = { isa = PBXGroup; children = ( + 4C7D097D2A0C58B900943473 /* WalletConnectTests.swift */, F944F56C29EA9CB20067B3BF /* Models */, 50A50A8C29A09E1C00C01BE7 /* RequestTests.swift */, DD597CBC2963D85A00C64D32 /* MarkdownTests.swift */, @@ -1851,6 +1854,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 */, diff --git a/damusTests/WalletConnectTests.swift b/damusTests/WalletConnectTests.swift new file mode 100644 index 00000000..9ef47739 --- /dev/null +++ b/damusTests/WalletConnectTests.swift @@ -0,0 +1,84 @@ +// +// WalletConnectTests.swift +// damusTests +// +// Created by William Casarin on 2023-04-02. +// + +import XCTest +@testable import damus + +final class WalletConnectTests: XCTestCase { + + override func setUpWithError() throws { + // Put setup code here. This method is called before the invocation of each test method in the class. + } + + override func tearDownWithError() throws { + // Put teardown code here. This method is called after the invocation of each test method in the class. + } + + func testWalletBalanceRequest() throws { + // This is an example of a functional test case. + // Use XCTAssert and related functions to verify your tests produce the correct results. + // Any test you write for XCTest can be annotated as throws and async. + // Mark your test throws to produce an unexpected failure when your test encounters an uncaught error. + // Mark your test async to allow awaiting for asynchronous code to complete. Check the results with assertions afterwards. + } + + func get_test_nwc() -> WalletConnectURL { + let pk = "9d088f4760422443d4699b485e2ac66e565a2f5da1198c55ddc5679458e3f67a" + let sec = "ff2eefd57196d42089e1b42acc39916d7ecac52e0625bd70597bbd5be14aff18" + let relay = "wss://relay.getalby.com/v1" + let str = "nostrwalletconnect://\(pk)?relay=\(relay)&secret=\(sec)" + + return WalletConnectURL(str: str)! + } + + func testDoesNWCParse() { + let pk = "9d088f4760422443d4699b485e2ac66e565a2f5da1198c55ddc5679458e3f67a" + let sec = "ff2eefd57196d42089e1b42acc39916d7ecac52e0625bd70597bbd5be14aff18" + let relay = "wss://relay.getalby.com/v1" + let str = "nostrwalletconnect://\(pk)?relay=\(relay)&secret=\(sec)&lud16=jb55@jb55.com" + + let url = WalletConnectURL(str: str) + XCTAssertNotNil(url) + guard let url else { + return + } + XCTAssertEqual(url.pubkey, pk) + XCTAssertEqual(url.keypair.privkey, sec) + XCTAssertEqual(url.keypair.pubkey, privkey_to_pubkey(privkey: sec)) + XCTAssertEqual(url.relay.id, relay) + XCTAssertEqual(url.lud16, "jb55@jb55.com") + } + + func testNWCEphemeralRelay() { + let sec = "8ba3a6b3b57d0f4211bb1ea4d8d1e351a367e9b4ea694746e0a4a452b2bc4d37" + let pk = "89446b900c70d62438dcf66756405eea6225ad94dc61f3856f62f9699111a9a6" + let nwc = WalletConnectURL(str: "nostrwalletconnect://\(pk)?relay=ws://127.0.0.1&secret=\(sec)&lud16=jb55@jb55.com")! + + let pool = RelayPool() + let box = PostBox(pool: pool) + + nwc_pay(url: nwc, pool: pool, post: box, invoice: "invoice") + + XCTAssertEqual(pool.our_descriptors.count, 0) + XCTAssertEqual(pool.all_descriptors.count, 1) + XCTAssertEqual(pool.all_descriptors[0].info.ephemeral, true) + 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. + } + } + +} From 1518a0a16c7ac0fbe3b790173047b5fc28e02c37 Mon Sep 17 00:00:00 2001 From: William Casarin Date: Sat, 13 May 2023 19:51:06 -0700 Subject: [PATCH 12/51] zaps: ensure returned bolt11 is the correct amount --- damus/Util/Zap.swift | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/damus/Util/Zap.swift b/damus/Util/Zap.swift index a466b3dd..b240584b 100644 --- a/damus/Util/Zap.swift +++ b/damus/Util/Zap.swift @@ -293,5 +293,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(amount) == bolt11.amount + else { + return nil + } + return result.pr } From 03691d03699091318a26ba9a8140c2632fadd997 Mon Sep 17 00:00:00 2001 From: William Casarin Date: Sat, 13 May 2023 21:33:34 -0700 Subject: [PATCH 13/51] Pending Zaps A fairly large change that replaces Zaps in the codebase with "Zapping" which is a tagged union consisting of a resolved Zap and a Pending Zap. These are both counted as Zaps everywhere in Damus, except pending zaps can be cancelled (most of the time). --- .envrc | 2 +- damus/Components/ZapButton.swift | 223 +++++++++++++----- damus/ContentView.swift | 8 +- damus/Models/ActionBarModel.swift | 13 +- damus/Models/DamusState.swift | 2 +- damus/Models/HomeModel.swift | 43 +++- damus/Models/Notifications/ZapGroup.swift | 24 +- damus/Models/NotificationsModel.swift | 12 +- damus/Models/ZapsModel.swift | 31 ++- damus/Nostr/NostrKind.swift | 2 + damus/Nostr/Relay.swift | 39 ++- damus/Nostr/RelayPool.swift | 12 +- damus/Util/EventCache.swift | 52 +++- damus/Util/InsertSort.swift | 19 +- damus/Util/PostBox.swift | 47 +++- damus/Util/WalletConnect.swift | 135 ++++++++++- damus/Util/Zap.swift | 148 +++++++++++- damus/Util/Zaps.swift | 50 +++- damus/Views/ActionBar/EventActionBar.swift | 4 +- damus/Views/Events/TextEvent.swift | 4 +- damus/Views/Events/ZapEvent.swift | 28 ++- .../Views/Notifications/EventGroupView.swift | 6 +- damus/Views/Relays/RelayConfigView.swift | 4 +- damus/Views/Zaps/ZapsView.swift | 9 +- 24 files changed, 738 insertions(+), 179 deletions(-) diff --git a/.envrc b/.envrc index 72617b2f..ba28b102 100644 --- a/.envrc +++ b/.envrc @@ -1,4 +1,4 @@ -use nix +#use nix export TODO_FILE=$PWD/TODO diff --git a/damus/Components/ZapButton.swift b/damus/Components/ZapButton.swift index 57d2955a..7cda07ca 100644 --- a/damus/Components/ZapButton.swift +++ b/damus/Components/ZapButton.swift @@ -23,45 +23,90 @@ 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 { + 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 { + switch our_zap { + case .none: + return Color.gray + case .pending: + return Color.yellow + case .zap: return Color.orange } - - if is_charging { - return Color.yellow + } + + 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 } - if !zapping { - return nil + // 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 + } + + 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 + } } - - return Color.yellow + + } var body: some View { @@ -69,37 +114,32 @@ 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 { + guard our_zap == nil else { return } - self.showing_zap_customizer = true + button.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 + .highPriorityGesture(TapGesture().onEnded { + tap() }) .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) { + .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 +157,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 +171,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: 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 +200,18 @@ 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 zapreq = make_zap_request_event(keypair: keypair, content: content, relays: relays, target: target, zap_type: zap_type) else { + // this should never happen + return + } + + let zap_amount = amount_sats ?? damus_state.settings.default_zap_amount + let amount_msat = Int64(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: ZapRequest(ev: zapreq), type: zap_type, state: pending_zap_state) + + UIImpactFeedbackGenerator(style: .heavy).impactOccurred() + damus_state.add_zap(zap: .pending(pending_zap)) Task { var mpayreq = damus_state.lnurls.lookup(target.pubkey) @@ -161,6 +222,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: zapreq.id, 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 +234,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 { DispatchQueue.main.async { + remove_zap(reqid: zapreq.id, 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 +245,24 @@ func send_zap(damus_state: DamusState, event: NostrEvent, lnurl: String, is_cust } DispatchQueue.main.async { - if let url = damus_state.settings.nostr_wallet_connect, - let nwc = WalletConnectURL(str: url) { - nwc_pay(url: nwc, pool: damus_state.pool, post: damus_state.postbox, invoice: inv) - } else { + + 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: zapreq.id, zapcache: damus_state.zaps, evcache: damus_state.events) + return + } + + guard let nwc_req = nwc_pay(url: nwc_state.url, pool: damus_state.pool, post: damus_state.postbox, invoice: inv), + case .nwc(let pzap_state) = pending_zap_state + else { + return + } + + pzap_state.state = .postbox_pending(nwc_req) + 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) } @@ -196,3 +271,41 @@ func send_zap(damus_state: DamusState, event: NostrEvent, lnurl: String, is_cust 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: + nwc_state.state = .cancel_fetching_invoice + // 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) + } + remove_zap(reqid: zap.request.ev.id, zapcache: zapcache, evcache: evcache) + + case .failed: + remove_zap(reqid: zap.request.ev.id, zapcache: zapcache, evcache: evcache) + } + + return nil +} diff --git a/damus/ContentView.swift b/damus/ContentView.swift index 03df09ec..e110d4e7 100644 --- a/damus/ContentView.swift +++ b/damus/ContentView.swift @@ -581,7 +581,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) } } @@ -592,6 +593,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), diff --git a/damus/Models/ActionBarModel.swift b/damus/Models/ActionBarModel.swift index 4320d2a5..7594c74c 100644 --- a/damus/Models/ActionBarModel.swift +++ b/damus/Models/ActionBarModel.swift @@ -7,12 +7,17 @@ import Foundation +enum Zapped { + case not_zapped + case pending + case zapped +} class ActionBarModel: ObservableObject { @Published var our_like: NostrEvent? @Published var our_boost: NostrEvent? @Published var our_reply: NostrEvent? - @Published var our_zap: Zap? + @Published var our_zap: Zapping? @Published var likes: Int @Published var boosts: Int @Published var zaps: Int @@ -35,7 +40,7 @@ class ActionBarModel: ObservableObject { self.replies = 0 } - init(likes: Int, boosts: Int, zaps: Int, zap_total: Int64, replies: Int, our_like: NostrEvent?, our_boost: NostrEvent?, our_zap: Zap?, our_reply: NostrEvent?) { + init(likes: Int, boosts: Int, zaps: Int, zap_total: Int64, replies: Int, our_like: NostrEvent?, our_boost: NostrEvent?, our_zap: Zapping?, our_reply: NostrEvent?) { self.likes = likes self.boosts = boosts self.zaps = zaps @@ -64,10 +69,6 @@ class ActionBarModel: ObservableObject { return likes == 0 && boosts == 0 && zaps == 0 } - var zapped: Bool { - return our_zap != nil - } - var liked: Bool { return our_like != nil } diff --git a/damus/Models/DamusState.swift b/damus/Models/DamusState.swift index 0a6eb335..367a8986 100644 --- a/damus/Models/DamusState.swift +++ b/damus/Models/DamusState.swift @@ -32,7 +32,7 @@ struct DamusState { 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 diff --git a/damus/Models/HomeModel.swift b/damus/Models/HomeModel.swift index 4aee02b8..6acb7c14 100644 --- a/damus/Models/HomeModel.swift +++ b/damus/Models/HomeModel.swift @@ -129,6 +129,25 @@ class HomeModel: ObservableObject { handle_zap_event(ev) case .zap_request: break + case .nwc_request: + break + case .nwc_response: + handle_nwc_response(ev) + } + } + + func handle_nwc_response(_ ev: NostrEvent) { + Task { @MainActor in + guard let resp = await FullWalletResponse(from: ev) else { + return + } + + if resp.response.error == nil { + nwc_success(zapcache: self.damus_state.zaps, resp: resp) + return + } + + nwc_error(zapcache: self.damus_state.zaps, evcache: self.damus_state.events, resp: resp) } } @@ -137,13 +156,13 @@ class HomeModel: ObservableObject { 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 +320,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 +460,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]) @@ -836,7 +865,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 +879,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 { diff --git a/damus/Models/Notifications/ZapGroup.swift b/damus/Models/Notifications/ZapGroup.swift index aa655acc..15ca1a59 100644 --- a/damus/Models/Notifications/ZapGroup.swift +++ b/damus/Models/Notifications/ZapGroup.swift @@ -8,7 +8,7 @@ import Foundation class ZapGroup { - var zaps: [Zap] + var zaps: [Zapping] var msat_total: Int64 var zappers: Set @@ -17,22 +17,16 @@ class ZapGroup { return 0 } - return first.event.created_at + return first.created_at } func zap_requests() -> [NostrEvent] { - zaps.map { z in - if let priv = z.private_request { - return priv - } else { - return z.request.ev - } - } + zaps.map { z in z.request } } func would_filter(_ isIncluded: (NostrEvent) -> Bool) -> Bool { for zap in zaps { - if !isIncluded(zap.request_ev) { + if !isIncluded(zap.request) { return true } } @@ -41,7 +35,7 @@ class ZapGroup { } func filter(_ isIncluded: (NostrEvent) -> Bool) -> ZapGroup? { - let new_zaps = zaps.filter { isIncluded($0.request_ev) } + let new_zaps = zaps.filter { isIncluded($0.request) } guard new_zaps.count > 0 else { return nil } @@ -59,15 +53,15 @@ class ZapGroup { } @discardableResult - func insert(_ zap: Zap) -> Bool { + func insert(_ zap: Zapping) -> Bool { if !insert_uniq_sorted_zap_by_created(zaps: &zaps, new_zap: zap) { return false } - msat_total += zap.invoice.amount + msat_total += zap.amount - if !zappers.contains(zap.request.ev.pubkey) { - zappers.insert(zap.request.ev.pubkey) + if !zappers.contains(zap.request.pubkey) { + zappers.insert(zap.request.pubkey) } return true diff --git a/damus/Models/NotificationsModel.swift b/damus/Models/NotificationsModel.swift index bbee48d0..85ebf544 100644 --- a/damus/Models/NotificationsModel.swift +++ b/damus/Models/NotificationsModel.swift @@ -99,7 +99,7 @@ enum NotificationItem { } class NotificationsModel: ObservableObject, ScrollQueue { - var incoming_zaps: [Zap] + var incoming_zaps: [Zapping] var incoming_events: [NostrEvent] var should_queue: Bool @@ -150,7 +150,7 @@ class NotificationsModel: ObservableObject, ScrollQueue { } for zap in incoming_zaps { - pks.insert(zap.request.ev.pubkey) + pks.insert(zap.request.pubkey) } return Array(pks) @@ -249,7 +249,7 @@ class NotificationsModel: ObservableObject, ScrollQueue { return false } - private func insert_zap_immediate(_ zap: Zap) -> Bool { + private func insert_zap_immediate(_ zap: Zapping) -> Bool { switch zap.target { case .note(let notezt): let id = notezt.note_id @@ -285,7 +285,7 @@ class NotificationsModel: ObservableObject, ScrollQueue { return false } - func insert_zap(_ zap: Zap) -> Bool { + func insert_zap(_ zap: Zapping) -> Bool { if should_queue { return insert_uniq_sorted_zap_by_created(zaps: &incoming_zaps, new_zap: zap) } @@ -307,7 +307,7 @@ class NotificationsModel: ObservableObject, ScrollQueue { changed = changed || incoming_events.count != count count = profile_zaps.zaps.count - profile_zaps.zaps = profile_zaps.zaps.filter { zap in isIncluded(zap.request.ev) } + profile_zaps.zaps = profile_zaps.zaps.filter { zap in isIncluded(zap.request) } changed = changed || profile_zaps.zaps.count != count for el in reactions { @@ -325,7 +325,7 @@ class NotificationsModel: ObservableObject, ScrollQueue { for el in zaps { count = el.value.zaps.count el.value.zaps = el.value.zaps.filter { - isIncluded($0.request.ev) + isIncluded($0.request) } changed = changed || el.value.zaps.count != count } diff --git a/damus/Models/ZapsModel.swift b/damus/Models/ZapsModel.swift index ff908464..a3c04f26 100644 --- a/damus/Models/ZapsModel.swift +++ b/damus/Models/ZapsModel.swift @@ -19,7 +19,7 @@ class ZapsModel: ObservableObject { self.target = target } - var zaps: [Zap] { + var zaps: [Zapping] { return state.events.lookup_zaps(target: target) } @@ -53,7 +53,7 @@ class ZapsModel: ObservableObject { case .notice: break case .eose: - let events = state.events.lookup_zaps(target: target).map { $0.request_ev } + let events = state.events.lookup_zaps(target: target).map { $0.request } load_profiles(profiles_subid: profiles_subid, relay_id: relay_id, load: .from_events(events), damus_state: state) case .event(_, let ev): guard ev.kind == 9735 else { @@ -61,22 +61,19 @@ class ZapsModel: ObservableObject { } if let zap = state.zaps.zaps[ev.id] { - if state.events.store_zap(zap: zap) { - objectWillChange.send() - } - } else { - guard let zapper = state.profiles.lookup_zapper(pubkey: target.pubkey) else { - return - } - - guard let zap = Zap.from_zap_event(zap_ev: ev, zapper: zapper, our_privkey: state.keypair.privkey) else { - return - } - - if self.state.add_zap(zap: zap) { - objectWillChange.send() - } + state.events.store_zap(zap: zap) + return } + + guard let zapper = state.profiles.lookup_zapper(pubkey: target.pubkey) else { + return + } + + guard let zap = Zap.from_zap_event(zap_ev: ev, zapper: zapper, our_privkey: state.keypair.privkey) else { + return + } + + self.state.add_zap(zap: .zap(zap)) } diff --git a/damus/Nostr/NostrKind.swift b/damus/Nostr/NostrKind.swift index 3d02ca87..3ee200b4 100644 --- a/damus/Nostr/NostrKind.swift +++ b/damus/Nostr/NostrKind.swift @@ -22,4 +22,6 @@ enum NostrKind: Int { case list = 30000 case zap = 9735 case zap_request = 9734 + case nwc_request = 23194 + case nwc_response = 23195 } diff --git a/damus/Nostr/Relay.swift b/damus/Nostr/Relay.swift index 2aa159de..110922bf 100644 --- a/damus/Nostr/Relay.swift +++ b/damus/Nostr/Relay.swift @@ -10,21 +10,46 @@ import Foundation public struct RelayInfo: Codable { let read: Bool? let write: Bool? - let ephemeral: Bool? - init(read: Bool, write: Bool, ephemeral: Bool = false) { + init(read: Bool, write: Bool) { self.read = read self.write = write - self.ephemeral = ephemeral } - static let rw = RelayInfo(read: true, write: true, ephemeral: false) - static let ephemeral = RelayInfo(read: true, write: true, ephemeral: true) + static let rw = RelayInfo(read: true, write: true) +} + +enum RelayVariant { + case regular + case ephemeral + case nwc } public struct RelayDescriptor { - public let url: RelayURL - public let info: RelayInfo + let url: RelayURL + let info: RelayInfo + let variant: RelayVariant + + init(url: RelayURL, info: RelayInfo, variant: RelayVariant = .regular) { + self.url = url + self.info = info + self.variant = variant + } + + var ephemeral: Bool { + switch variant { + case .regular: + return false + case .ephemeral: + return true + case .nwc: + return true + } + } + + static func nwc(url: RelayURL) -> RelayDescriptor { + return RelayDescriptor(url: url, info: .rw, variant: .nwc) + } } enum RelayFlags: Int { diff --git a/damus/Nostr/RelayPool.swift b/damus/Nostr/RelayPool.swift index 20ab9490..72230eec 100644 --- a/damus/Nostr/RelayPool.swift +++ b/damus/Nostr/RelayPool.swift @@ -43,7 +43,7 @@ class RelayPool { } var our_descriptors: [RelayDescriptor] { - return all_descriptors.filter { d in !(d.info.ephemeral ?? false) } + return all_descriptors.filter { d in !d.ephemeral } } var all_descriptors: [RelayDescriptor] { @@ -91,7 +91,8 @@ class RelayPool { } } - func add_relay(_ url: RelayURL, info: RelayInfo) throws { + func add_relay(_ desc: RelayDescriptor) throws { + let url = desc.url let relay_id = get_relay_id(url) if get_relay(relay_id) != nil { throw RelayError.RelayAlreadyExists @@ -99,8 +100,7 @@ class RelayPool { let conn = RelayConnection(url: url) { event in self.handle_event(relay_id: relay_id, event: event) } - let descriptor = RelayDescriptor(url: url, info: info) - let relay = Relay(descriptor: descriptor, connection: conn) + let relay = Relay(descriptor: desc, connection: conn) self.relays.append(relay) } @@ -196,7 +196,7 @@ class RelayPool { continue } - if (relay.descriptor.info.ephemeral ?? false) && skip_ephemeral { + if relay.descriptor.ephemeral && skip_ephemeral { continue } @@ -266,7 +266,7 @@ func add_rw_relay(_ pool: RelayPool, _ url: String) { guard let url = RelayURL(url) else { return } - try? pool.add_relay(url, info: RelayInfo.rw) + try? pool.add_relay(RelayDescriptor(url: url, info: .rw)) } diff --git a/damus/Util/EventCache.swift b/damus/Util/EventCache.swift index 6ff6a34a..24b917cd 100644 --- a/damus/Util/EventCache.swift +++ b/damus/Util/EventCache.swift @@ -55,11 +55,42 @@ class PreviewModel: ObservableObject { } class ZapsDataModel: ObservableObject { - @Published var zaps: [Zap] + @Published var zaps: [Zapping] - init(_ zaps: [Zap]) { + init(_ zaps: [Zapping]) { self.zaps = zaps } + + func update_state(reqid: String, state: PendingZapState) { + guard let zap = zaps.first(where: { z in z.request.id == reqid }), + case .pending(let pzap) = zap, + pzap.state != state + else { + return + } + + pzap.state = state + + 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 +117,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 +162,23 @@ class EventCache { } @discardableResult - func store_zap(zap: Zap) -> Bool { + func store_zap(zap: Zapping) -> Bool { let data = get_cache_data(zap.target.id).zaps_model return insert_uniq_sorted_zap_by_amount(zaps: &data.zaps, new_zap: zap) } - func lookup_zaps(target: ZapTarget) -> [Zap] { + func remove_zap(zap: Zapping) { + switch zap.target { + case .note(let note_target): + let zaps = get_cache_data(note_target.note_id).zaps_model + zaps.remove(reqid: zap.request.id) + case .profile: + // these aren't stored anywhere yet + break + } + } + + func lookup_zaps(target: ZapTarget) -> [Zapping] { return get_cache_data(target.id).zaps_model.zaps } diff --git a/damus/Util/InsertSort.swift b/damus/Util/InsertSort.swift index 613344b6..29bc62c0 100644 --- a/damus/Util/InsertSort.swift +++ b/damus/Util/InsertSort.swift @@ -7,12 +7,17 @@ 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 { + zaps[i] = new_zap + return true + } + // don't insert duplicate events return false } @@ -28,16 +33,16 @@ func insert_uniq_sorted_zap(zaps: inout [Zap], new_zap: Zap, cmp: (Zap, Zap) -> } @discardableResult -func insert_uniq_sorted_zap_by_created(zaps: inout [Zap], new_zap: Zap) -> Bool { +func insert_uniq_sorted_zap_by_created(zaps: inout [Zapping], new_zap: Zapping) -> Bool { return insert_uniq_sorted_zap(zaps: &zaps, new_zap: new_zap) { (a, b) in - a.event.created_at > b.event.created_at + a.created_at > b.created_at } } @discardableResult -func insert_uniq_sorted_zap_by_amount(zaps: inout [Zap], new_zap: Zap) -> Bool { +func insert_uniq_sorted_zap_by_amount(zaps: inout [Zapping], new_zap: Zapping) -> Bool { return insert_uniq_sorted_zap(zaps: &zaps, new_zap: new_zap) { (a, b) in - a.invoice.amount > b.invoice.amount + a.amount > b.amount } } diff --git a/damus/Util/PostBox.swift b/damus/Util/PostBox.swift index 902af1a6..a1bf926e 100644 --- a/damus/Util/PostBox.swift +++ b/damus/Util/PostBox.swift @@ -26,16 +26,24 @@ class PostedEvent { let event: NostrEvent let skip_ephemeral: Bool var remaining: [Relayer] + let flush_after: Date? - init(event: NostrEvent, remaining: [String], skip_ephemeral: Bool) { + init(event: NostrEvent, remaining: [String], skip_ephemeral: Bool, flush_after: Date? = nil) { self.event = event self.skip_ephemeral = skip_ephemeral + self.flush_after = flush_after self.remaining = remaining.map { Relayer(relay: $0, attempts: 0, retry_after: 2.0) } } } +enum CancelSendErr { + case nothing_to_cancel + case not_delayed + case too_late +} + class PostBox { let pool: RelayPool var events: [String: PostedEvent] @@ -46,12 +54,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) } @@ -99,16 +132,20 @@ class PostBox { } } - 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) { // 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) + events[event.id] = posted_ev - flush_event(posted_ev) + if after == nil { + flush_event(posted_ev) + } } } diff --git a/damus/Util/WalletConnect.swift b/damus/Util/WalletConnect.swift index 2410d253..3f6e75ee 100644 --- a/damus/Util/WalletConnect.swift +++ b/damus/Util/WalletConnect.swift @@ -67,6 +67,80 @@ struct WalletRequest: Codable { 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) async { + guard let req_id = from.referenced_ids.first else { + return nil + } + + self.req_id = req_id.ref_id + + let ares = Task { + guard let resp: WalletResponse = decode_json(from.content) 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) + + switch result_type { + case .pay_invoice: + let res = try container.decode(PayInvoiceResponse.self, forKey: .result) + self.result = .pay_invoice(res) + } + } +} + func make_wallet_pay_invoice_request(invoice: String) -> WalletRequest { let data = PayInvoiceRequest(invoice: invoice) return WalletRequest(method: "pay_invoice", params: data) @@ -92,12 +166,65 @@ func make_wallet_connect_request(req: WalletRequest, to_pk: String, keypai return create_encrypted_event(content, to_pk: to_pk, tags: tags, keypair: keypair, created_at: created_at, kind: 23194) } -func nwc_pay(url: WalletConnectURL, pool: RelayPool, post: PostBox, invoice: String) { +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]) +} + +func nwc_pay(url: WalletConnectURL, pool: RelayPool, post: PostBox, invoice: String) -> 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 + return nil } - try? pool.add_relay(url.relay, info: .ephemeral) - post.send(ev, to: [url.relay.id], skip_ephemeral: false) + 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: 5.0) + return ev +} + + +func nwc_success(zapcache: Zaps, resp: FullWalletResponse) { + // find the pending zap and mark it as pending-confirmed + 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 nwc_req) = nwc_state.state, + nwc_req.id == resp.req_id + else { + continue + } + + nwc_state.state = .confirmed + return + } + } +} + +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 + remove_zap(reqid: pzap.request.ev.id, zapcache: zapcache, evcache: evcache) + return + } + } } diff --git a/damus/Util/Zap.swift b/damus/Util/Zap.swift index b240584b..29c669d2 100644 --- a/damus/Util/Zap.swift +++ b/damus/Util/Zap.swift @@ -7,7 +7,7 @@ import Foundation -public struct NoteZapTarget: Equatable { +public struct NoteZapTarget: Equatable, Hashable { public let note_id: String public let author: String } @@ -41,6 +41,148 @@ 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 { + var state: NWCStateType + let url: WalletConnectURL + + init(state: NWCStateType, url: WalletConnectURL) { + self.state = state + self.url = url + } + + 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 + var state: PendingZapState + + init(amount_msat: Int64, target: ZapTarget, request: ZapRequest, type: ZapType, state: PendingZapState) { + self.amount_msat = amount_msat + self.target = target + self.request = request + self.type = type + self.state = state + } +} + + +enum Zapping { + case zap(Zap) + case pending(PendingZap) + + var is_pending: Bool { + switch self { + case .zap: + return false + case .pending: + return true + } + } + + 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,7 +388,7 @@ 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, sats: Int, zap_type: ZapType, comment: String?) async -> String? { guard var base_url = payreq.callback.flatMap({ URLComponents(string: $0) }) else { return nil } @@ -256,7 +398,7 @@ func fetch_zap_invoice(_ payreq: LNUrlPayRequest, zapreq: NostrEvent?, sats: Int var query = [URLQueryItem(name: "amount", value: "\(amount)")] - 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)) } diff --git a/damus/Util/Zaps.swift b/damus/Util/Zaps.swift index 5d6b8e67..35671b78 100644 --- a/damus/Util/Zaps.swift +++ b/damus/Util/Zaps.swift @@ -8,9 +8,9 @@ import Foundation class Zaps { - var zaps: [String: Zap] + var zaps: [String: Zapping] let our_pubkey: String - var our_zaps: [String: [Zap]] + var our_zaps: [String: [Zapping]] var event_counts: [String: Int] var event_totals: [String: Int64] @@ -22,15 +22,42 @@ class Zaps { self.event_counts = [:] self.event_totals = [:] } + + 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: Zap) { - if zaps[zap.event.id] != nil { + 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: String, zapcache: Zaps, evcache: EventCache) { + guard let zap = zapcache.remove_zap(reqid: reqid) else { + return + } + evcache.get_cache_data(zap.target.id).zaps_model.remove(reqid: reqid) +} diff --git a/damus/Views/ActionBar/EventActionBar.swift b/damus/Views/ActionBar/EventActionBar.swift index f774173a..c0317d72 100644 --- a/damus/Views/ActionBar/EventActionBar.swift +++ b/damus/Views/ActionBar/EventActionBar.swift @@ -88,7 +88,7 @@ struct EventActionBar: View { if let lnurl = self.lnurl { Spacer() - ZapButton(damus_state: damus_state, event: event, lnurl: lnurl, bar: bar) + ZapButton(damus_state: damus_state, event: event, lnurl: lnurl, zaps: self.damus_state.events.get_cache_data(self.event.id).zaps_model) } Spacer() @@ -227,7 +227,7 @@ struct EventActionBar_Previews: PreviewProvider { let likedbar_ours = ActionBarModel(likes: 10, boosts: 0, zaps: 0, zap_total: 0, replies: 0, our_like: test_event, our_boost: nil, our_zap: nil, our_reply: nil) let maxed_bar = ActionBarModel(likes: 999, boosts: 999, zaps: 999, zap_total: 99999999, replies: 999, our_like: test_event, our_boost: test_event, our_zap: nil, our_reply: nil) let extra_max_bar = ActionBarModel(likes: 9999, boosts: 9999, zaps: 9999, zap_total: 99999999, replies: 9999, our_like: test_event, our_boost: test_event, our_zap: nil, our_reply: test_event) - let mega_max_bar = ActionBarModel(likes: 9999999, boosts: 99999, zaps: 9999, zap_total: 99999999, replies: 9999999, our_like: test_event, our_boost: test_event, our_zap: test_zap, our_reply: test_event) + let mega_max_bar = ActionBarModel(likes: 9999999, boosts: 99999, zaps: 9999, zap_total: 99999999, replies: 9999999, our_like: test_event, our_boost: test_event, our_zap: .zap(test_zap), our_reply: test_event) VStack(spacing: 50) { EventActionBar(damus_state: ds, event: ev, bar: bar) diff --git a/damus/Views/Events/TextEvent.swift b/damus/Views/Events/TextEvent.swift index 1550b8e7..4d5da904 100644 --- a/damus/Views/Events/TextEvent.swift +++ b/damus/Views/Events/TextEvent.swift @@ -181,7 +181,9 @@ struct TextEvent: View { VStack(alignment: .leading) { TopPart(is_anon: is_anon) - ReplyPart + if !options.contains(.no_replying_to) { + ReplyPart + } EvBody(options: self.options) diff --git a/damus/Views/Events/ZapEvent.swift b/damus/Views/Events/ZapEvent.swift index eadbf8c3..f92de39c 100644 --- a/damus/Views/Events/ZapEvent.swift +++ b/damus/Views/Events/ZapEvent.swift @@ -9,30 +9,30 @@ import SwiftUI struct ZapEvent: View { let damus: DamusState - let zap: Zap + let zap: Zapping var body: some View { VStack(alignment: .leading) { HStack(alignment: .center) { - Text("⚡️ \(format_msats(zap.invoice.amount))", comment: "Text indicating the zap amount. i.e. number of satoshis that were tipped to a user") + Text("⚡️ \(format_msats(zap.amount))", comment: "Text indicating the zap amount. i.e. number of satoshis that were tipped to a user") .font(.headline) .padding([.top], 2) - if zap.private_request != nil { + if zap.is_private { Image(systemName: "lock.fill") .foregroundColor(DamusColors.green) .help(NSLocalizedString("Only you can see this message and who sent it.", comment: "Help text on green lock icon that explains that only the current user can see the message of a zap event and who sent the zap.")) } + + if zap.is_pending { + Image(systemName: "clock.arrow.circlepath") + .foregroundColor(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: test_zap_request, type: .pub, state: .external(.init(state: .fetching_invoice))) + struct ZapEvent_Previews: PreviewProvider { static var previews: some View { VStack { - ZapEvent(damus: test_damus_state(), zap: test_zap) + ZapEvent(damus: test_damus_state(), zap: .zap(test_zap)) - ZapEvent(damus: test_damus_state(), zap: test_private_zap) + ZapEvent(damus: test_damus_state(), zap: .zap(test_private_zap)) } } } diff --git a/damus/Views/Notifications/EventGroupView.swift b/damus/Views/Notifications/EventGroupView.swift index 26f10508..280d244a 100644 --- a/damus/Views/Notifications/EventGroupView.swift +++ b/damus/Views/Notifications/EventGroupView.swift @@ -68,15 +68,11 @@ func event_group_author_name(profiles: Profiles, ind: Int, group: EventGroupType if let zapgrp = group.zap_group { let zap = zapgrp.zaps[ind] - if let privzap = zap.private_request { - return event_author_name(profiles: profiles, pubkey: privzap.pubkey) - } - if zap.is_anon { return NSLocalizedString("Anonymous", comment: "Placeholder author name of the anonymous person who zapped an event.") } - return event_author_name(profiles: profiles, pubkey: zap.request.ev.pubkey) + return event_author_name(profiles: profiles, pubkey: zap.request.pubkey) } else { let ev = group.events[ind] return event_author_name(profiles: profiles, pubkey: ev.pubkey) diff --git a/damus/Views/Relays/RelayConfigView.swift b/damus/Views/Relays/RelayConfigView.swift index dd5c335a..59503f63 100644 --- a/damus/Views/Relays/RelayConfigView.swift +++ b/damus/Views/Relays/RelayConfigView.swift @@ -88,8 +88,8 @@ struct RelayConfigView: View { } let info = RelayInfo.rw - - guard (try? state.pool.add_relay(url, info: info)) != nil else { + let descriptor = RelayDescriptor(url: url, info: info) + guard (try? state.pool.add_relay(descriptor)) != nil else { return } diff --git a/damus/Views/Zaps/ZapsView.swift b/damus/Views/Zaps/ZapsView.swift index 1a06ceab..e04d58a9 100644 --- a/damus/Views/Zaps/ZapsView.swift +++ b/damus/Views/Zaps/ZapsView.swift @@ -9,17 +9,20 @@ import SwiftUI struct ZapsView: View { let state: DamusState - @StateObject var model: ZapsModel + var model: ZapsModel + + @ObservedObject var zaps: ZapsDataModel init(state: DamusState, target: ZapTarget) { self.state = state - self._model = StateObject(wrappedValue: ZapsModel(state: state, target: target)) + self.model = ZapsModel(state: state, target: target) + self._zaps = ObservedObject(wrappedValue: state.events.get_cache_data(target.id).zaps_model) } var body: some View { ScrollView { LazyVStack { - ForEach(model.zaps, id: \.event.id) { zap in + ForEach(zaps.zaps, id: \.request.id) { zap in ZapEvent(damus: state, zap: zap) .padding([.horizontal]) } From 69fc6694f14fbdd02f44a35a829ce955c66f1663 Mon Sep 17 00:00:00 2001 From: William Casarin Date: Sat, 13 May 2023 23:28:07 -0700 Subject: [PATCH 14/51] nwc: turn pending zap orange when we have a NWC success Orange means payment successful now, not just presence of zap This introduces a paid pending state, which shows up as an orange timer thing in the zaps view. This can be useful if the zap is never sent. We don't want the user to think the payment didn't go through. --- damus/Components/ZapButton.swift | 19 ++++++++------ damus/Models/HomeModel.swift | 2 +- damus/Util/EventCache.swift | 16 +++++++----- damus/Util/WalletConnect.swift | 7 ++++-- damus/Util/Zap.swift | 41 +++++++++++++++++++++++++++++-- damus/Views/Events/ZapEvent.swift | 2 +- 6 files changed, 68 insertions(+), 19 deletions(-) diff --git a/damus/Components/ZapButton.swift b/damus/Components/ZapButton.swift index 7cda07ca..6c2428b5 100644 --- a/damus/Components/ZapButton.swift +++ b/damus/Components/ZapButton.swift @@ -54,13 +54,14 @@ struct ZapButton: View { } var zap_color: Color { - switch our_zap { - case .none: + guard let our_zap else { return Color.gray - case .pending: - return Color.yellow - case .zap: + } + + if our_zap.is_paid { return Color.orange + } else { + return Color.yellow } } @@ -260,7 +261,9 @@ func send_zap(damus_state: DamusState, event: NostrEvent, lnurl: String, is_cust return } - pzap_state.state = .postbox_pending(nwc_req) + 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) @@ -285,7 +288,9 @@ func cancel_zap(zap: PendingZap, box: PostBox, zapcache: Zaps, evcache: EventCac switch nwc_state.state { case .fetching_invoice: - nwc_state.state = .cancel_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 diff --git a/damus/Models/HomeModel.swift b/damus/Models/HomeModel.swift index 6acb7c14..8b13e129 100644 --- a/damus/Models/HomeModel.swift +++ b/damus/Models/HomeModel.swift @@ -143,7 +143,7 @@ class HomeModel: ObservableObject { } if resp.response.error == nil { - nwc_success(zapcache: self.damus_state.zaps, resp: resp) + nwc_success(zapcache: self.damus_state.zaps, evcache: self.damus_state.events, resp: resp) return } diff --git a/damus/Util/EventCache.swift b/damus/Util/EventCache.swift index 24b917cd..4f5e01ab 100644 --- a/damus/Util/EventCache.swift +++ b/damus/Util/EventCache.swift @@ -61,17 +61,21 @@ class ZapsDataModel: ObservableObject { self.zaps = zaps } - func update_state(reqid: String, state: PendingZapState) { + func confirm_nwc(reqid: String) { guard let zap = zaps.first(where: { z in z.request.id == reqid }), - case .pending(let pzap) = zap, - pzap.state != state + case .pending(let pzap) = zap else { return } - pzap.state = state - - self.objectWillChange.send() + 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 { diff --git a/damus/Util/WalletConnect.swift b/damus/Util/WalletConnect.swift index 3f6e75ee..67ad920e 100644 --- a/damus/Util/WalletConnect.swift +++ b/damus/Util/WalletConnect.swift @@ -188,7 +188,7 @@ func nwc_pay(url: WalletConnectURL, pool: RelayPool, post: PostBox, invoice: Str } -func nwc_success(zapcache: Zaps, resp: FullWalletResponse) { +func nwc_success(zapcache: Zaps, evcache: EventCache, resp: FullWalletResponse) { // find the pending zap and mark it as pending-confirmed for kv in zapcache.our_zaps { let zaps = kv.value @@ -202,7 +202,10 @@ func nwc_success(zapcache: Zaps, resp: FullWalletResponse) { continue } - nwc_state.state = .confirmed + if nwc_state.update_state(state: .confirmed) { + // notify the zaps model of an update so it can mark them as paid + evcache.get_cache_data(pzap.target.id).zaps_model.objectWillChange.send() + } return } } diff --git a/damus/Util/Zap.swift b/damus/Util/Zap.swift index 29c669d2..dcde92e3 100644 --- a/damus/Util/Zap.swift +++ b/damus/Util/Zap.swift @@ -76,7 +76,7 @@ enum NWCStateType: Equatable { } class NWCPendingZapState: Equatable { - var state: NWCStateType + private(set) var state: NWCStateType let url: WalletConnectURL init(state: NWCStateType, url: WalletConnectURL) { @@ -84,6 +84,15 @@ class NWCPendingZapState: Equatable { 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 } @@ -94,7 +103,7 @@ class PendingZap { let target: ZapTarget let request: ZapRequest let type: ZapType - var state: PendingZapState + private(set) var state: PendingZapState init(amount_msat: Int64, target: ZapTarget, request: ZapRequest, type: ZapType, state: PendingZapState) { self.amount_msat = amount_msat @@ -103,6 +112,17 @@ class PendingZap { 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 + } } @@ -119,6 +139,23 @@ enum Zapping { } } + 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): diff --git a/damus/Views/Events/ZapEvent.swift b/damus/Views/Events/ZapEvent.swift index f92de39c..84b4608e 100644 --- a/damus/Views/Events/ZapEvent.swift +++ b/damus/Views/Events/ZapEvent.swift @@ -26,7 +26,7 @@ struct ZapEvent: View { if zap.is_pending { Image(systemName: "clock.arrow.circlepath") - .foregroundColor(DamusColors.yellow) + .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.")) } } From 64a224135aa98376d3fd8a2a8cd3ab8867ab251a Mon Sep 17 00:00:00 2001 From: William Casarin Date: Sat, 13 May 2023 23:54:02 -0700 Subject: [PATCH 15/51] nwc: always allow long press zap --- damus/Components/ZapButton.swift | 4 ---- 1 file changed, 4 deletions(-) diff --git a/damus/Components/ZapButton.swift b/damus/Components/ZapButton.swift index 6c2428b5..8a51b75e 100644 --- a/damus/Components/ZapButton.swift +++ b/damus/Components/ZapButton.swift @@ -119,10 +119,6 @@ struct ZapButton: View { .font(.footnote.weight(.medium)) }) .simultaneousGesture(LongPressGesture().onEnded {_ in - guard our_zap == nil else { - return - } - button.showing_zap_customizer = true }) .highPriorityGesture(TapGesture().onEnded { From 85262e1a4e5884cc790911d7e8424cec3da54687 Mon Sep 17 00:00:00 2001 From: William Casarin Date: Sat, 13 May 2023 23:54:25 -0700 Subject: [PATCH 16/51] nwc: fix response parsing --- damus/Models/HomeModel.swift | 5 ++++- damus/Util/WalletConnect.swift | 13 ++++++++++--- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/damus/Models/HomeModel.swift b/damus/Models/HomeModel.swift index 8b13e129..28babd7c 100644 --- a/damus/Models/HomeModel.swift +++ b/damus/Models/HomeModel.swift @@ -138,7 +138,10 @@ class HomeModel: ObservableObject { func handle_nwc_response(_ ev: NostrEvent) { Task { @MainActor in - guard let resp = await FullWalletResponse(from: ev) else { + // 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 } diff --git a/damus/Util/WalletConnect.swift b/damus/Util/WalletConnect.swift index 67ad920e..d0141597 100644 --- a/damus/Util/WalletConnect.swift +++ b/damus/Util/WalletConnect.swift @@ -88,7 +88,7 @@ struct FullWalletResponse { let req_id: String let response: WalletResponse - init?(from: NostrEvent) async { + init?(from: NostrEvent, nwc: WalletConnectURL) async { guard let req_id = from.referenced_ids.first else { return nil } @@ -96,7 +96,9 @@ struct FullWalletResponse { self.req_id = req_id.ref_id let ares = Task { - guard let resp: WalletResponse = decode_json(from.content) else { + 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 } @@ -116,7 +118,7 @@ struct FullWalletResponse { struct WalletResponse: Decodable { let result_type: WalletResponseResultType let error: WalletResponseErr? - let result: WalletResponseResult + let result: WalletResponseResult? private enum CodingKeys: CodingKey { case result_type, error, result @@ -133,6 +135,11 @@ struct WalletResponse: Decodable { 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) From ae4d9ab8ba007c1e82990c502378199d75385e7d Mon Sep 17 00:00:00 2001 From: William Casarin Date: Sat, 13 May 2023 23:54:55 -0700 Subject: [PATCH 17/51] nwc: make delay 3 seconds instead of 5 --- damus/Util/WalletConnect.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/damus/Util/WalletConnect.swift b/damus/Util/WalletConnect.swift index d0141597..c1780048 100644 --- a/damus/Util/WalletConnect.swift +++ b/damus/Util/WalletConnect.swift @@ -190,7 +190,7 @@ func nwc_pay(url: WalletConnectURL, pool: RelayPool, post: PostBox, invoice: Str 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: 5.0) + post.send(ev, to: [url.relay.id], skip_ephemeral: false, delay: 3.0) return ev } From 8fb5b4f49cee2fa5e7773644d1d4781eade22d5c Mon Sep 17 00:00:00 2001 From: William Casarin Date: Sun, 14 May 2023 00:02:44 -0700 Subject: [PATCH 18/51] misc logs --- damus/Models/UserSettingsStore.swift | 2 +- damus/Util/WalletConnect.swift | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/damus/Models/UserSettingsStore.swift b/damus/Models/UserSettingsStore.swift index bc77dd22..2e6d181e 100644 --- a/damus/Models/UserSettingsStore.swift +++ b/damus/Models/UserSettingsStore.swift @@ -203,7 +203,7 @@ class UserSettingsStore: ObservableObject { var internal_libretranslate_api_key: String? @KeychainStorage(account: "nostr_wallet_connect") - var nostr_wallet_connect: String? + var nostr_wallet_connect: String? // TODO: strongly type this to WalletConnectURL var can_translate: Bool { switch translation_service { diff --git a/damus/Util/WalletConnect.swift b/damus/Util/WalletConnect.swift index c1780048..f612ac36 100644 --- a/damus/Util/WalletConnect.swift +++ b/damus/Util/WalletConnect.swift @@ -212,6 +212,7 @@ func nwc_success(zapcache: Zaps, evcache: EventCache, resp: FullWalletResponse) if nwc_state.update_state(state: .confirmed) { // notify the zaps model of an update so it can mark them as paid evcache.get_cache_data(pzap.target.id).zaps_model.objectWillChange.send() + print("NWC success confirmed") } return } From 02d99f734065a17a3271e1a4481cf3cbeb3039d3 Mon Sep 17 00:00:00 2001 From: William Casarin Date: Sun, 14 May 2023 00:12:51 -0700 Subject: [PATCH 19/51] postbox: try flushing events every second relying on network activity for flushing is not reliable and is causing delays in zapping --- damus/ContentView.swift | 6 ++++++ damus/Util/PostBox.swift | 2 -- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/damus/ContentView.swift b/damus/ContentView.swift index e110d4e7..640ca095 100644 --- a/damus/ContentView.swift +++ b/damus/ContentView.swift @@ -80,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") @@ -347,6 +350,9 @@ struct ContentView: View { 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 } diff --git a/damus/Util/PostBox.swift b/damus/Util/PostBox.swift index a1bf926e..0d56d192 100644 --- a/damus/Util/PostBox.swift +++ b/damus/Util/PostBox.swift @@ -93,8 +93,6 @@ class PostBox { } func handle_event(relay_id: String, _ ev: NostrConnectionEvent) { - try_flushing_events() - guard case .nostr_event(let resp) = ev else { return } From 122b5284075eb5d8a4901fb66f7f31fbb9427f70 Mon Sep 17 00:00:00 2001 From: William Casarin Date: Sun, 14 May 2023 00:14:10 -0700 Subject: [PATCH 20/51] Add rigid haptic feedback when you zap cancel fails This feels different than the soft haptic feedback so it should let people know that cancelling is no longer possible --- damus/Components/ZapButton.swift | 2 ++ 1 file changed, 2 insertions(+) diff --git a/damus/Components/ZapButton.swift b/damus/Components/ZapButton.swift index 8a51b75e..c598ca1e 100644 --- a/damus/Components/ZapButton.swift +++ b/damus/Components/ZapButton.swift @@ -85,6 +85,8 @@ struct ZapButton: View { return } + UIImpactFeedbackGenerator(style: .rigid).impactOccurred() + switch res { case .send_err(let cancel_err): switch cancel_err { From 13138805747054e94d2946fd0802deb23cfa525e Mon Sep 17 00:00:00 2001 From: William Casarin Date: Sun, 14 May 2023 00:15:06 -0700 Subject: [PATCH 21/51] Add release Damus build scheme --- .../xcshareddata/xcschemes/Release.xcscheme | 98 +++++++++++++++++++ 1 file changed, 98 insertions(+) create mode 100644 damus.xcodeproj/xcshareddata/xcschemes/Release.xcscheme diff --git a/damus.xcodeproj/xcshareddata/xcschemes/Release.xcscheme b/damus.xcodeproj/xcshareddata/xcschemes/Release.xcscheme new file mode 100644 index 00000000..d33ee7b5 --- /dev/null +++ b/damus.xcodeproj/xcshareddata/xcschemes/Release.xcscheme @@ -0,0 +1,98 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + From d0c67237dd1f483fa726b0dd541e43e7bf4b7b8a Mon Sep 17 00:00:00 2001 From: William Casarin Date: Sun, 14 May 2023 00:27:50 -0700 Subject: [PATCH 22/51] nwc: remove requests from postbox Since these are ephemeral events, there will never be command results, so we need to remove them manually on NWC responses or else it will keep trying to send them. We should probably handle this for all ephemeral events in the postbox somehow. We probably shouldn't use the postbox for ephemeral events without response events. --- damus/Models/HomeModel.swift | 8 ++++++++ damus/Util/PostBox.swift | 14 +++++++++----- 2 files changed, 17 insertions(+), 5 deletions(-) diff --git a/damus/Models/HomeModel.swift b/damus/Models/HomeModel.swift index 28babd7c..766dc967 100644 --- a/damus/Models/HomeModel.swift +++ b/damus/Models/HomeModel.swift @@ -145,6 +145,14 @@ class HomeModel: ObservableObject { 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") + } else { + print("nwc: \(resp.req_id) not found in the postbox, nothing to remove") + } + if resp.response.error == nil { nwc_success(zapcache: self.damus_state.zaps, evcache: self.damus_state.events, resp: resp) return diff --git a/damus/Util/PostBox.swift b/damus/Util/PostBox.swift index 0d56d192..e8c1a62c 100644 --- a/damus/Util/PostBox.swift +++ b/damus/Util/PostBox.swift @@ -104,16 +104,18 @@ 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 - } - ev.remaining = ev.remaining.filter { - $0.relay != relay_id + return false } + 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) { @@ -147,3 +149,5 @@ class PostBox { } } } + + From 0091df8f779a9e6f7abc9ad71219b162e50d3f83 Mon Sep 17 00:00:00 2001 From: William Casarin Date: Sun, 14 May 2023 00:31:30 -0700 Subject: [PATCH 23/51] nwc: fix broken test --- damusTests/WalletConnectTests.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/damusTests/WalletConnectTests.swift b/damusTests/WalletConnectTests.swift index 9ef47739..498bed96 100644 --- a/damusTests/WalletConnectTests.swift +++ b/damusTests/WalletConnectTests.swift @@ -65,7 +65,7 @@ final class WalletConnectTests: XCTestCase { XCTAssertEqual(pool.our_descriptors.count, 0) XCTAssertEqual(pool.all_descriptors.count, 1) - XCTAssertEqual(pool.all_descriptors[0].info.ephemeral, true) + 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 From e472e559a5653de6487d8c5df98bc77b7811e873 Mon Sep 17 00:00:00 2001 From: William Casarin Date: Sun, 14 May 2023 10:47:41 -0700 Subject: [PATCH 24/51] nwc: don't use yellow on the Zap Button for pending zaps I just find this stressful --- damus/Components/ZapButton.swift | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/damus/Components/ZapButton.swift b/damus/Components/ZapButton.swift index c598ca1e..8ca03bbe 100644 --- a/damus/Components/ZapButton.swift +++ b/damus/Components/ZapButton.swift @@ -54,15 +54,19 @@ struct ZapButton: View { } var zap_color: Color { - guard let our_zap else { + if our_zap == nil { return Color.gray } + // always orange ! + return Color.orange + /* if our_zap.is_paid { return Color.orange } else { return Color.yellow } + */ } func tap() { From b1fee253b4faa691c8b4eb0c445df74c721bc829 Mon Sep 17 00:00:00 2001 From: William Casarin Date: Sun, 14 May 2023 10:48:44 -0700 Subject: [PATCH 25/51] nwc: fix bug where private nwc zaps weren't getting tracked properly It was using the wrapper zap request id instead of the inner id. Fix this type error by creating a ZapRequestId wrapper that makes sure it uses the proper request id. --- damus/Components/ZapButton.swift | 20 +++++++----- damus/Nostr/NostrEvent.swift | 51 +++++++++++++++++++++++++++---- damus/Util/WalletConnect.swift | 3 +- damus/Util/Zap.swift | 19 ++++++++++-- damus/Util/Zaps.swift | 6 ++-- damus/Views/Events/ZapEvent.swift | 2 +- 6 files changed, 80 insertions(+), 21 deletions(-) diff --git a/damus/Components/ZapButton.swift b/damus/Components/ZapButton.swift index 8ca03bbe..c4ef26ad 100644 --- a/damus/Components/ZapButton.swift +++ b/damus/Components/ZapButton.swift @@ -174,7 +174,7 @@ struct ZapButton: View { struct ZapButton_Previews: PreviewProvider { static var previews: some View { - let pending_zap = PendingZap(amount_msat: 1000, target: ZapTarget.note(id: "noteid", author: "author"), request: test_zap_request, type: .pub, state: .external(.init(state: .fetching_invoice))) + 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) @@ -203,7 +203,7 @@ 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 ?? "" - guard let zapreq = make_zap_request_event(keypair: keypair, content: content, relays: relays, target: target, zap_type: zap_type) else { + 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 } @@ -211,7 +211,9 @@ func send_zap(damus_state: DamusState, event: NostrEvent, lnurl: String, is_cust let zap_amount = amount_sats ?? damus_state.settings.default_zap_amount let amount_msat = Int64(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: ZapRequest(ev: zapreq), type: zap_type, state: pending_zap_state) + 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)) @@ -225,7 +227,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: zapreq.id, zapcache: damus_state.zaps, evcache: damus_state.events) + 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) @@ -239,7 +241,7 @@ func send_zap(damus_state: DamusState, event: NostrEvent, lnurl: String, is_cust guard let inv = await fetch_zap_invoice(payreq, zapreq: zapreq, sats: zap_amount, zap_type: zap_type, comment: comment) else { DispatchQueue.main.async { - remove_zap(reqid: zapreq.id, zapcache: damus_state.zaps, evcache: damus_state.events) + 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) @@ -253,7 +255,7 @@ func send_zap(damus_state: DamusState, event: NostrEvent, lnurl: String, is_cust case .nwc(let nwc_state): // don't both continuing, user has canceled if case .cancel_fetching_invoice = nwc_state.state { - remove_zap(reqid: zapreq.id, zapcache: damus_state.zaps, evcache: damus_state.events) + remove_zap(reqid: reqid, zapcache: damus_state.zaps, evcache: damus_state.events) return } @@ -308,10 +310,12 @@ func cancel_zap(zap: PendingZap, box: PostBox, zapcache: Zaps, evcache: EventCac if let err = box.cancel_send(evid: nwc_req.id) { return .send_err(err) } - remove_zap(reqid: zap.request.ev.id, zapcache: zapcache, evcache: evcache) + let reqid = ZapRequestId(from_pending: zap) + remove_zap(reqid: reqid, zapcache: zapcache, evcache: evcache) case .failed: - remove_zap(reqid: zap.request.ev.id, zapcache: zapcache, evcache: evcache) + let reqid = ZapRequestId(from_pending: zap) + remove_zap(reqid: reqid, zapcache: zapcache, evcache: evcache) } return nil diff --git a/damus/Nostr/NostrEvent.swift b/damus/Nostr/NostrEvent.swift index 5243fd67..861d74cc 100644 --- a/damus/Nostr/NostrEvent.swift +++ b/damus/Nostr/NostrEvent.swift @@ -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 _, let pzr): + return pzr.req + case .normal(let zr): + return zr + } + } + + var potentially_anon_outer_request: ZapRequest { + switch self { + case .priv(let zr, _): + return zr + case .normal(let zr): + return zr + } + } +} + +func make_zap_request_event(keypair: FullKeypair, content: String, relays: [RelayDescriptor], target: ZapTarget, zap_type: ZapType) -> MakeZapRequest? { var tags = zap_target_to_tags(target) var relay_tag = ["relays"] relay_tag.append(contentsOf: relays.map { $0.url.id }) @@ -597,6 +628,8 @@ func make_zap_request_event(keypair: FullKeypair, content: String, relays: [Rela let now = Int64(Date().timeIntervalSince1970) + var privzap_req: PrivateZapRequest? + var message = content switch zap_type { case .pub: @@ -614,14 +647,20 @@ func make_zap_request_event(keypair: FullKeypair, content: String, relays: [Rela guard let privreq = make_private_zap_request_event(identity: keypair, enc_key: kp, target: target, message: message) else { return nil } - tags.append(["anon", privreq]) + tags.append(["anon", privreq.enc]) message = "" + privzap_req = privreq } let ev = NostrEvent(content: message, pubkey: kp.pubkey, kind: 9734, tags: tags, createdAt: now) ev.id = calculate_event_id(ev: ev) ev.sig = sign_event(privkey: kp.privkey, ev: ev) - return ev + let zapreq = ZapRequest(ev: ev) + if let privzap_req { + return .priv(zapreq, privzap_req) + } else { + return .normal(zapreq) + } } func uniq(_ xs: [T]) -> [T] { diff --git a/damus/Util/WalletConnect.swift b/damus/Util/WalletConnect.swift index f612ac36..06ed6930 100644 --- a/damus/Util/WalletConnect.swift +++ b/damus/Util/WalletConnect.swift @@ -234,7 +234,8 @@ func nwc_error(zapcache: Zaps, evcache: EventCache, resp: FullWalletResponse) { } // remove the pending zap if there was an error - remove_zap(reqid: pzap.request.ev.id, zapcache: zapcache, evcache: evcache) + let reqid = ZapRequestId(from_pending: pzap) + remove_zap(reqid: reqid, zapcache: zapcache, evcache: evcache) return } } diff --git a/damus/Util/Zap.swift b/damus/Util/Zap.swift index dcde92e3..7e7df445 100644 --- a/damus/Util/Zap.swift +++ b/damus/Util/Zap.swift @@ -105,10 +105,10 @@ class PendingZap { let type: ZapType private(set) var state: PendingZapState - init(amount_msat: Int64, target: ZapTarget, request: ZapRequest, type: ZapType, 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 + self.request = request.private_inner_request self.type = type self.state = state } @@ -125,6 +125,21 @@ class PendingZap { } } +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) diff --git a/damus/Util/Zaps.swift b/damus/Util/Zaps.swift index 35671b78..894bf22a 100644 --- a/damus/Util/Zaps.swift +++ b/damus/Util/Zaps.swift @@ -91,9 +91,9 @@ class Zaps { } } -func remove_zap(reqid: String, zapcache: Zaps, evcache: EventCache) { - guard let zap = zapcache.remove_zap(reqid: reqid) else { +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) + evcache.get_cache_data(zap.target.id).zaps_model.remove(reqid: reqid.reqid) } diff --git a/damus/Views/Events/ZapEvent.swift b/damus/Views/Events/ZapEvent.swift index 84b4608e..e0531e80 100644 --- a/damus/Views/Events/ZapEvent.swift +++ b/damus/Views/Events/ZapEvent.swift @@ -45,7 +45,7 @@ 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: test_zap_request, type: .pub, state: .external(.init(state: .fetching_invoice))) +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 { From 1fff0abce5ce3bc05f103c1f2f8aaadfea24a3be Mon Sep 17 00:00:00 2001 From: William Casarin Date: Sun, 14 May 2023 11:04:56 -0700 Subject: [PATCH 26/51] Make it easier to tap zap button Right now the tap target only covered the bolt, now it's bolt+amount --- damus/Components/ZapButton.swift | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/damus/Components/ZapButton.swift b/damus/Components/ZapButton.swift index c4ef26ad..1fd4002d 100644 --- a/damus/Components/ZapButton.swift +++ b/damus/Components/ZapButton.swift @@ -124,13 +124,6 @@ struct ZapButton: View { .foregroundColor(zap_color) .font(.footnote.weight(.medium)) }) - .simultaneousGesture(LongPressGesture().onEnded {_ in - button.showing_zap_customizer = true - }) - .highPriorityGesture(TapGesture().onEnded { - tap() - }) - .accessibilityLabel(NSLocalizedString("Zap", comment: "Accessibility label for zap button")) if zaps.zap_total > 0 { Text(verbatim: format_msats_abbrev(zaps.zap_total)) @@ -138,6 +131,13 @@ struct ZapButton: View { .foregroundColor(zap_color) } } + .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) } From 60260ae53bd052875ce6eb13604cfba04cff84fa Mon Sep 17 00:00:00 2001 From: William Casarin Date: Sun, 14 May 2023 11:11:27 -0700 Subject: [PATCH 27/51] nwc: make delay 5 seconds instead of 3 --- damus/Util/WalletConnect.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/damus/Util/WalletConnect.swift b/damus/Util/WalletConnect.swift index 06ed6930..8d9241a6 100644 --- a/damus/Util/WalletConnect.swift +++ b/damus/Util/WalletConnect.swift @@ -190,7 +190,7 @@ func nwc_pay(url: WalletConnectURL, pool: RelayPool, post: PostBox, invoice: Str 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: 3.0) + post.send(ev, to: [url.relay.id], skip_ephemeral: false, delay: 5.0) return ev } From a30a79b1fcf80eb83a738bcf2f5775f487c021da Mon Sep 17 00:00:00 2001 From: William Casarin Date: Sun, 14 May 2023 11:20:28 -0700 Subject: [PATCH 28/51] Fix tests --- damusTests/ZapTests.swift | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/damusTests/ZapTests.swift b/damusTests/ZapTests.swift index fb7191dd..4199e7dd 100644 --- a/damusTests/ZapTests.swift +++ b/damusTests/ZapTests.swift @@ -24,13 +24,14 @@ final class ZapTests: XCTestCase { let target = ZapTarget.profile(bob.pubkey) let message = "hey bob!" - let zapreq = make_zap_request_event(keypair: alice, content: message, relays: [], target: target, zap_type: .priv) + let mzapreq = make_zap_request_event(keypair: alice, content: message, relays: [], target: target, zap_type: .priv) - XCTAssertNotNil(zapreq) - guard let zapreq else { + XCTAssertNotNil(mzapreq) + guard let mzapreq else { return } + let zapreq = mzapreq.potentially_anon_outer_request.ev let decrypted = decrypt_private_zap(our_privkey: bob.privkey, zapreq: zapreq, target: target) XCTAssertNotNil(decrypted) From 50e445201616a256cf83fc503aa8c5dca021b63a Mon Sep 17 00:00:00 2001 From: Terry Yiu <963907+tyiu@users.noreply.github.com> Date: Sun, 14 May 2023 14:12:49 -0400 Subject: [PATCH 29/51] Fix nostr URL scheme to open properly even if there's already a different view open Closes: #1130 Changelog-Fixed: Fix nostr URL scheme to open properly even if there's already a different view open --- damus/ContentView.swift | 3 +++ 1 file changed, 3 insertions(+) diff --git a/damus/ContentView.swift b/damus/ContentView.swift index 640ca095..5f602493 100644 --- a/damus/ContentView.swift +++ b/damus/ContentView.swift @@ -240,6 +240,7 @@ struct ContentView: View { } func open_event(ev: NostrEvent) { + popToRoot() self.active_event = ev self.thread_open = true } @@ -250,11 +251,13 @@ struct ContentView: View { } 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 } From 0293b61d7a4fda02a1db5dd739e4f121a5ce3042 Mon Sep 17 00:00:00 2001 From: William Casarin Date: Sun, 14 May 2023 14:06:29 -0700 Subject: [PATCH 30/51] Rename 'Connect to Alby' to 'Attach Alby Wallet' --- damus/Views/Buttons/AlbyButton.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/damus/Views/Buttons/AlbyButton.swift b/damus/Views/Buttons/AlbyButton.swift index 28c3da28..5cc64702 100644 --- a/damus/Views/Buttons/AlbyButton.swift +++ b/damus/Views/Buttons/AlbyButton.swift @@ -23,7 +23,7 @@ struct AlbyButton: View { HStack { Image("alby") - Text("Connect to Alby") + Text("Attach Alby Wallet") } .offset(x: -25) .frame(minWidth: 300, maxWidth: .infinity, minHeight: 50, maxHeight: 50, alignment: .center) From ec50c750625d24a58e12ce2b4cc689f8b5ccf605 Mon Sep 17 00:00:00 2001 From: William Casarin Date: Sun, 14 May 2023 20:47:53 -0700 Subject: [PATCH 31/51] ui: expose raw LinearGradient in DamusGradient This will be used in background-fill applications --- damus/Components/Gradients/DamusGradient.swift | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/damus/Components/Gradients/DamusGradient.swift b/damus/Components/Gradients/DamusGradient.swift index 90434a91..1f2d3b86 100644 --- a/damus/Components/Gradients/DamusGradient.swift +++ b/damus/Components/Gradients/DamusGradient.swift @@ -14,9 +14,13 @@ fileprivate let damus_grad = [damus_grad_c1, damus_grad_c2, damus_grad_c3] struct DamusGradient: View { var body: some View { - LinearGradient(colors: damus_grad, startPoint: .bottomLeading, endPoint: .topTrailing) + DamusGradient.gradient .edgesIgnoringSafeArea([.top,.bottom]) } + + static var gradient: LinearGradient { + LinearGradient(colors: damus_grad, startPoint: .bottomLeading, endPoint: .topTrailing) + } } struct DamusGradient_Previews: PreviewProvider { From 6f23b69f2c8a9732356d38f32ea544cc9e842d03 Mon Sep 17 00:00:00 2001 From: Terry Yiu <963907+tyiu@users.noreply.github.com> Date: Thu, 11 May 2023 10:09:56 -0400 Subject: [PATCH 32/51] Export strings for translation --- damus/Views/Buttons/AlbyButton.swift | 2 +- .../Localized Contents/en-US.xliff | 23 +++++++++--------- .../damus/en-US.lproj/Localizable.strings | Bin 82154 -> 82138 bytes 3 files changed, 12 insertions(+), 13 deletions(-) diff --git a/damus/Views/Buttons/AlbyButton.swift b/damus/Views/Buttons/AlbyButton.swift index 5cc64702..ad6ffa96 100644 --- a/damus/Views/Buttons/AlbyButton.swift +++ b/damus/Views/Buttons/AlbyButton.swift @@ -23,7 +23,7 @@ struct AlbyButton: View { HStack { Image("alby") - Text("Attach Alby Wallet") + Text("Attach Alby Wallet", comment: "Button to attach an Alby Wallet, a service that provides a Lightning wallet for zapping sats. Alby is the name of the service and should not be translated.") } .offset(x: -25) .frame(minWidth: 300, maxWidth: .infinity, minHeight: 50, maxHeight: 50, alignment: .center) diff --git a/damus/en-US.xcloc/Localized Contents/en-US.xliff b/damus/en-US.xcloc/Localized Contents/en-US.xliff index 46785326..1fe7bc9b 100644 --- a/damus/en-US.xcloc/Localized Contents/en-US.xliff +++ b/damus/en-US.xcloc/Localized Contents/en-US.xliff @@ -291,6 +291,11 @@ Sentence composed of 2 variables to describe how many people are following a use Connect To Relay Label for section for adding a relay server. + + Connect to Alby + Connect to Alby + Button to connect to Alby, a service that provides a Lightning wallet for zapping sats. Alby is the name of the service and should not be translated. + Connected Relays Connected Relays @@ -471,11 +476,6 @@ Sentence composed of 2 variables to describe how many people are following a use Disconnect From Relay Button to disconnect from the relay. - - Dismiss - Dismiss - Button to dismiss a text field alert. - Display Name Display Name @@ -995,8 +995,7 @@ Button text to indicate that the zap type is a private zap. Relay Relay - Label to display relay address. - Text field for relay server. Used for testing purposes. + Label to display relay address. Relays @@ -1067,11 +1066,6 @@ Button text to indicate that the zap type is a private zap. Repost Button to repost a note - - Repost Note - Repost Note - Title text to indicate that the buttons below are meant to be used to repost a note to others. - Reposted Reposted @@ -1358,6 +1352,11 @@ Button text to indicate that the zap type is a private zap. Universe 🛸 Toolbar label for the universal view where posts from all connected relay servers appear. + + Unmute + Unmute + Button to unmute a profile. + Unmute conversation Unmute conversation diff --git a/damus/en-US.xcloc/Source Contents/damus/en-US.lproj/Localizable.strings b/damus/en-US.xcloc/Source Contents/damus/en-US.lproj/Localizable.strings index ce232ab0adc6a22f1f71aae3e9c0e6a0ef2c8c49..67202776bc44e6a4ad6f6f4d4a3cf808af774631 100644 GIT binary patch delta 331 zcmaFW$a<@hbwie~r6WTQLlQ$JgARiNLn06tGo&&UF_bZ6G9&|83JfI-89?z8Agcf< zo)4Bu0m>9lF8r+StiX^46e|MCRsmHN0AVIW9z#0V9FVSJ20b7fW`_b$wiw6K$#S<85xruqlFohCOcY)PF9f>n7qM{j~}EzA7Sd`^L~=(Vw(+p z=Sok0kR~*pPo0r(b6I9f-sFT1uE`CzxF+{xaBc3pafNTPLk-7t1qVi{$qG;THqUu+ z&T9HU1xAUd~$t&^7MadjC`BRvRd+_ zxq$K@7=mfbpaZ)kR>P&__hIAmmfT0v9UI3H@s+@fB zk?>@`+iaWpZr Date: Thu, 11 May 2023 16:24:52 +0000 Subject: [PATCH 33/51] Apply translations in: - nl - de - cs - hu_HU - sv_SE - ar - fa - pl_PL - ja Closes: #1122 --- damus/ar.lproj/Localizable.strings | Bin 81808 -> 80974 bytes damus/cs.lproj/Localizable.strings | Bin 83574 -> 83576 bytes damus/de.lproj/Localizable.strings | Bin 85150 -> 85168 bytes damus/fa.lproj/InfoPlist.strings | Bin 782 -> 1376 bytes damus/fa.lproj/Localizable.strings | Bin 73760 -> 82726 bytes damus/fa.lproj/Localizable.stringsdict | 58 ++++++++++++++++++++++--- damus/hu-HU.lproj/Localizable.strings | Bin 84758 -> 84760 bytes damus/ja.lproj/Localizable.strings | Bin 77246 -> 77236 bytes damus/nl.lproj/Localizable.strings | Bin 84026 -> 84004 bytes damus/pl-PL.lproj/Localizable.strings | Bin 83986 -> 83974 bytes damus/sv-SE.lproj/Localizable.strings | Bin 83246 -> 83236 bytes 11 files changed, 53 insertions(+), 5 deletions(-) diff --git a/damus/ar.lproj/Localizable.strings b/damus/ar.lproj/Localizable.strings index 6d1936e242eca20c0e7e4d4611bbfd332c799294..ac88a3fd007b2c70fbc5dc1f8bdf243b50e0a506 100644 GIT binary patch delta 490 zcmY*VODKd<82#>;E-}U!nlYZ&?=j=ilqrc)6dSKR%3>BWm&BSnJ8!BcYFT`<@Ov#Ti z87Qr0xKkLChXuPjCh)u5ipx(H?1~yT_e^rY9A>zE=PoLPCJ-Jd&O|($ z1Ye@B>85#Tra4d{fGchr@?#TjnN28q^|;hwz=3vNm0(q9uaT|-cJfxk9lVYlwvy97 zWn}$!d~Lsyrybq7xY23G@S9N{`9s!-)5=^juS&w9E4uw{%C z)-tCYds($e-A{K3UJqv{%|H+jq7{*Ig&A`Ht2fXO7n82`5`2v2py*1$%Ra~1n!=0) EzjLIBD*ylh delta 859 zcmb_aUr1A76hC*a8(d4*n$7*cDr-~Xu0$%M=o)KfIZ4VMqKAx4x7eCEt%P#bvk$Qo zE{Mb+>?Jb!pr99DdJB5Udg>t(^R2xqEM~vk%*& zgtIVck}N#AYAZb7Bh?FUciF*H*K9nNw)FIo3WjmIfv7@R61icY3Oy<0Lpay4`{h6w z9a(5FViu05bOfuB0`eg_EC=Lce6+PeXoVouN)#K8+EhoXgo+y`|=tTE#+6JInVy7x>D&!mqzNYg^7Cc^^Mq@$po< z!o^QP@n&&s*OtEw$}gL%Gx(9#yj%B&%VlNzpUz07op7cNH^kx6`6l*0+WSiPNj07y Dr9Iwb diff --git a/damus/cs.lproj/Localizable.strings b/damus/cs.lproj/Localizable.strings index 32c08fa36edf7bf3046ae3bdd50653d9a393ce54..56327364a60c570ff11c79653c004532faf4eaf9 100644 GIT binary patch delta 357 zcmey?!}_C#b%T?ir6WTQLlQ$JgARiNLn06tGo&&UF_bZ6G9&|83JfI-89?z8Agcf< zo)4Bu0m>9lw*Ri_mCPN-WI@lDDo?-?)ARA_b0#LRX$OoASGC2>Z zCwKDwUQu0C8xw&tDPS`)fTouM-3Q|30ofo|Buy^t7oFS`C^EUrpKJ0wUk-Mt`IF!K ziYul9)x8C($_Cp|4Afi7P{)|WAk7GM$MirsM%Bp^e8r~UFlOYLTu{I^S;vcObDW=@ z%;XKXg(g4H;oIzyy{usRJu^nG$qvt0CNH?lwz=ZYJ-*F(Z^UdS=cS7L~!iOolv$bg=G3ppIe&Js=xw@^nR6Mq$r9pt4-B zarr>45JN$Rqg$2;G$sWso&mI=6zDV%FAvBr0n$kfsgo6(g{L1-Wn`J`5-KoRE|6>T zIe!kgAC!QKY=Kyb!58QvkQSKl6d1}j_xc}~+58~qPtkNaTSl(Q8{V;OPI$n>KY7A4 zgUu^GEV7-v&Pas0lp$~O$3>c~p+H-6Crd9@pL}5r+jI$iMuo}uLNq2fL~?Ae(qmMS F0|1fKQ|kZ# delta 264 zcmdlmlXc!q)(zMEr+c|F3T-Y6%#oSAAWLkrRWYA+B7*`$2}3GF1&~x=NMpzZvU35amxEPceT!1Qy8FE47ks+6%l%bemVo?RRGnkjkP{fcp`9GsE3g4eeyI!4bJ)0q$0)s2t z86c_0=E0`U<_=`*19_HgE7^>Jv<6Vj5U9=oOoP;|WOIY6(FcmT0(C*uxdY|&fov_H zdSkGO`an5DHVq)#4JZfFrOsvqRs}LsfAU5~#fh(82>UW*G9&{Xna@zbkO3s~7*avb z#VSt7*&bl0odH@5a;h27a&sVU0`wBdd6akt6i6$9&i0UEa|9ZX)km8*GR86j01#$M A(*OVf delta 21 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 Qjui^$hC)WDJxlHVU*2&_;Q#;t delta 5676 zcmb7I3s6&68onoqml$G3qdrC{T9@wF z)}{Qlx6`_4wbKH1DBZg)o!yRuJ6(5{It+F?Ti13dZd=#2U7Vfis@-+j{m)54ZiMM$ zn1q~j&;8H;`2O$w|9P!v(Wx=j$e2N#T}d`AuHhQF7Vc5*G5CWiF?4RkdEjcCVX>Rf z;8XcxK2=&4M+n?XD3y$YpoTF+qf{69Dv^d`UQq0GaCIo7nd{(eoP(>u-?iL6->*je zZQ~NzpTMo)^0@>~;BxVkfFmbo!I>ZYKHRiLN@Ip0rKE|r6UZ{@o(O)oo$JK?8#oVF zjr%roJ5ehS%HMBn)}9sX+Z7gKUN`bg5MmUl=4oAK+!t_lZvb%)n{pguil+cd8XhHjF zd<68F2b@DG&@g0IR&(v}+|UZizNS(^F%kz=4Jz1wN(W=P#?UtOTr0lW&UL_Zkp^+t zOw=&DP7eb)31-!Hz+Nq!a;xF}+Ayg6MoaAAoG~me zKrz*b>IV34p($l<6i|dKK-D^MMGO9K#x3X8__nwKU!R>PJ;lyeQe5rDw*_uH?$U^V z9yUtiQaKIoQNWP57as7$M)`ivTMfM@9kS$mwm4-T5)9Dw<(j1dd{9N};psEcCMuLA zt<0dgR25GqkZ9T_x#|ES&|Fal^pxpO;#>$KS#&{N=kf=KXiZ3}4(Po3Td#!Gs5e=;mF@D9~A zKo)WhGLe)v!1DhJ2#peH)Q_xqmUL;vXEOfSe5g31ftiv#Xe<@P5rOb9vQ7he zFKQrdqY-9HUs_1FQmWhi2xCeJ0_VcqYYo!y-e&CrSQ>blyqWymC`rJgq$7ym={LS z-{xWVVT;VG*2)%HqB+n34)LIsMCQBk@N**C1P+zJUjk?6H}FY88Ige*lFnbmxi0)p zW%*LXQ;e<@plY*4GCne({7#>wzq1^+)+`V8lXErKB%T*Yc=Y|uKOKx11+K!!-7c;P zmNghmZoYtRN7YK_yYM>|Nz#ETTQNNzJYaxxGg|m`i(2}$=C=yyF0+V}`AFBtE?b}w z&yFp1*I=w(N24*v!tgM==cT7$j5x%%$;=HKm8b`hC3xo6WhC8h8PSPrNLxrYS)Yc5}NNq+AfWh)_RM8@b`3h zY5MsPH?j*J0Z=j(3yQXg#R8X!st)o=;5rZwS6a71*SHRT@w`rYzU`Ssq0}V;o)^_z zGkh?Vq1?c`MOQd6q!uxB*mxV7;{82{2%*y43<=)AFvda$SmBl3W@t>;N$>2v90Ie+ zrP;+SJ19}nRNRjL+i13MW|=)1=shb6)Up^;&sljh2a}EjxN@j85D!Q^?55=c&T1CX z^Vb6#pRj_hTMw2}qiojvZ7M836cbsDKB#2@b8%%{2`aD=NBh8aI3Ak21?Yb!LhO$w zMrrGlN0byn($G^!6;QS*LhRO%e8}mElWLyMAyVD5D&aOzJhq0)f}?z^j?mPr#Y#3`k)mq!$RJMrXCQN!c~3yk!aLdD6#g%og71vXjAN?D*#b8~bKh1%Q&J*;lU+B#gr{|1KU_D;t#z#LGr41BYaz+XE|>(x44u$b7s2 z&cSe#9q~e0Lf~jJIFL)cXu%?zkB7IaB3*WzzsSGIIHd^J72+s^aZUlEjE)43{`boE zr8|>IQHCbM&E%g#!t0fgzb6j99=yD04z?v5@a-PdDFbB`vGmglNsKUlERJ`tFfjQQ zZ%kv!*aq&io7Z}!d&x*E2%Hr&idtDH+bxr}6AZ{Mq_5TZg^t-H1qB>f$5Lzeb-ge{&Qo>g|Jgdu~Wt>We=i1LZKwSFLjhvN zuES{vhom>cVCHPLq?jC+hw-~wk6-Os1zkfCbFD||hc*?KcIncKAOBhr%Nib3rW~2c zTXxSK3}ST#xetVFRjRkVq`eI7MQJJ8fy=Q=VYE|^<5pyVHeYMu0r!>F^hl1Pks;BFH65#2}Cb)H_3??QuF!r%ZI`T=Wzm!l*J3iAAY~hL|`{zalO!ewu=5Nbk z_=_wUyP6J`s{+Xe*B8->Y_9JwE2XM08?oWrrh@BEtrCknIP_Yi5_>2}72`o$rA_tg z>^*v^d2Lt+v4Wpj(t_W#D4f@MbzM`#o$J@Av*7(3F@9ws?*`8QQ7ct`HLO?&*;y3X zTXBcSQ7ZM8zaa?B1@Ii0lV<;MEJPe}5{-26mJturb}fUp<=ZrD$DCouH3X~8*?bwy zJ7>?s^ncXHb%dXPBGL7}S>K5S@BL4+Bm#Ii3w05WC;zrQrhlwMEXhsK=ByT~} za;a(CFpzC%-eYK z*V}xtx>I)QCt=Flk)-JXfh8vV^7C-Kc~SLU7U0gYFtAst#d9Xo25I{>;$P!Qfr*{? zC~OZ)0h$bjoEuMGH|CBTK5<5iRRU>VT}}%FeCX|6y;V&sEA;$rWzdU$fpB}v+Rbhs zV)mhFw7o}@@HwsTX?quZuON4PIi+E?Lax&JoN)1uK$1dblcOultoZLWrOB_k#1bn>mW7tS zwN%c#c&5TX8)9jxG1_G#H;A|?nH14P6ASDleWB!ff+8^ZYM=#aiK3DoWc2ccx=!w% z?_V82>-VmOw+sd^NObV&H(Ka89%TxCX&Z}}2kTj?4j$hBvWlfDqC*d9N4xE0Z>YGV zfW$yqn+ERgh#0-QhGbW_f!`=$i%4E1ZwFx3&oc7uoNY`Wk!3i|6DbM4x^y_U_}OEl*ZHz^nE z^GNcju%2`gh7RVbv;vZ_&^^`t6e1O<8zlznmtdL-ei8_M&^(+!mU{2~*PLA1S9=R@ XIn3z$fhWOo=jO#%oJemN|MC9=aplHC diff --git a/damus/fa.lproj/Localizable.stringsdict b/damus/fa.lproj/Localizable.stringsdict index 8aadfd27..17dc0417 100644 --- a/damus/fa.lproj/Localizable.stringsdict +++ b/damus/fa.lproj/Localizable.stringsdict @@ -15,7 +15,7 @@ one ... %d یادداشت دیگر ... other - ... %d یادداشت‌های دیگر ... + ... %d نوت های دیگر ... followers_count @@ -63,7 +63,7 @@ one %2$@ و %1$d نفر دیگر به یک مطلب که شما در آن تگ شده‌اید بازخورد داده‌اند other - %2$@ و %1$d نفر دیگر به یک مطلب که شما در آن تگ شده‌اید بازخورد داده‌اند + %2$@ و %1$d نفر دیگر به یک نوت که شما در آن تگ شده‌اید بازخورد داده‌اند reacted_your_post_3 @@ -79,7 +79,7 @@ one %2$@ و %1$d نفر دیگر به مطلب شما بازخورد داده‌اند other - %2$@ و %1$d نفر دیگر به مطلب شما بازخورد داده‌اند + %2$@ و %1$d نفر دیگر به نوت شما بازخورد داده‌اند reacted_your_profile_3 @@ -95,7 +95,7 @@ one %2$@ و %1$d نفر دیگر به نمایه‌ی شما بازخورد داده‌اند other - %2$@ و %1$d نفر دیگر به نمایه‌ی شما بازخورد داده‌اند + %2$@ و %1$d نفر دیگر به پروفایل شما بازخورد داده‌اند reactions_count @@ -210,6 +210,22 @@ بازنشرها + sats + + NSStringLocalizedFormatKey + %#@SATS@ + SATS + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + d + one + ساتوشی + other + ساتوشی + + sats_count NSStringLocalizedFormatKey @@ -226,6 +242,38 @@ %2$@ ساتوشی + zap_notification_no_message + + NSStringLocalizedFormatKey + %1$#@NOTIFICATION@ + NOTIFICATION + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + @ + one + %2$@ ساتوشی از %3$@ دریافت کردید + other + %2$@ ساتوشی از %3$@ دریافت کردید + + + zap_notification_with_message + + NSStringLocalizedFormatKey + %1$#@NOTIFICATION@ + NOTIFICATION + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + @ + one + %2$@ ساتوشی از %3$@ دریافت کردید: "%4$@" + other + %2$@ ساتوشی از %3$@ دریافت کردید: "%4$@" + + zapped_tagged_in_3 NSStringLocalizedFormatKey @@ -287,7 +335,7 @@ one Zap other - Zaps + زپ diff --git a/damus/hu-HU.lproj/Localizable.strings b/damus/hu-HU.lproj/Localizable.strings index c70e8fffa551c0ab5211acc51e2657daab7163e1..051632aeed3fb6e0091057624bacf69c61f18c88 100644 GIT binary patch delta 293 zcmbO>jdjK})(sK0nbpdWsqJfNZD@lOIY7Gbb|SPHyZM zl|;285vVH#tSe*k-Z;t0Q9S4BCIL4l!!A(f#5NGdR-F=PVSISeU4b|R2YWhi1O znar3gESU?GDF#9%po}dLD>1kN<;xgSr+?IC6rDUlS7Gy#+zW-uTtGPx48dw<8bdxq z(PYO!dHqnJS*c)qz+ylhAXgLv$xMbkhIAmmfT0vuIUpT7}Ykjyl1hUye`UM`w<<+5IF#?=vGev diff --git a/damus/ja.lproj/Localizable.strings b/damus/ja.lproj/Localizable.strings index 8d16014d94cb2117cb5d3d4823a2294d10a068b2..d0b98af10c8330e755636bb0a2c9d8070a413f1f 100644 GIT binary patch delta 326 zcmdmYn`O&wmJJ&`EFBqg7?Kz&8FUyF7!rZFm?4#+h@p%jlOY+%QeY@y$N-9$09ge< z@qDmM3Q(qa^1&GC$rGOO*`@(Si-7V~Ky?K`n8}dGkPbEpq_3Dk56Fhuq5zaF2J%6s zf=thwd@)K~AKlJGpb05p6Ec8imjYb~;^hI^Aa^89Rx}f3PGv}$tmrALPy!T1n6Ct6 z+XAr?+{Vdqo)VK6tPzL_i}) z%232mGFdTMm^~LLQ_N62IsB~tf8gT+=@&GpcR=cYBTLWS<~|?OsBRS<(Qc CjYt~+ diff --git a/damus/nl.lproj/Localizable.strings b/damus/nl.lproj/Localizable.strings index 4c2c7c3bd8c187303a77886f0b2aee352cac3281..ef91a2ec040f595e6ca2368a0cd5e00b7ce76512 100644 GIT binary patch delta 296 zcmdlrfpy6Q)(u_$s*Vgf3`q=?3_1)742eKo%#g}Z#8AeN$&d_WDKL~UWH2O7{qnjG+kW%Hf84*ZkPRdY-)aA%a7{NOd) zWI1Mz%}MX>SWmv!D8gLIkT*HgNOSrMEk>T{=d>6lCaW#tn;Z~fusu(U@r*10{rgs? delta 282 zcmZ27fpyme)(u_$lkcqK*enz1Co^e}igF@@0z(NyDnkX3RA5MB$ON);7*c@jL?E5Y zP{dF&`C*Q*WG+yq7zmYsGPXdh#NY>%O=n1*e6d=1(hH%@HMtK8l(~R1AQ*zx#59I{ zhN8*w$E7DL__OGT0!>Q=+XNN|>H@i=7)WL^F3(~joZwRY!&#h9rhc1|0?khD0DPW=LfyVkl$CWJm_G6c|bvG8hsk3+f6p z7EfkOm7ZLX%A>8okj9V?RHeXB1=LdjgqaL^4C!Fqi9j9240=E|*yQPj(u~5Br&tI~ zO6HiX5Y3{CY<410R|;5H2GG1xpi4l!JRrLSNGCC*PG)Qpp01$C$TB%7Kx%T19|t?s zxsz)HWZ~?ELF$`-`31>LR&eFnd@oy~aPprZuE`Vbb8T+8>%u?X!JScQ)4ONZll#I% rrYEQa4HGib;0^`alMA%7gduf$sTQN|^c*EdzDZFA+r_jP|HuLW;fYFV delta 286 zcmZphz&dFH>jobG%@YC~WF}wOtrDEbpukYVkjhX2Bo!Fa7&3wE9EKDiI}u2yG86&D z^?*Dr27LxD1|T3br(jAs=W6#7V_Kt1}t$7}A0K0-(V~Kr$bsa`MF2 z!kd}ydGSwPV8}J~7-PfaH*aJn>v^!T<^#24OwNBJJ-Of=i?jlRA5a6xcL;X|07X(5 a5~puuWE9?P@K(iUvR|aZb}lW(Ke7M{3rAJ} diff --git a/damus/sv-SE.lproj/Localizable.strings b/damus/sv-SE.lproj/Localizable.strings index 4049def40ed51ca4f3184817be87aaba8362879f..59a508001868c9b96e83aafb9b28138eb102495d 100644 GIT binary patch delta 332 zcmZ42#k!=6b%U9or6WTQLlQ$JgARiNLn06tGo&&UF_bZ6G9&|83JfI-89?z8Agcf< zo)4Bu0m>9luK%X4t-z256e|MCRsmHN0AVIW9z#0V9FVSJ20b7fY{&G4VvNF!d6WMq zicd~B%c6~JNg_~F3RqJH(6~~dlR&&YARATq%u?hNd<;9hD;zkham;XP6X1a z3`Ib3Js?kuL7#z(L5aZysG^u57fcod`3elSK&-^z$WR7^K)LA`RT!10YiTp`ZGM*h zE}xYPD6KGEQHfD<@}>ZVNtP`7p+Eyt!B(U(t4xB=5uFb%?B#am~8k`ni;5ZvgUus$rp}sO%`~hvFY^{ L>+OE(jMroV76Cw& From f9982e992acbe40238b2c664db95133bccec9957 Mon Sep 17 00:00:00 2001 From: Terry Yiu <963907+tyiu@users.noreply.github.com> Date: Thu, 11 May 2023 23:25:27 -0400 Subject: [PATCH 34/51] Migrate away from sticky deprecated non-pubkey-scoped settings Changelog-Fixed: Migrate away from sticky deprecated non-pubkey-scoped settings Closes: #1124 --- damus/Models/UserSettingsStore.swift | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/damus/Models/UserSettingsStore.swift b/damus/Models/UserSettingsStore.swift index 2e6d181e..08d29593 100644 --- a/damus/Models/UserSettingsStore.swift +++ b/damus/Models/UserSettingsStore.swift @@ -19,8 +19,11 @@ let fallback_zap_amount = 1000 if let loaded = UserDefaults.standard.object(forKey: self.key) as? T { self.value = loaded } else if let loaded = UserDefaults.standard.object(forKey: key) as? T { - // try to load from deprecated non-pubkey-keyed setting + // If pubkey-scoped setting does not exist but the deprecated non-pubkey-scoped setting does, + // migrate the deprecated setting into the pubkey-scoped one and delete the deprecated one. self.value = loaded + UserDefaults.standard.set(loaded, forKey: self.key) + UserDefaults.standard.removeObject(forKey: key) } else { self.value = default_value } @@ -48,8 +51,11 @@ let fallback_zap_amount = 1000 if let loaded = UserDefaults.standard.string(forKey: self.key), let val = T.init(from: loaded) { self.value = val } else if let loaded = UserDefaults.standard.string(forKey: key), let val = T.init(from: loaded) { - // try to load from deprecated non-pubkey-keyed setting + // If pubkey-scoped setting does not exist but the deprecated non-pubkey-scoped setting does, + // migrate the deprecated setting into the pubkey-scoped one and delete the deprecated one. self.value = val + UserDefaults.standard.set(val.to_string(), forKey: self.key) + UserDefaults.standard.removeObject(forKey: key) } else { self.value = default_value } From 5aa0d6c3e14f3e00ae1b9469704ac2c34354e1d5 Mon Sep 17 00:00:00 2001 From: William Casarin Date: Sun, 14 May 2023 20:49:56 -0700 Subject: [PATCH 35/51] settings: add donation_percent to settings This will be used in damus donations splits --- damus/Models/UserSettingsStore.swift | 3 +++ 1 file changed, 3 insertions(+) diff --git a/damus/Models/UserSettingsStore.swift b/damus/Models/UserSettingsStore.swift index 08d29593..29962a6a 100644 --- a/damus/Models/UserSettingsStore.swift +++ b/damus/Models/UserSettingsStore.swift @@ -143,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. From 631220fdcb278a985217e19683b939df96a3addb Mon Sep 17 00:00:00 2001 From: William Casarin Date: Sun, 14 May 2023 20:50:27 -0700 Subject: [PATCH 36/51] ui: add support damus ui in WalletView This appears after you've connected your wallet --- damus/ContentView.swift | 2 +- damus/Models/DamusState.swift | 2 +- damus/Models/Mentions.swift | 2 +- damus/Models/WalletModel.swift | 13 ++- damus/Views/SideMenuView.swift | 2 +- damus/Views/Wallet/ConnectWalletView.swift | 2 +- damus/Views/Wallet/WalletView.swift | 126 ++++++++++++++++++++- 7 files changed, 137 insertions(+), 12 deletions(-) diff --git a/damus/ContentView.swift b/damus/ContentView.swift index 5f602493..40672375 100644 --- a/damus/ContentView.swift +++ b/damus/ContentView.swift @@ -147,7 +147,7 @@ struct ContentView: View { func MainContent(damus: DamusState) -> some View { VStack { - NavigationLink(destination: WalletView(model: damus_state!.wallet), isActive: $wallet_open) { + NavigationLink(destination: WalletView(damus_state: damus, model: damus_state!.wallet), isActive: $wallet_open) { EmptyView() } NavigationLink(destination: MaybeProfileView, isActive: $profile_open) { diff --git a/damus/Models/DamusState.swift b/damus/Models/DamusState.swift index 367a8986..2607cd9f 100644 --- a/damus/Models/DamusState.swift +++ b/damus/Models/DamusState.swift @@ -48,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)), wallet: WalletModel()) } + return DamusState.init(pool: RelayPool(), keypair: Keypair(pubkey: "", privkey: ""), likes: EventCounter(our_pubkey: ""), boosts: EventCounter(our_pubkey: ""), contacts: Contacts(our_pubkey: ""), profiles: Profiles(), dms: DirectMessagesModel(our_pubkey: ""), previews: PreviewCache(), zaps: Zaps(our_pubkey: ""), lnurls: LNUrls(), settings: UserSettingsStore(), relay_filters: RelayFilters(our_pubkey: ""), relay_metadata: RelayMetadatas(), drafts: Drafts(), events: EventCache(), bookmarks: BookmarksManager(pubkey: ""), postbox: PostBox(pool: RelayPool()), bootstrap_relays: [], replies: ReplyCounter(our_pubkey: ""), muted_threads: MutedThreadsManager(keypair: Keypair(pubkey: "", privkey: nil)), wallet: WalletModel(settings: UserSettingsStore())) } } diff --git a/damus/Models/Mentions.swift b/damus/Models/Mentions.swift index f06161ce..9216d263 100644 --- a/damus/Models/Mentions.swift +++ b/damus/Models/Mentions.swift @@ -246,7 +246,7 @@ func format_msats_abbrev(_ msats: Int64) -> String { formatter.positiveSuffix = "m" formatter.positivePrefix = "" formatter.minimumFractionDigits = 0 - formatter.maximumFractionDigits = 3 + formatter.maximumFractionDigits = 2 formatter.roundingMode = .down formatter.roundingIncrement = 0.1 formatter.multiplier = 1 diff --git a/damus/Models/WalletModel.swift b/damus/Models/WalletModel.swift index 206dab63..6b348443 100644 --- a/damus/Models/WalletModel.swift +++ b/damus/Models/WalletModel.swift @@ -14,14 +14,15 @@ enum WalletConnectState { } class WalletModel: ObservableObject { - let settings: UserSettingsStore? + var settings: UserSettingsStore private(set) var previous_state: WalletConnectState + @Published private(set) var connect_state: WalletConnectState - init() { - self.connect_state = .none + init(state: WalletConnectState, settings: UserSettingsStore) { + self.connect_state = state self.previous_state = .none - self.settings = nil + self.settings = settings } init(settings: UserSettingsStore) { @@ -42,7 +43,7 @@ class WalletModel: ObservableObject { } func disconnect() { - self.settings?.nostr_wallet_connect = nil + self.settings.nostr_wallet_connect = nil self.connect_state = .none self.previous_state = .none } @@ -52,7 +53,7 @@ class WalletModel: ObservableObject { } func connect(_ nwc: WalletConnectURL) { - self.settings?.nostr_wallet_connect = nwc.to_url().absoluteString + self.settings.nostr_wallet_connect = nwc.to_url().absoluteString notify(.attached_wallet, nwc) self.connect_state = .existing(nwc) self.previous_state = .existing(nwc) diff --git a/damus/Views/SideMenuView.swift b/damus/Views/SideMenuView.swift index 14615564..2d54e808 100644 --- a/damus/Views/SideMenuView.swift +++ b/damus/Views/SideMenuView.swift @@ -48,7 +48,7 @@ struct SideMenuView: View { navLabel(title: NSLocalizedString("Profile", comment: "Sidebar menu label for Profile view."), systemImage: "person") } - NavigationLink(destination: WalletView(model: damus_state.wallet)) { + NavigationLink(destination: WalletView(damus_state: damus_state, model: damus_state.wallet)) { HStack { Image("wallet") .tint(DamusColors.adaptableBlack) diff --git a/damus/Views/Wallet/ConnectWalletView.swift b/damus/Views/Wallet/ConnectWalletView.swift index e3b0e6de..de16540c 100644 --- a/damus/Views/Wallet/ConnectWalletView.swift +++ b/damus/Views/Wallet/ConnectWalletView.swift @@ -99,6 +99,6 @@ struct ConnectWalletView: View { struct ConnectWalletView_Previews: PreviewProvider { static var previews: some View { - ConnectWalletView(model: WalletModel()) + ConnectWalletView(model: WalletModel(settings: UserSettingsStore())) } } diff --git a/damus/Views/Wallet/WalletView.swift b/damus/Views/Wallet/WalletView.swift index b80924ef..c9ad4520 100644 --- a/damus/Views/Wallet/WalletView.swift +++ b/damus/Views/Wallet/WalletView.swift @@ -8,10 +8,22 @@ 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("\(nwc.relay.id)") if let lud16 = nwc.lud16 { @@ -21,10 +33,119 @@ struct WalletView: View { BigButton("Disconnect Wallet") { self.model.disconnect() } + } + .navigationTitle("Wallet") + .navigationBarTitleDisplayMode(.large) .padding() } + func donation_binding() -> Binding { + return Binding(get: { + return Double(model.settings.donation_percent) + }, set: { v in + model.settings.donation_percent = Int(v) + }) + } + + static let min_donation: Double = 0.0 + static let max_donation: Double = 100.0 + + var percent: Double { + Double(model.settings.donation_percent) / 100.0 + } + + var tip_msats: String { + let msats = Int64(percent * Double(model.settings.default_zap_amount * 1000)) + let s = format_msats_abbrev(msats) + return s.split(separator: ".").first.map({ x in String(x) }) ?? 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") + .font(.title.bold()) + .foregroundColor(.white) + } + + Text("Help build the future of decentralized communication on the web.") + .fixedSize(horizontal: false, vertical: true) + .foregroundColor(.white) + + Text("An additional percentage of each zap will be sent 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))%") + .font(.title.bold()) + .foregroundColor(.white) + } + + 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: 100) + } + + Text("Zap") + .foregroundColor(.white) + } + Spacer() + + Text("+") + .font(.title) + .foregroundColor(.white) + Spacer() + + VStack { + HStack { + Text("\(Image("zap.fill")) \(tip_msats)") + .font(.title) + .foregroundColor(percent == 0 ? .gray : Color.yellow) + .frame(width: 100) + } + Text("Donation") + .foregroundColor(.white) + } + Spacer() + } + + EventProfile(damus_state: damus_state, pubkey: damus_state.pubkey, profile: damus_state.profiles.lookup(id: damus_state.pubkey), size: .small) + + /* + Slider(value: donation_binding(), + in: WalletView.min...WalletView.max, + step: 1, + minimumValueLabel: { Text("\(WalletView.min)") }, + maximumValueLabel: { Text("\(WalletView.max)") }, + label: { Text("label") } + ) + */ + } + .padding(25) + } + .frame(height: 370) + } + var body: some View { switch model.connect_state { case .new: @@ -37,8 +158,11 @@ struct WalletView: View { } } +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(model: WalletModel()) + WalletView(damus_state: tds, model: WalletModel(state: .existing(test_wallet_connect_url), settings: tds.settings)) } } From a6745af5199913aa4677d38fcd0f3e55509fa68f Mon Sep 17 00:00:00 2001 From: William Casarin Date: Mon, 15 May 2023 09:40:48 -0700 Subject: [PATCH 37/51] Implement damus zap split donations using NWC --- damus-c/hex.h | 2 +- damus/Components/ZapButton.swift | 18 ++++++++++++------ damus/Models/HomeModel.swift | 6 +++++- damus/Nostr/NostrEvent.swift | 2 +- damus/Util/InsertSort.swift | 1 + damus/Util/PostBox.swift | 28 +++++++++++++++++++++++++--- damus/Util/WalletConnect.swift | 26 +++++++++++++++++++++----- damus/Util/Zap.swift | 7 +++---- damus/Util/Zaps.swift | 2 +- damus/Views/Wallet/WalletView.swift | 2 +- 10 files changed, 71 insertions(+), 23 deletions(-) diff --git a/damus-c/hex.h b/damus-c/hex.h index 60d26cf5..117f6a3a 100644 --- a/damus-c/hex.h +++ b/damus-c/hex.h @@ -26,7 +26,7 @@ bool hex_decode(const char *str, size_t slen, void *buf, size_t bufsize); /** * hex_encode - Create a nul-terminated hex string * @buf: the buffer to read the data from - * @bufsize: the length of @buf + * @bufsize: the length of buf * @dest: the string to fill * @destsize: the max size of the string * diff --git a/damus/Components/ZapButton.swift b/damus/Components/ZapButton.swift index 1fd4002d..909fa473 100644 --- a/damus/Components/ZapButton.swift +++ b/damus/Components/ZapButton.swift @@ -208,8 +208,7 @@ func send_zap(damus_state: DamusState, event: NostrEvent, lnurl: String, is_cust return } - let zap_amount = amount_sats ?? damus_state.settings.default_zap_amount - let amount_msat = Int64(zap_amount) * 1000 + 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 @@ -239,7 +238,7 @@ func send_zap(damus_state: DamusState, event: NostrEvent, lnurl: String, is_cust damus_state.lnurls.endpoints[target.pubkey] = payreq } - 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) @@ -259,9 +258,16 @@ func send_zap(damus_state: DamusState, event: NostrEvent, lnurl: String, is_cust return } - guard let nwc_req = nwc_pay(url: nwc_state.url, pool: damus_state.pool, post: damus_state.postbox, invoice: inv), - case .nwc(let pzap_state) = pending_zap_state - else { + let nwc_req = nwc_pay(url: nwc_state.url, pool: damus_state.pool, post: damus_state.postbox, invoice: inv, on_flush: .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) + } + + })) + + guard let nwc_req, case .nwc(let pzap_state) = pending_zap_state else { return } diff --git a/damus/Models/HomeModel.swift b/damus/Models/HomeModel.swift index 766dc967..0d693883 100644 --- a/damus/Models/HomeModel.swift +++ b/damus/Models/HomeModel.swift @@ -154,10 +154,14 @@ class HomeModel: ObservableObject { } if resp.response.error == nil { - nwc_success(zapcache: self.damus_state.zaps, evcache: self.damus_state.events, resp: resp) + } + + guard let err = resp.response.error else { + nwc_success(state: self.damus_state, resp: resp) return } + print("nwc error: \(err)") nwc_error(zapcache: self.damus_state.zaps, evcache: self.damus_state.events, resp: resp) } } diff --git a/damus/Nostr/NostrEvent.swift b/damus/Nostr/NostrEvent.swift index 861d74cc..c807d239 100644 --- a/damus/Nostr/NostrEvent.swift +++ b/damus/Nostr/NostrEvent.swift @@ -601,7 +601,7 @@ enum MakeZapRequest { var private_inner_request: ZapRequest { switch self { - case .priv(let _, let pzr): + case .priv(_, let pzr): return pzr.req case .normal(let zr): return zr diff --git a/damus/Util/InsertSort.swift b/damus/Util/InsertSort.swift index 29bc62c0..b6249c4e 100644 --- a/damus/Util/InsertSort.swift +++ b/damus/Util/InsertSort.swift @@ -14,6 +14,7 @@ func insert_uniq_sorted_zap(zaps: inout [Zapping], new_zap: Zapping, cmp: (Zappi 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 } diff --git a/damus/Util/PostBox.swift b/damus/Util/PostBox.swift index e8c1a62c..d2c1758e 100644 --- a/damus/Util/PostBox.swift +++ b/damus/Util/PostBox.swift @@ -22,16 +22,25 @@ 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, flush_after: Date? = nil) { + 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) } @@ -109,6 +118,19 @@ class PostBox { guard let ev = self.events[event_id] else { return false } + + 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 @@ -132,7 +154,7 @@ class PostBox { } } - func send(_ event: NostrEvent, to: [String]? = nil, skip_ephemeral: Bool = true, delay: TimeInterval? = nil) { + 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 @@ -140,7 +162,7 @@ class PostBox { let remaining = to ?? pool.our_descriptors.map { $0.url.id } 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) + let posted_ev = PostedEvent(event: event, remaining: remaining, skip_ephemeral: skip_ephemeral, flush_after: after, on_flush: on_flush) events[event.id] = posted_ev diff --git a/damus/Util/WalletConnect.swift b/damus/Util/WalletConnect.swift index 8d9241a6..3ce5ffbb 100644 --- a/damus/Util/WalletConnect.swift +++ b/damus/Util/WalletConnect.swift @@ -182,7 +182,8 @@ func subscribe_to_nwc(url: WalletConnectURL, pool: RelayPool) { pool.send(.subscribe(sub), to: [url.relay.id]) } -func nwc_pay(url: WalletConnectURL, pool: RelayPool, post: PostBox, invoice: String) -> NostrEvent? { +@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 @@ -190,14 +191,14 @@ func nwc_pay(url: WalletConnectURL, pool: RelayPool, post: PostBox, invoice: Str 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: 5.0) + post.send(ev, to: [url.relay.id], skip_ephemeral: false, delay: delay, on_flush: on_flush) return ev } -func nwc_success(zapcache: Zaps, evcache: EventCache, resp: FullWalletResponse) { +func nwc_success(state: DamusState, resp: FullWalletResponse) { // find the pending zap and mark it as pending-confirmed - for kv in zapcache.our_zaps { + for kv in state.zaps.our_zaps { let zaps = kv.value for zap in zaps { @@ -211,14 +212,29 @@ func nwc_success(zapcache: Zaps, evcache: EventCache, resp: FullWalletResponse) if nwc_state.update_state(state: .confirmed) { // notify the zaps model of an update so it can mark them as paid - evcache.get_cache_data(pzap.target.id).zaps_model.objectWillChange.send() + 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 + } + + 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 { diff --git a/damus/Util/Zap.swift b/damus/Util/Zap.swift index 7e7df445..f8c6291b 100644 --- a/damus/Util/Zap.swift +++ b/damus/Util/Zap.swift @@ -440,15 +440,14 @@ 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 zappable && zap_type != .non_zap, let json = encode_json(zapreq) { print("zapreq json: \(json)") @@ -489,7 +488,7 @@ func fetch_zap_invoice(_ payreq: LNUrlPayRequest, zapreq: NostrEvent, sats: Int, // make sure it's the correct amount guard let bolt11 = decode_bolt11(result.pr), - .specific(amount) == bolt11.amount + .specific(msats) == bolt11.amount else { return nil } diff --git a/damus/Util/Zaps.swift b/damus/Util/Zaps.swift index 894bf22a..f34cfdc9 100644 --- a/damus/Util/Zaps.swift +++ b/damus/Util/Zaps.swift @@ -22,7 +22,7 @@ class Zaps { self.event_counts = [:] self.event_totals = [:] } - + func remove_zap(reqid: String) -> Zapping? { var res: Zapping? = nil for kv in our_zaps { diff --git a/damus/Views/Wallet/WalletView.swift b/damus/Views/Wallet/WalletView.swift index c9ad4520..b8b90537 100644 --- a/damus/Views/Wallet/WalletView.swift +++ b/damus/Views/Wallet/WalletView.swift @@ -123,7 +123,7 @@ struct WalletView: View { .foregroundColor(percent == 0 ? .gray : Color.yellow) .frame(width: 100) } - Text("Donation") + Text("💜") .foregroundColor(.white) } Spacer() From 51cd34c9c25bfbc2d16bc02e84704b2f837318ff Mon Sep 17 00:00:00 2001 From: William Casarin Date: Mon, 15 May 2023 09:59:10 -0700 Subject: [PATCH 38/51] c: move parse_digit to remove warning --- damus-c/cursor.h | 15 --------------- damus-c/damus.c | 16 ++++++++++++++++ 2 files changed, 16 insertions(+), 15 deletions(-) diff --git a/damus-c/cursor.h b/damus-c/cursor.h index 36907912..0deed48c 100644 --- a/damus-c/cursor.h +++ b/damus-c/cursor.h @@ -110,21 +110,6 @@ static inline int peek_char(struct cursor *cur, int ind) { return *(cur->p + ind); } -static int parse_digit(struct cursor *cur, int *digit) { - int c; - if ((c = peek_char(cur, 0)) == -1) - return 0; - - c -= '0'; - - if (c >= 0 && c <= 9) { - *digit = c; - cur->p++; - return 1; - } - return 0; -} - static inline int pull_byte(struct cursor *cur, u8 *byte) { if (cur->p >= cur->end) diff --git a/damus-c/damus.c b/damus-c/damus.c index 92ff5da9..8c6c60bb 100644 --- a/damus-c/damus.c +++ b/damus-c/damus.c @@ -12,6 +12,22 @@ #include #include +static int parse_digit(struct cursor *cur, int *digit) { + int c; + if ((c = peek_char(cur, 0)) == -1) + return 0; + + c -= '0'; + + if (c >= 0 && c <= 9) { + *digit = c; + cur->p++; + return 1; + } + return 0; +} + + static int parse_mention_index(struct cursor *cur, struct block *block) { int d1, d2, d3, ind; const u8 *start = cur->p; From af912b1a0e5f76e950d9af04db263af61c602df3 Mon Sep 17 00:00:00 2001 From: William Casarin Date: Mon, 15 May 2023 09:59:22 -0700 Subject: [PATCH 39/51] v1.5-1 --- damus.xcodeproj/project.pbxproj | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/damus.xcodeproj/project.pbxproj b/damus.xcodeproj/project.pbxproj index 418379a5..0b6857ac 100644 --- a/damus.xcodeproj/project.pbxproj +++ b/damus.xcodeproj/project.pbxproj @@ -2128,7 +2128,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; @@ -2175,7 +2175,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; From 8097cfdfb8e6fe328e0a2683ec24b6d31edd7dc9 Mon Sep 17 00:00:00 2001 From: William Casarin Date: Mon, 15 May 2023 09:59:43 -0700 Subject: [PATCH 40/51] Include donation_amount on profile --- damus/Nostr/Nostr.swift | 18 ++++++++++++++++-- damus/Views/Profile/ProfilePicView.swift | 2 +- damus/Views/Profile/ProfileView.swift | 2 +- damus/Views/SaveKeysView.swift | 2 +- damus/Views/Wallet/WalletView.swift | 12 ++++++++++++ 5 files changed, 31 insertions(+), 5 deletions(-) diff --git a/damus/Nostr/Nostr.swift b/damus/Nostr/Nostr.swift index bcb7d775..284a3e88 100644 --- a/damus/Nostr/Nostr.swift +++ b/damus/Nostr/Nostr.swift @@ -10,7 +10,7 @@ import Foundation class Profile: Codable { var value: [String: AnyCodable] - init (name: String?, display_name: String?, about: String?, picture: String?, banner: String?, website: String?, lud06: String?, lud16: String?, nip05: String?) { + init (name: String?, display_name: String?, about: String?, picture: String?, banner: String?, website: String?, lud06: String?, lud16: String?, nip05: String?, damus_donation: Int?) { self.value = [:] self.name = name self.display_name = display_name @@ -21,12 +21,17 @@ class Profile: Codable { self.lud06 = lud06 self.lud16 = lud16 self.nip05 = nip05 + self.damus_donation = damus_donation } private func str(_ str: String) -> String? { return get_val(str) } + private func int(_ key: String) -> Int? { + return get_val(key) + } + private func get_val(_ v: String) -> T? { guard let val = self.value[v] else{ return nil @@ -52,6 +57,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) } @@ -77,6 +86,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) } @@ -180,7 +194,7 @@ class Profile: Codable { } func make_test_profile() -> Profile { - return Profile(name: "jb55", display_name: "Will", about: "Its a me", picture: "https://cdn.jb55.com/img/red-me.jpg", banner: "https://pbs.twimg.com/profile_banners/9918032/1531711830/600x200", website: "jb55.com", lud06: "jb55@jb55.com", lud16: nil, nip05: "jb55@jb55.com") + return Profile(name: "jb55", display_name: "Will", about: "Its a me", picture: "https://cdn.jb55.com/img/red-me.jpg", banner: "https://pbs.twimg.com/profile_banners/9918032/1531711830/600x200", website: "jb55.com", lud06: "jb55@jb55.com", lud16: nil, nip05: "jb55@jb55.com", damus_donation: 1) } func make_ln_url(_ str: String?) -> URL? { diff --git a/damus/Views/Profile/ProfilePicView.swift b/damus/Views/Profile/ProfilePicView.swift index 63b1e767..1b340d7d 100644 --- a/damus/Views/Profile/ProfilePicView.swift +++ b/damus/Views/Profile/ProfilePicView.swift @@ -177,7 +177,7 @@ func get_profile_url(picture: String?, pubkey: String, profiles: Profiles) -> UR func make_preview_profiles(_ pubkey: String) -> Profiles { let profiles = Profiles() let picture = "http://cdn.jb55.com/img/red-me.jpg" - let profile = Profile(name: "jb55", display_name: "William Casarin", about: "It's me", picture: picture, banner: "", website: "https://jb55.com", lud06: nil, lud16: nil, nip05: "jb55.com") + let profile = Profile(name: "jb55", display_name: "William Casarin", about: "It's me", picture: picture, banner: "", website: "https://jb55.com", lud06: nil, lud16: nil, nip05: "jb55.com", damus_donation: nil) let ts_profile = TimestampedProfile(profile: profile, timestamp: 0, event: test_event) profiles.add(id: pubkey, profile: ts_profile) return profiles diff --git a/damus/Views/Profile/ProfileView.swift b/damus/Views/Profile/ProfileView.swift index e56e4a54..ab89278e 100644 --- a/damus/Views/Profile/ProfileView.swift +++ b/damus/Views/Profile/ProfileView.swift @@ -497,7 +497,7 @@ func test_damus_state() -> DamusState { let pubkey = "3efdaebb1d8923ebd99c9e7ace3b4194ab45512e2be79c1b7d68d9243e0d2681" let damus = DamusState.empty - let prof = Profile(name: "damus", display_name: "damus", about: "iOS app!", picture: "https://damus.io/img/logo.png", banner: "", website: "https://damus.io", lud06: nil, lud16: "jb55@sendsats.lol", nip05: "damus.io") + let prof = Profile(name: "damus", display_name: "damus", about: "iOS app!", picture: "https://damus.io/img/logo.png", banner: "", website: "https://damus.io", lud06: nil, lud16: "jb55@sendsats.lol", nip05: "damus.io", damus_donation: nil) let tsprof = TimestampedProfile(profile: prof, timestamp: 0, event: test_event) damus.profiles.add(id: pubkey, profile: tsprof) return damus diff --git a/damus/Views/SaveKeysView.swift b/damus/Views/SaveKeysView.swift index 4090e641..b0c0c5a1 100644 --- a/damus/Views/SaveKeysView.swift +++ b/damus/Views/SaveKeysView.swift @@ -224,5 +224,5 @@ struct SaveKeysView_Previews: PreviewProvider { } func create_account_to_metadata(_ model: CreateAccountModel) -> Profile { - return Profile(name: model.nick_name, display_name: model.real_name, about: model.about, picture: model.profile_image, banner: nil, website: nil, lud06: nil, lud16: nil, nip05: nil) + return Profile(name: model.nick_name, display_name: model.real_name, about: model.about, picture: model.profile_image, banner: nil, website: nil, lud06: nil, lud16: nil, nip05: nil, damus_donation: nil) } diff --git a/damus/Views/Wallet/WalletView.swift b/damus/Views/Wallet/WalletView.swift index b8b90537..47de1fe9 100644 --- a/damus/Views/Wallet/WalletView.swift +++ b/damus/Views/Wallet/WalletView.swift @@ -154,6 +154,18 @@ struct WalletView: View { ConnectWalletView(model: model) case .existing(let nwc): MainWalletView(nwc: nwc) + .onDisappear { + guard let keypair = damus_state.keypair.to_full(), + let profile = damus_state.profiles.lookup(id: damus_state.pubkey), + profile.damus_donation != settings.donation_percent + else { + return + } + + profile.damus_donation = settings.donation_percent + let meta = make_metadata_event(keypair: keypair, metadata: profile) + damus_state.postbox.send(meta) + } } } } From bffa42a13a5e799e3083bdb1834fd50657c0448e Mon Sep 17 00:00:00 2001 From: William Casarin Date: Mon, 15 May 2023 11:57:37 -0700 Subject: [PATCH 41/51] Supporter Badges --- damus.xcodeproj/project.pbxproj | 8 ++ .../Gradients/GoldSupportGradient.swift | 29 ++++++++ damus/Components/SupporterBadge.swift | 73 +++++++++++++++++++ damus/ContentView.swift | 2 +- damus/Models/HomeModel.swift | 4 +- damus/Models/Mentions.swift | 2 +- damus/Models/WalletModel.swift | 3 + damus/Nostr/Profiles.swift | 8 +- damus/Views/Posting/UserSearch.swift | 2 +- damus/Views/Profile/EventProfileName.swift | 16 ++++ damus/Views/Profile/ProfileName.swift | 16 ++++ damus/Views/Profile/ProfileView.swift | 3 + damus/Views/SearchResultsView.swift | 2 +- damus/Views/Wallet/WalletView.swift | 38 ++++++++-- 14 files changed, 194 insertions(+), 12 deletions(-) create mode 100644 damus/Components/Gradients/GoldSupportGradient.swift create mode 100644 damus/Components/SupporterBadge.swift diff --git a/damus.xcodeproj/project.pbxproj b/damus.xcodeproj/project.pbxproj index 0b6857ac..61f9627a 100644 --- a/damus.xcodeproj/project.pbxproj +++ b/damus.xcodeproj/project.pbxproj @@ -53,6 +53,8 @@ 4C216F34286F5ACD00040376 /* DMView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C216F33286F5ACD00040376 /* DMView.swift */; }; 4C216F362870A9A700040376 /* InputDismissKeyboard.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C216F352870A9A700040376 /* InputDismissKeyboard.swift */; }; 4C216F382871EDE300040376 /* DirectMessageModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C216F372871EDE300040376 /* DirectMessageModel.swift */; }; + 4C2859602A12A2BE004746F7 /* SupporterBadge.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C28595F2A12A2BE004746F7 /* SupporterBadge.swift */; }; + 4C2859622A12A7F0004746F7 /* GoldSupportGradient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C2859612A12A7F0004746F7 /* GoldSupportGradient.swift */; }; 4C285C8228385570008A31F1 /* CarouselView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C285C8128385570008A31F1 /* CarouselView.swift */; }; 4C285C8428385690008A31F1 /* CreateAccountView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C285C8328385690008A31F1 /* CreateAccountView.swift */; }; 4C285C86283892E7008A31F1 /* CreateAccountModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C285C85283892E7008A31F1 /* CreateAccountModel.swift */; }; @@ -444,6 +446,8 @@ 4C216F33286F5ACD00040376 /* DMView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DMView.swift; sourceTree = ""; }; 4C216F352870A9A700040376 /* InputDismissKeyboard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InputDismissKeyboard.swift; sourceTree = ""; }; 4C216F372871EDE300040376 /* DirectMessageModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DirectMessageModel.swift; sourceTree = ""; }; + 4C28595F2A12A2BE004746F7 /* SupporterBadge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SupporterBadge.swift; sourceTree = ""; }; + 4C2859612A12A7F0004746F7 /* GoldSupportGradient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GoldSupportGradient.swift; sourceTree = ""; }; 4C285C8128385570008A31F1 /* CarouselView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CarouselView.swift; sourceTree = ""; }; 4C285C8328385690008A31F1 /* CreateAccountView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreateAccountView.swift; sourceTree = ""; }; 4C285C85283892E7008A31F1 /* CreateAccountModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreateAccountModel.swift; sourceTree = ""; }; @@ -1041,6 +1045,7 @@ children = ( 4C7D09712A0AEF5E00943473 /* DamusGradient.swift */, 4C7D09732A0AEF9000943473 /* AlbyGradient.swift */, + 4C2859612A12A7F0004746F7 /* GoldSupportGradient.swift */, ); path = Gradients; sourceTree = ""; @@ -1218,6 +1223,7 @@ 4CE4F0F729DB7399005914DB /* ThiccDivider.swift */, 4C1A9A2229DDDB8100516EAC /* IconLabel.swift */, 4C8D00C929DF80350036AF10 /* TruncatedText.swift */, + 4C28595F2A12A2BE004746F7 /* SupporterBadge.swift */, ); path = Components; sourceTree = ""; @@ -1730,6 +1736,7 @@ 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 */, @@ -1837,6 +1844,7 @@ 3169CAED294FCCFC00EE4006 /* Constants.swift in Sources */, 4CB9D4A72992D02B00A9A7E4 /* ProfileNameView.swift in Sources */, 4CE4F0F429D779B5005914DB /* PostBox.swift in Sources */, + 4C2859622A12A7F0004746F7 /* GoldSupportGradient.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/damus/Components/Gradients/GoldSupportGradient.swift b/damus/Components/Gradients/GoldSupportGradient.swift new file mode 100644 index 00000000..3e255cd1 --- /dev/null +++ b/damus/Components/Gradients/GoldSupportGradient.swift @@ -0,0 +1,29 @@ +// +// GoldSupportGradient.swift +// damus +// +// Created by William Casarin on 2023-05-15. +// + +import SwiftUI + +fileprivate let gold_grad_c1 = hex_col(r: 226, g: 168, b: 0) +fileprivate let gold_grad_c2 = hex_col(r: 249, g: 243, b: 100) + +fileprivate let gold_grad = [gold_grad_c2, gold_grad_c1] + +let GoldGradient: LinearGradient = + LinearGradient(colors: gold_grad, startPoint: .bottomLeading, endPoint: .topTrailing) + +struct GoldGradientView: View { + var body: some View { + GoldGradient + .edgesIgnoringSafeArea([.top,.bottom]) + } +} + +struct GoldGradientView_Previews: PreviewProvider { + static var previews: some View { + GoldGradientView() + } +} diff --git a/damus/Components/SupporterBadge.swift b/damus/Components/SupporterBadge.swift new file mode 100644 index 00000000..f6241bec --- /dev/null +++ b/damus/Components/SupporterBadge.swift @@ -0,0 +1,73 @@ +// +// SupporterBadge.swift +// damus +// +// Created by William Casarin on 2023-05-15. +// + +import SwiftUI + +struct SupporterBadge: View { + let percent: Int + + let size: CGFloat = 17 + + var body: some View { + if percent < 100 { + Image("star.fill") + .resizable() + .frame(width:size, height:size) + .foregroundColor(support_level_color(percent)) + } else { + Image("star.fill") + .resizable() + .frame(width:size, height:size) + .foregroundStyle(GoldGradient) + } + } +} + +func support_level_color(_ percent: Int) -> Color { + if percent == 0 { + return .gray + } + + let percent_f = Double(percent) / 100.0 + let cutoff = 0.5 + let h = cutoff + (percent_f * cutoff); // Hue (note 0.2 = Green, see huge chart below) + let s = 0.9; // Saturation + let b = 0.9; // Brightness + + return Color(hue: h, saturation: s, brightness: b) +} + +struct SupporterBadge_Previews: PreviewProvider { + static func Level(_ p: Int) -> some View { + HStack(alignment: .center) { + SupporterBadge(percent: p) + .frame(width: 50) + Text("\(p)") + .frame(width: 50) + } + } + + static var previews: some View { + VStack(spacing: 0) { + VStack(spacing: 0) { + Level(1) + Level(10) + Level(20) + Level(30) + Level(40) + Level(50) + } + Level(60) + Level(70) + Level(80) + Level(90) + Level(100) + } + } +} + + diff --git a/damus/ContentView.swift b/damus/ContentView.swift index 40672375..42c078ad 100644 --- a/damus/ContentView.swift +++ b/damus/ContentView.swift @@ -397,7 +397,7 @@ struct ContentView: View { 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) } } diff --git a/damus/Models/HomeModel.swift b/damus/Models/HomeModel.swift index 0d693883..f62a2f22 100644 --- a/damus/Models/HomeModel.swift +++ b/damus/Models/HomeModel.swift @@ -735,7 +735,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 } @@ -752,7 +752,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)) diff --git a/damus/Models/Mentions.swift b/damus/Models/Mentions.swift index 9216d263..f06161ce 100644 --- a/damus/Models/Mentions.swift +++ b/damus/Models/Mentions.swift @@ -246,7 +246,7 @@ func format_msats_abbrev(_ msats: Int64) -> String { formatter.positiveSuffix = "m" formatter.positivePrefix = "" formatter.minimumFractionDigits = 0 - formatter.maximumFractionDigits = 2 + formatter.maximumFractionDigits = 3 formatter.roundingMode = .down formatter.roundingIncrement = 0.1 formatter.multiplier = 1 diff --git a/damus/Models/WalletModel.swift b/damus/Models/WalletModel.swift index 6b348443..db6797be 100644 --- a/damus/Models/WalletModel.swift +++ b/damus/Models/WalletModel.swift @@ -16,6 +16,7 @@ enum WalletConnectState { class WalletModel: ObservableObject { var settings: UserSettingsStore private(set) var previous_state: WalletConnectState + var inital_percent: Int @Published private(set) var connect_state: WalletConnectState @@ -23,6 +24,7 @@ class WalletModel: ObservableObject { self.connect_state = state self.previous_state = .none self.settings = settings + self.inital_percent = settings.donation_percent } init(settings: UserSettingsStore) { @@ -35,6 +37,7 @@ class WalletModel: ObservableObject { self.previous_state = .none self.connect_state = .none } + self.inital_percent = settings.donation_percent } func cancel() { diff --git a/damus/Nostr/Profiles.swift b/damus/Nostr/Profiles.swift index cf30d1fa..62a776ef 100644 --- a/damus/Nostr/Profiles.swift +++ b/damus/Nostr/Profiles.swift @@ -17,7 +17,7 @@ class Profiles { qos: .userInteractive, attributes: .concurrent) - var profiles: [String: TimestampedProfile] = [:] + private var profiles: [String: TimestampedProfile] = [:] var validated: [String: NIP05] = [:] var nip05_pubkey: [String: String] = [:] var zappers: [String: String] = [:] @@ -26,6 +26,12 @@ class Profiles { return validated[pk] } + func enumerated() -> EnumeratedSequence<[String: TimestampedProfile]> { + return queue.sync { + return profiles.enumerated() + } + } + func lookup_zapper(pubkey: String) -> String? { if let zapper = zappers[pubkey] { return zapper diff --git a/damus/Views/Posting/UserSearch.swift b/damus/Views/Posting/UserSearch.swift index 74a91d95..67c6b8ba 100644 --- a/damus/Views/Posting/UserSearch.swift +++ b/damus/Views/Posting/UserSearch.swift @@ -140,7 +140,7 @@ func search_users_for_autocomplete(profiles: Profiles, tags: [[String]], search } // search profile cache as well - for tup in profiles.profiles.enumerated() { + for tup in profiles.enumerated() { let pk = tup.element.key let prof = tup.element.value.profile diff --git a/damus/Views/Profile/EventProfileName.swift b/damus/Views/Profile/EventProfileName.swift index 38c08f44..0527fdeb 100644 --- a/damus/Views/Profile/EventProfileName.swift +++ b/damus/Views/Profile/EventProfileName.swift @@ -15,6 +15,7 @@ struct EventProfileName: View { @State var display_name: DisplayName? @State var nip05: NIP05? + @State var donation: Int? let size: EventViewKind @@ -23,6 +24,7 @@ struct EventProfileName: View { self.pubkey = pubkey self.profile = profile self.size = size + self._donation = State(wrappedValue: profile?.damus_donation) } var friend_type: FriendType? { @@ -45,6 +47,15 @@ struct EventProfileName: View { return profile.reactions == false } + var supporter: Int? { + guard let donation, donation > 0 + else { + return nil + } + + return donation + } + var body: some View { HStack(spacing: 2) { switch current_display_name { @@ -73,6 +84,10 @@ struct EventProfileName: View { Image("zap-hashtag") .frame(width: 14, height: 14) } + + if let supporter { + SupporterBadge(percent: supporter) + } } .onReceive(handle_notify(.profile_updated)) { notif in let update = notif.object as! ProfileUpdate @@ -81,6 +96,7 @@ struct EventProfileName: View { } display_name = Profile.displayName(profile: update.profile, pubkey: pubkey) nip05 = damus_state.profiles.is_validated(pubkey) + donation = update.profile.damus_donation } } } diff --git a/damus/Views/Profile/ProfileName.swift b/damus/Views/Profile/ProfileName.swift index 8ae5b34a..c0b8b204 100644 --- a/damus/Views/Profile/ProfileName.swift +++ b/damus/Views/Profile/ProfileName.swift @@ -34,6 +34,7 @@ struct ProfileName: View { @State var display_name: DisplayName? @State var nip05: NIP05? + @State var donation: Int? init(pubkey: String, profile: Profile?, damus: DamusState, show_nip5_domain: Bool = true) { self.pubkey = pubkey @@ -75,6 +76,17 @@ struct ProfileName: View { return profile.reactions == false } + var supporter: Int? { + guard let profile, + let donation = profile.damus_donation, + donation > 0 + else { + return nil + } + + return donation + } + var body: some View { HStack(spacing: 2) { Text(verbatim: "\(prefix)\(name_choice)") @@ -90,6 +102,9 @@ struct ProfileName: View { Image("zap-hashtag") .frame(width: 14, height: 14) } + if let supporter { + SupporterBadge(percent: supporter) + } } .onReceive(handle_notify(.profile_updated)) { notif in let update = notif.object as! ProfileUpdate @@ -98,6 +113,7 @@ struct ProfileName: View { } display_name = Profile.displayName(profile: update.profile, pubkey: pubkey) nip05 = damus_state.profiles.is_validated(pubkey) + donation = profile?.damus_donation } } } diff --git a/damus/Views/Profile/ProfileView.swift b/damus/Views/Profile/ProfileView.swift index ab89278e..d6fb47d7 100644 --- a/damus/Views/Profile/ProfileView.swift +++ b/damus/Views/Profile/ProfileView.swift @@ -496,6 +496,9 @@ 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", damus_donation: nil) let tsprof = TimestampedProfile(profile: prof, timestamp: 0, event: test_event) diff --git a/damus/Views/SearchResultsView.swift b/damus/Views/SearchResultsView.swift index 13cb09fc..bc18ce39 100644 --- a/damus/Views/SearchResultsView.swift +++ b/damus/Views/SearchResultsView.swift @@ -182,7 +182,7 @@ func make_hashtagable(_ str: String) -> String { func search_profiles(profiles: Profiles, search: String) -> [SearchedUser] { let new = search.lowercased() - return profiles.profiles.enumerated().reduce(into: []) { acc, els in + return profiles.enumerated().reduce(into: []) { acc, els in let pk = els.element.key let prof = els.element.value.profile diff --git a/damus/Views/Wallet/WalletView.swift b/damus/Views/Wallet/WalletView.swift index 47de1fe9..f5bc9eab 100644 --- a/damus/Views/Wallet/WalletView.swift +++ b/damus/Views/Wallet/WalletView.swift @@ -58,7 +58,19 @@ struct WalletView: View { var tip_msats: String { let msats = Int64(percent * Double(model.settings.default_zap_amount * 1000)) let s = format_msats_abbrev(msats) - return s.split(separator: ".").first.map({ x in String(x) }) ?? s + // 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 { @@ -93,6 +105,7 @@ struct WalletView: View { Text("\(Int(binding.wrappedValue))%") .font(.title.bold()) .foregroundColor(.white) + .frame(width: 80) } HStack{ @@ -103,7 +116,7 @@ struct WalletView: View { Text("\(Image("zap.fill")) \(format_msats_abbrev(Int64(model.settings.default_zap_amount) * 1000))") .font(.title) .foregroundColor(percent == 0 ? .gray : .yellow) - .frame(width: 100) + .frame(width: 120) } Text("Zap") @@ -121,9 +134,10 @@ struct WalletView: View { Text("\(Image("zap.fill")) \(tip_msats)") .font(.title) .foregroundColor(percent == 0 ? .gray : Color.yellow) - .frame(width: 100) + .frame(width: 120) } - Text("💜") + + Text(percent == 0 ? "🩶" : "💜") .foregroundColor(.white) } Spacer() @@ -154,16 +168,30 @@ struct WalletView: View { 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), - profile.damus_donation != settings.donation_percent + 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) } } From 2bbbb5db6542bd9f7066bdb424112874301a4011 Mon Sep 17 00:00:00 2001 From: William Casarin Date: Mon, 15 May 2023 12:53:36 -0700 Subject: [PATCH 42/51] Fix a few bugs with donations --- damus/Components/ZapButton.swift | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/damus/Components/ZapButton.swift b/damus/Components/ZapButton.swift index 909fa473..ac43a218 100644 --- a/damus/Components/ZapButton.swift +++ b/damus/Components/ZapButton.swift @@ -258,14 +258,18 @@ func send_zap(damus_state: DamusState, event: NostrEvent, lnurl: String, is_cust return } - let nwc_req = nwc_pay(url: nwc_state.url, pool: damus_state.pool, post: damus_state.postbox, invoice: inv, on_flush: .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) - } - - })) + 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 { return From 0b9a274e67c38a6f27d4309bc0143b4f541c93ff Mon Sep 17 00:00:00 2001 From: William Casarin Date: Mon, 15 May 2023 12:54:09 -0700 Subject: [PATCH 43/51] postbox: change initial retry_after from 2 to 10 seconds --- damus/Util/PostBox.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/damus/Util/PostBox.swift b/damus/Util/PostBox.swift index d2c1758e..be0e1b6a 100644 --- a/damus/Util/PostBox.swift +++ b/damus/Util/PostBox.swift @@ -42,7 +42,7 @@ class PostedEvent { 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) } } } From 1b161fefd099d8df6f57d694a0becc9289c12e4a Mon Sep 17 00:00:00 2001 From: William Casarin Date: Mon, 15 May 2023 12:54:42 -0700 Subject: [PATCH 44/51] nwc: make sure to support nostr+walletconnect scheme not sure why we have 2 schemes --- damus/Util/WalletConnect.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/damus/Util/WalletConnect.swift b/damus/Util/WalletConnect.swift index 3ce5ffbb..c3ffc399 100644 --- a/damus/Util/WalletConnect.swift +++ b/damus/Util/WalletConnect.swift @@ -36,7 +36,8 @@ struct WalletConnectURL: Equatable { } init?(str: String) { - guard let url = URL(string: str), url.scheme == "nostrwalletconnect", + 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, From 47a74257c84c6a3868078e506cd330ff9bd97f82 Mon Sep 17 00:00:00 2001 From: William Casarin Date: Wed, 24 May 2023 14:56:01 -0700 Subject: [PATCH 45/51] nwc debugging --- damus/Components/ZapButton.swift | 3 +++ damus/Models/HomeModel.swift | 15 +++++++-------- damus/Util/PostBox.swift | 5 +++++ damus/Util/WalletConnect.swift | 3 ++- 4 files changed, 17 insertions(+), 9 deletions(-) diff --git a/damus/Components/ZapButton.swift b/damus/Components/ZapButton.swift index ac43a218..31cc50f3 100644 --- a/damus/Components/ZapButton.swift +++ b/damus/Components/ZapButton.swift @@ -272,9 +272,12 @@ func send_zap(damus_state: DamusState, event: NostrEvent, lnurl: String, is_cust 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 } diff --git a/damus/Models/HomeModel.swift b/damus/Models/HomeModel.swift index f62a2f22..d2414764 100644 --- a/damus/Models/HomeModel.swift +++ b/damus/Models/HomeModel.swift @@ -132,11 +132,11 @@ class HomeModel: ObservableObject { case .nwc_request: break case .nwc_response: - handle_nwc_response(ev) + handle_nwc_response(ev, relay: relay_id) } } - func handle_nwc_response(_ ev: NostrEvent) { + 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, @@ -148,25 +148,24 @@ class HomeModel: ObservableObject { // 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") + 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") - } - - if resp.response.error == nil { + 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: \(err)") + 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 } diff --git a/damus/Util/PostBox.swift b/damus/Util/PostBox.swift index be0e1b6a..a7d77634 100644 --- a/damus/Util/PostBox.swift +++ b/damus/Util/PostBox.swift @@ -150,6 +150,11 @@ 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) } } diff --git a/damus/Util/WalletConnect.swift b/damus/Util/WalletConnect.swift index c3ffc399..f54094a4 100644 --- a/damus/Util/WalletConnect.swift +++ b/damus/Util/WalletConnect.swift @@ -180,7 +180,7 @@ func subscribe_to_nwc(url: WalletConnectURL, pool: RelayPool) { filter.limit = 0 let sub = NostrSubscribe(filters: [filter], sub_id: "nwc") - pool.send(.subscribe(sub), to: [url.relay.id]) + pool.send(.subscribe(sub), to: [url.relay.id], skip_ephemeral: false) } @discardableResult @@ -233,6 +233,7 @@ func send_donation_zap(pool: RelayPool, postbox: PostBox, nwc: WalletConnectURL, return } + print("damus-donation donating...") nwc_pay(url: nwc, pool: pool, post: postbox, invoice: invoice, delay: nil) } From d6ecf14b552cb988b0ebbf0f483abba4e0c9174e Mon Sep 17 00:00:00 2001 From: "transifex-integration[bot]" <43880903+transifex-integration[bot]@users.noreply.github.com> Date: Sun, 14 May 2023 13:46:45 +0000 Subject: [PATCH 46/51] Apply translations Closes: #1134 --- damus/Components/SupporterBadge.swift | 2 +- damus/Views/Relays/RelayDetailView.swift | 6 +- damus/Views/Wallet/ConnectWalletView.swift | 12 +-- damus/Views/Wallet/WalletView.swift | 32 +++----- damus/Views/Zaps/CustomizeZapView.swift | 2 +- damus/cs.lproj/Localizable.strings | Bin 83576 -> 86554 bytes damus/de.lproj/Localizable.strings | Bin 85168 -> 88262 bytes damus/el-GR.lproj/Localizable.strings | Bin 85574 -> 88622 bytes .../Localized Contents/en-US.xliff | 71 +++++++++++++++--- .../damus/en-US.lproj/Localizable.strings | Bin 82138 -> 85106 bytes damus/es-419.lproj/Localizable.strings | Bin 85626 -> 84768 bytes damus/es-ES.lproj/Localizable.strings | Bin 85168 -> 87540 bytes damus/es-ES.lproj/Localizable.stringsdict | 18 +++++ damus/fa.lproj/Localizable.strings | Bin 82726 -> 85638 bytes damus/fa.lproj/Localizable.stringsdict | 18 ++--- damus/fr.lproj/Localizable.stringsdict | 18 +++++ damus/hu-HU.lproj/Localizable.strings | Bin 84760 -> 87758 bytes damus/ja.lproj/Localizable.strings | Bin 77236 -> 79976 bytes damus/nl.lproj/Localizable.strings | Bin 84004 -> 87110 bytes damus/pl-PL.lproj/Localizable.strings | Bin 83974 -> 86930 bytes damus/sv-SE.lproj/Localizable.strings | Bin 83236 -> 86354 bytes damus/zh-CN.lproj/Localizable.strings | Bin 74474 -> 77056 bytes damus/zh-HK.lproj/Localizable.strings | Bin 74402 -> 76984 bytes damus/zh-TW.lproj/Localizable.strings | Bin 74368 -> 76950 bytes 24 files changed, 128 insertions(+), 51 deletions(-) diff --git a/damus/Components/SupporterBadge.swift b/damus/Components/SupporterBadge.swift index f6241bec..1af67eee 100644 --- a/damus/Components/SupporterBadge.swift +++ b/damus/Components/SupporterBadge.swift @@ -46,7 +46,7 @@ struct SupporterBadge_Previews: PreviewProvider { HStack(alignment: .center) { SupporterBadge(percent: p) .frame(width: 50) - Text("\(p)") + Text(verbatim: p.formatted()) .frame(width: 50) } } diff --git a/damus/Views/Relays/RelayDetailView.swift b/damus/Views/Relays/RelayDetailView.swift index 5814a539..6a7b6c91 100644 --- a/damus/Views/Relays/RelayDetailView.swift +++ b/damus/Views/Relays/RelayDetailView.swift @@ -24,7 +24,11 @@ struct RelayDetailView: View { } func FieldText(_ str: String?) -> some View { - Text(str ?? "No data available") + if let s = str { + return Text(verbatim: s) + } else { + return Text("No data available", comment: "Text indicating that there is no data available to show for specific metadata about a relay server.") + } } var body: some View { diff --git a/damus/Views/Wallet/ConnectWalletView.swift b/damus/Views/Wallet/ConnectWalletView.swift index de16540c..a926935b 100644 --- a/damus/Views/Wallet/ConnectWalletView.swift +++ b/damus/Views/Wallet/ConnectWalletView.swift @@ -17,7 +17,7 @@ struct ConnectWalletView: View { var body: some View { MainContent - .navigationTitle("Attach a Wallet") + .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 @@ -39,7 +39,7 @@ struct ConnectWalletView: View { func AreYouSure(nwc: WalletConnectURL) -> some View { VStack { - Text("Are you sure you want to attach this wallet?") + 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) @@ -52,11 +52,11 @@ struct ConnectWalletView: View { .foregroundColor(.gray) } - BigButton("Attach") { + BigButton(NSLocalizedString("Attach", comment: "Text for button to attach Nostr Wallet Connect lightning wallet.")) { model.connect(nwc) } - BigButton("Cancel") { + BigButton(NSLocalizedString("Cancel", comment: "Text for button to cancel out of connecting Nostr Wallet Connect lightning ewallet.")) { model.cancel() } } @@ -72,7 +72,7 @@ struct ConnectWalletView: View { openURL(URL(string:"https://nwc.getalby.com/apps/new?c=Damus")!) } - BigButton("Attach Wallet") { + BigButton(NSLocalizedString("Attach Wallet", comment: "Text for button to attach Nostr Wallet Connect lightning wallet.")) { scanning = true } @@ -89,7 +89,7 @@ struct ConnectWalletView: View { case .new(let nwc): AreYouSure(nwc: nwc) case .existing: - Text("Shouldn't happen") + Text(verbatim: "Shouldn't happen") case .none: ConnectWallet } diff --git a/damus/Views/Wallet/WalletView.swift b/damus/Views/Wallet/WalletView.swift index f5bc9eab..21fd6b47 100644 --- a/damus/Views/Wallet/WalletView.swift +++ b/damus/Views/Wallet/WalletView.swift @@ -24,18 +24,18 @@ struct WalletView: View { Spacer() - Text("\(nwc.relay.id)") + Text(verbatim: nwc.relay.id) if let lud16 = nwc.lud16 { - Text("\(lud16)") + Text(verbatim: lud16) } - BigButton("Disconnect Wallet") { + BigButton(NSLocalizedString("Disconnect Wallet", comment: "Text for button to disconnect from Nostr Wallet Connect lightning wallet.")) { self.model.disconnect() } } - .navigationTitle("Wallet") + .navigationTitle(NSLocalizedString("Wallet", comment: "Navigation title for Wallet view")) .navigationBarTitleDisplayMode(.large) .padding() } @@ -83,16 +83,16 @@ struct WalletView: View { Image("logo-nobg") .resizable() .frame(width: 50, height: 50) - Text("Support Damus") + 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.") + 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 ") + 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) @@ -102,7 +102,7 @@ struct WalletView: View { Slider(value: binding, in: WalletView.min_donation...WalletView.max_donation, label: { }) - Text("\(Int(binding.wrappedValue))%") + 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) @@ -119,12 +119,12 @@ struct WalletView: View { .frame(width: 120) } - Text("Zap") + Text("Zap", comment: "Text underneath the number of sats indicating that it's the amount used for zaps.") .foregroundColor(.white) } Spacer() - Text("+") + Text(verbatim: "+") .font(.title) .foregroundColor(.white) Spacer() @@ -137,23 +137,13 @@ struct WalletView: View { .frame(width: 120) } - Text(percent == 0 ? "🩶" : "💜") + 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) - - /* - Slider(value: donation_binding(), - in: WalletView.min...WalletView.max, - step: 1, - minimumValueLabel: { Text("\(WalletView.min)") }, - maximumValueLabel: { Text("\(WalletView.max)") }, - label: { Text("label") } - ) - */ } .padding(25) } diff --git a/damus/Views/Zaps/CustomizeZapView.swift b/damus/Views/Zaps/CustomizeZapView.swift index e06a667f..543465ae 100644 --- a/damus/Views/Zaps/CustomizeZapView.swift +++ b/damus/Views/Zaps/CustomizeZapView.swift @@ -136,7 +136,7 @@ struct CustomizeZapView: View { VStack(alignment: .center, spacing: 0) { TextField("", text: $custom_amount) .placeholder(when: custom_amount.isEmpty, alignment: .center) { - Text(String("0")) + Text(verbatim: 0.formatted()) } .accentColor(.clear) .font(.system(size: 72, weight: .heavy)) diff --git a/damus/cs.lproj/Localizable.strings b/damus/cs.lproj/Localizable.strings index 56327364a60c570ff11c79653c004532faf4eaf9..6d5ca1b0adcdcede21ba93a9755b389c0b09ed9a 100644 GIT binary patch delta 2221 zcmb_eU1%It6u#GFlTEVSwApOUrY+gsW{r^;O=+UoCQWF?*0%XEV5uq6&F&;lcHP}% zcavCusucu5O36V-`f7!O5Q6iNhxVbx2SE@QpL}RReJHfz&yy{sR=;y^COaWjNReS? z?wot?`Oe?{&fNLPbN+&7X(C{k0?oc>hJ_(!L;}x@D2TY2#3(GXVgk>&Na8XfHzCR3 zI}Pb6ymJ(JJ5X1|cM_6uVNm2&U@R|+B8MvrjRnXLVCCzO#W^<5gyiUwK zA)XSoA}V5{A9oazUflfMC0dnimuRKy(c|{+aFfsevg3mqDt+z!WUqY8D(#e&R;EdM zM-$4z0+3DEQv^Qyp=?$710lIKn1KY%0BmqYA&-wK*BFS)W!xD4znQ2p$qGK35qHsbIz7R#Ft{r+onr7f03_sg{9y_BtnyOH+9|Wli_UAeCMP z+E>l?;@&mm>)qqlYr^r#hYL@@r5zeL z!^ddoc98BK-E6&7EgNX+&Sv`KOk=wnkQZ{151_cl#T&e-97+4oV1*2=)$3a5Xs=n_ zuEMHt&b?Xt{{^motqJShvX&}kqy21uPaWO8Uh6T$JWA)gu@L8N;n4P8O%E$Ig$mqo zkfmY$QYAz7{NZmNv9ArivYlKG(x<6#+s5m`Pe)P>6;77!{p_V&s;Zo+lQ$aZ#}2>! za{ByMMg!wD({Q|U&m<0r=3>p$Yq`~N&dt7aICv%>Lh8B}q|2AQU3?JJsQR#&!werx zHy0mVon_`Qu`)@K{h`xkJf~yKC`(N6hJgbX21XX_&a9tNh+JyAdvI6bLenI`xCRQY z({)sb)n4Wx&EB&p$AaP`KB1t76AqCbDa&6xPXiBNpMlntDYKI23 zAw_FCCS!Kx`%AHvV0Tn&X35as7b7pQ;ktz|xz1tt1m4VEOq0x2CL|o>uxfA2dP&^; z<})^q)nFcaZ?uJmpYz%GFE*cKpiK!(rg5pfS&=pwwC+DC{r}l3IEKvDMR3Jxmr*q6 zbkkAHJ-8Q_I||2J^Z!bGT^&10A72U4yCdP{12yukm}V;Lw@wKv{HtdfgA-e}sbdE3 zIIdiTGkA0BgL(8tsHHlu#(5g~aOVzAV4E`0DFFUCU>ES?LDTI)kN&av_Oll1D?Pq^ JreFT)`3Kw?3WNXv delta 273 zcmbQWg!M-c>xMMp$yqvHys8Wi3`z_N47Nb5G&!(dcyfU$%jPU$F`mgER5d2QyU4b= zLBWKF*8ri4!C-QtknrX_oim)94J-oKCg+`1+brc!!93Z(LTK|XcQfwEK2Nwdp9nt1 z#O}zD!;r*Kxw+DnnQ^i}49nzO;arp7g>t|oCjWO4S4?HdV|WX+Kbs*HNEQRNlrq#Y zCNW4eDlmWznBJ(ts5<#UsMz#Zo{U11&joUAUKeI3Gx>s!!e*9|Sz?nfq={_K=-Hq; zxj=|za-NRcWW611o8N5`NtoUs#Hh3Vi!fu2>SP^Xz3H1fL;ZE+p{F0hSJ+;B6vWRt8_D+`+pbVjpe545z<(Ow#QfyoO# z=$mnIU&4urF_|&ONMcy-1BnmngW<*aU`+O~p_#^q#fV{h@?pvRedkhWbu)pOn|trM z=g;^1o$ve3@4L$%xK?ktmOl>C{id9KCA8{2F)Y$zTBPtag(XJCI9i675*a*4#Tc$E z`m^XU@ts5OJl+LrUkK7#kB62&ajTOF8R#jB8BxGB1#=dry@+Xf^cB$_gV9;sIgA!W zp5p7lwu2%j4vG$p_T%nA`q|zwEn#dYFNyd)R#_E3i!~VO~6#LFY#-&Cw2i<*@ zrgy`zDPFeWe}tCaKi@e)X%fXPs^zuZcsgy;J*ggpKc9(;5+H=Om z;rpa`voaF)Pv@&;xDsM>A_KKNauG~uPxD??Y(UoikG}PmabX7PL7(*1mILZ+T#TRd z($Bxtxzgea@~7oe1hMVtcf?~et8_B#(CspU;*r&JTHT1FILvB+1LK_8cx#^=L^%QzFU^kd|8#U&&js4dy)SMc} zVd1S5A53wf zGw{ySjl;pPqnw5=i!4^`X^S34!*u73kX`@ID~C2?afkvd?pjBPOj=rMSHITDhQ18! zvA`AKUG_;9i)+M=^=n@Fsnq>#;vyS{&83{V9yFIOziRmo>p(0cm` z`f|OMYQoL6PQ5A8y<%qzr%9KV-BKcOxo1SwGG~V} Cr1K5{ delta 258 zcmX@Mk#)mN)(wk z%H|6SOL%w<5b78VCMWI_-E5$HiF3MvD5KD3Ez2~v$#qUVn++V9SRg_bo<7`@|7_se zTod|=iQSPQharifabMuDMhvuwn1 znaLA$6*hk>{UJ8lAV6gEgT5fO$p!n^CL8FwY-ZXvEfFX!G}+HoYWoBU#uoL-5B&7D ecR4V&2u@$)z{oY(!Jloi%sq|mAA%UKu>t^exlZQ* diff --git a/damus/el-GR.lproj/Localizable.strings b/damus/el-GR.lproj/Localizable.strings index 749448b90c94529fd59b2b99c68bc8a75b2cf461..ccd6d465d8c605313b8b09036295e3ab46eae570 100644 GIT binary patch delta 2397 zcmcImTTC2P7(T}X3*Ay~yL6!zn6fQ26t~4vNJMBCFW?0OX`)dRY1$@UQXkrK>wrrsrAvXD#u{tlgYf~2FD5?J_DP?J-#N3}-KN$^jG4^L zIsZTBzyIGqS5Ib+O=V6Tup4LWWjPHU%*Vpai)V;MnTPqY(pVqs!PCRKaRopRfD*z! z2~oOL*5bGL2&y)HTAf_sb;&G zl~u4E>;UcxP?~VdeJ$IrXpge(wCa4>DBN~F$EbE)-9Yi-Y&!aDrGBoQ=L~w4QBf!j zur8>JD$|vDN_7-`4XgiRoEvUGAP_b1=fSb_AqHO7i^FET3U=A3Y%-fd6WP1hX%g{+ zw!yf#C`QF)aY<+_j`yOvm&A4K4RJwC;yo+Qi5W4Cl^F!{%Vq3m6hAIv!oX@AQl@c6 zW7otLJeP$jaY>Ac1<>d5oyL~%l-z0TNB2Oz$&jQJ>ng-8h&dQz;IQN+lCOK(xy`uU z@S2528gr@abguqs4Y$%d1wAPeQAGj-=_e%_@Iv7aFpo0Can-;|~0x*SeE{bW|*5aTS z*9wj1=9&yyUzt5n5~j|N9Q*DSxK6V+x>F9)iH<5gE_iigy2t=(%fEhq^U?(4w1>X) zmpHyi^Jz(yz$kp2B;M&B@FRQ92LgK8JTS{6YU{r*_d4j*XgC~|L568dClZ1;y|i8|LD8}3>~kf1)pH*mJ6>Y^**;TF^TXDZ z?KB^;P%yZqYMr%8V?jIJ@E7YVxxCJJKBU?8+g6@ogx~q;A*lhFyM=}V8I6AAKsID@ zLfnh)$;_wY{{V6*OQ@G>-9>bCKD$;ndJx#Du>o*oC8X!E?JieBdO+r$4nI{gXig-l zFFC8i%8~%;PS%13dLQPCoI4?KNnwmKu1HfXy&M`oO**@}p z8E}Rs!t?8AvAvLBjw=L|%sF7lMO-E3`TUSZiH!}K_6#vSSW%f>pem=WRFWkrr_a$2|qDGMy z?)E6xH8p4@x^}B3l>O|1%d(lGw7+wgv2ylGoj%gct%kPxN?u~F?li97nJJYjH+fZX zgOG-1rc*yYOlv=65B}5OlEDot4SuYWa3HZFIp_~U```a2q!MxZ?kI{Oa^o01W;#?d ztjXcRBKjSTJ9SqbFOE&*ahFEThwS>$BRqq&y*u@`Jv@gtAIkqk@C*nX)8E^}%VR6M zdA)@?{>Y&IkxIR#o^Pg`tMxG~ek-8rTV?cl(N_INJGbx%O}_6emBm@5CP=by2*$wYMZ5;zA;b!XDPJV&#Q=gvd<>2%@tugu~e8n7bsKAP&~Q#we@D5as?6A{mf^X_ipy@ zzRk30f`{7V32ZErpXs_yj@!YuS#J9tlgS4{MW#2HGIC8$iBd4v1DY2K)Sn7AIgKG7 zXfDLwVxYa540#OcKz;#3DNwusC<{_q%%C?}@vZ3QcdrBZCvPy~njXi`*f9ClFB#?% zhWzRO`5C1r@B7U&eSra^$mE{C0+SDLaZLWe!#4et0i)dH59S7&bAIoVoi1R{sIy%~ nicwEta$lmsb{1 - - %@ - %@ - No comment provided by engineer. - %@ %@ %@ %@ @@ -83,6 +78,11 @@ Sentence composed of 2 variables to describe how many people are following a use %@. Tip your friend's posts and stack sats with Bitcoin⚡️, the native currency of the internet. Explanation of what can be done by users to earn money. There is a heading that precedes this explanation which is a variable to this string. + + %lld%% + %lld%% + Percentage of additional zap that should be sent to support Damus development. + %lld/%lld %lld/%lld @@ -174,6 +174,11 @@ Sentence composed of 2 variables to describe how many people are following a use Always show images Setting to always show and never blur images + + An additional percentage of each zap will be sent to support Damus development + An additional percentage of each zap will be sent to support Damus development + Text indicating that they can contribute zaps to support Damus development. + Animations Animations @@ -201,6 +206,11 @@ Sentence composed of 2 variables to describe how many people are following a use Are you lost? Text asking the user if they are lost in the app. + + Are you sure you want to attach this wallet? + Are you sure you want to attach this wallet? + Prompt to ask user if they want to attach their Nostr Wallet Connect lightning wallet. + Are you sure you want to delete all of your bookmarks? Are you sure you want to delete all of your bookmarks? @@ -216,6 +226,26 @@ Sentence composed of 2 variables to describe how many people are following a use Are you sure you want to upload this media? Alert message asking if the user wants to upload media. + + Attach + Attach + Text for button to attach Nostr Wallet Connect lightning wallet. + + + Attach Alby Wallet + Attach Alby Wallet + Button to attach an Alby Wallet, a service that provides a Lightning wallet for zapping sats. Alby is the name of the service and should not be translated. + + + Attach Wallet + Attach Wallet + Text for button to attach Nostr Wallet Connect lightning wallet. + + + Attach a Wallet + Attach a Wallet + Navigation title for attaching Nostr Wallet Connect lightning wallet. + Automatically translate notes Automatically translate notes @@ -264,7 +294,8 @@ Sentence composed of 2 variables to describe how many people are following a use Button to cancel the upload. Cancel deleting bookmarks. Cancel deleting the user. - Cancel out of logging out the user. + Cancel out of logging out the user. + Text for button to cancel out of connecting Nostr Wallet Connect lightning ewallet. Choose from Library @@ -291,11 +322,6 @@ Sentence composed of 2 variables to describe how many people are following a use Connect To Relay Label for section for adding a relay server. - - Connect to Alby - Connect to Alby - Button to connect to Alby, a service that provides a Lightning wallet for zapping sats. Alby is the name of the service and should not be translated. - Connected Relays Connected Relays @@ -476,6 +502,11 @@ Sentence composed of 2 variables to describe how many people are following a use Disconnect From Relay Button to disconnect from the relay. + + Disconnect Wallet + Disconnect Wallet + Text for button to disconnect from Nostr Wallet Connect lightning wallet. + Display Name Display Name @@ -581,6 +612,11 @@ Sentence composed of 2 variables to describe how many people are following a use Get API Key with BTC/Lightning Button to navigate to nokyctranslate website to get a translation API key. + + Help build the future of decentralized communication on the web. + Help build the future of decentralized communication on the web. + Text indicating the goal of developing Damus which the user can help with. + Hide Hide @@ -777,6 +813,11 @@ Sentence composed of 2 variables to describe how many people are following a use No Button to cancel out of posting a note after being alerted that it looks like they might be posting a private key. + + No data available + No data available + Text indicating that there is no data available to show for specific metadata about a relay server. + No mute list found, create a new one? This will overwrite any previous mute lists. No mute list found, create a new one? This will overwrite any previous mute lists. @@ -1226,6 +1267,11 @@ Button text to indicate that the zap type is a private zap. Software Label to display relay software. + + Support Damus + Support Damus + Text calling for the user to support Damus through zaps + Supported NIPs Supported NIPs @@ -1414,7 +1460,8 @@ YOU WILL NO LONGER BE ABLE TO LOG INTO DAMUS USING THIS ACCOUNT KEY. Wallet Wallet - Sidebar menu label for Wallet view. + Navigation title for Wallet view + Sidebar menu label for Wallet view. Title for section in zap settings that controls the Lightning wallet selection. diff --git a/damus/en-US.xcloc/Source Contents/damus/en-US.lproj/Localizable.strings b/damus/en-US.xcloc/Source Contents/damus/en-US.lproj/Localizable.strings index 67202776bc44e6a4ad6f6f4d4a3cf808af774631..9b9750543083d749a9bf8bced27b68c11d5559ed 100644 GIT binary patch delta 2002 zcmcIlT})GF82&zJ`B$N()gua}{7C2D(0~CWwtpVbH2Cl z^Spo0_vnK2#dYWOjEfvqaqCO>t-%FPsfT&9vOvY;;vLOu{@ zVrk%*A_9c8rf*YZv^4ga;F|b!hqUk2!%qU?RSAnB&H*H2F-ar?ZYsZ7q!MkiXkrZ{ z0Kq}|;phNvzjz0P99*^>MmyKUh?-A$tlpOP0{Z!{_4L_q_3CN0tgPh@;J&fL=AMY5 z6r*6>gVd))T3rrE_qtA9rvx?!M4G}EH&V~lO?0VaPu+hVn7U8K;WUmb2K(PT4z2>U z!cNI%>81xiHCogAzscv0p{{-}UFt6I0h8v&C>x1)x8#Wy(Wql%XX`3_eGvSD~Kx%op#Pehz$yX9nAUmVGoo?KUph74k? zx|4xYPnu=PTc@ow%Qs`K2+|Iwr=`*wJQyrh-v?xgGbYZUzM*~tb@CV5)107%o9_Hq zw~madaGo@*=|gw&)L5HzS&pN}s&c4Px#=r>eVyqK(@QhaO?1X|&oSCqU(y2~kVi_}|)uWn?2|bQMRBb67)-s#WsN6BOZnK_qzu4316XWKLWV{l8ByH)cod8hk>(zZZ~xL~tlh=1tlQIH&*PW)zw}%Zrh3bCS6f+w=qaj69R~os`g;(7s3IPnCu@aiNX&|Ro&bZS|L68 zfIH7*1s##iGDQwzlM_-!Hmh}AQJJj3!7_Q1w%g`Y>!-zmlx#QPXS}B}-GP@;ZE`~? h$8-x5MzP5{K|GV^`EV`YV8SQ}WC(1}a%1FR0|0BuR4f1h diff --git a/damus/es-419.lproj/Localizable.strings b/damus/es-419.lproj/Localizable.strings index 0f682fb3b3013dfbe36974b88a9db4b857bb432d..24629091a72da496b4abfa9d3f9f1478c1623bf2 100644 GIT binary patch delta 514 zcmZWlO(;ZB6h7CyDPtOcZ)OnkW|%>k#*ng5lq{^s&@6;!#&7eZdE<9wM^?j$Cn=*e zYr4fwLu_PerA+LUjVu*8??seu=icv}ukSnO-1mKBYR7oIE8==nFFuPBCa(nkSe-U- zcU6k1_pL0CCu^MyPaO$JH%wABPW%e#=jhB(pDEo*(<$H~ z{M`kM>O|~mw_|2lX!zSjCXKd+LMy~zo@UarC}FMhI9^QH474b+zH*B;Q|!kRE&VRj zufJofmXGz_R;W@HE9N&5Ybl;5?KqsR;B9N+A@#L}`S?=yDhGT0d6;<-c;+ckfT@mL zwXmDHdEjlMop+)fKN$^ncVnGQWM5H#sV?b~=RZ)AXEHli{``&!tye*fc=89B( z?68I59HzZd{N(VMHJwF7+rcXgCx?T${aVJ#phsl*3k3^P?I=5wqSE(_iH5t>HmAdN zSbz-VAP72Kr8o|^e?}>agD}$iI1Ir!_<_R-GCyTU;Sg2!h{wBMT-J0UJ6CNY;u>X` zAJFdnjW%GB7$hM9F`Ar$dE%9z3Af14!yI*XSf(kDQ)Lm-|HW?C{lUEsu;`26$hTpv z7~R;MP%-tc6K9VpxRtbH^`RoRqgfA@({8L8suZIY<;;x$>%osNdwpTzY7ke;8-MID zOgap>1~J^&8pOtD88uxKg^b*fC&!#!Ndm;_2;F5Z1)tmtV7_R_;)Lhl-lOB}2o{&z zIJ&G#r{FxC#ptF3*Y9h1dPc*|CAZ0aEF{*KQ`0!>y+qcFsizt?9PLs$+iet%yUFAL+`@jCcQ5ZeKoiiIn3S;`~acc*P{Rc diff --git a/damus/es-ES.lproj/Localizable.strings b/damus/es-ES.lproj/Localizable.strings index b3c2cf7f135a21ba31f3a80db390caa392d8ef9d..3819d1ee3c9d0826ed983cd2a1fd061a4c38212a 100644 GIT binary patch delta 4091 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 + sats + + NSStringLocalizedFormatKey + %#@SATS@ + SATS + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + d + one + sat + many + sats + other + sats + + sats_count NSStringLocalizedFormatKey diff --git a/damus/fa.lproj/Localizable.strings b/damus/fa.lproj/Localizable.strings index b2d0e91e69a26d2992c607b95558e8b7244ef4f6..60e6ee9754f8fa4a26d802b878f156853567133f 100644 GIT binary patch delta 2418 zcmcImTTEO<7@pxWE|f0IF1H0*4hv+-cImQQYPx`7E0;}85wyk_so}Q8C9oj7h+@Mf zCOm0k`6I_fCGAU*#2UnWTSw=?6wAP%SBG_onwPzSxwtI(?TOh0GxgHi|Q)~#|FpIJQHUdgx6YM;`18f*i z2MX`dj8XP7s3@ois0lU=?l5M9EJ)rvwqp^jOyZe0X>Xc zUd1~G?j#$hSC(u?s#zPWW)5&$@j5_zS(RF=XI1p8;{~HkbLSYJxF@spo(gW#`%du^ z>Y2)>Ykr|`=W|o>3qw$28rMo=VhYP^qu@(*_oY~uAErOb8I!o-QSJ-E;32ps zu7Uo3Gq0TA$K?<$W0LfUM|ee(@Q7`pF{h}(3l^`8?ey{pu^E~vJqQ$q1n)cpz0L>5ilYXFEXDX@xLLpu9=hqBp)UFTOt$@D z2gi5lm%ZHnzxh$xi0gG<20w}%T&2LLmaHxnS_Ihms%!q&a}Alf?+Eaq{(U;P8{wz6(&+w#)%f;lQz_NWW+%<2)z1a_MoXLrNF4el zluyqDi|NX!Rd31QWy>L}adWiM!kvcg4MULjTNTyyXEcu>S!EF=aCk3TCJal((-{61`((tpWcfd`_dJll?&I zk?0gtO2ZJqHK@)>=Z`CV56>%AWO~NhhBQMaXF~K0jtoMTB(T*;XkrZr_i@y*ta68{ zcAyeyCs4{IYQTLQuY|Z57Pzq9iMfPBAHH7TnhWn?FPf=D$8}8B^+drKHx_rR;Nv>l z?Gum1KqgF;YZdxRI=99{-1|*I`8f2GI5&(}jt{;^`BzkC*YrzhWd@9XOsGL`kSoLlIFbJ@o7cc=4GTCk_LfEIdc zjH>H()%tKLuQM+EJ!+CUcUVSI0;!5OUHqYxx^J8QvwuhjPbo!5K&3XmM9iOs5&zzk zlfjr1jmoLg;hoC4u`Z$-2StW#%OUrsRq*aCTP$u}*t7(92LxJ>elC}nmv7^&2ai+9 z(wLmf4e{Tc&8xZmY8rQLzG&rt7SrlmE`9bGFQof7PHci$$)lfdAEkOK(>G3W!6UTz nS>;KY!A{j05{V@LCuCS;m((yu;<>1xA!$E(^&^{FD;MG)XUiwR delta 594 zcmZ8eK}#D!6rM>oFK*(ln<$uUQdv{eEMd{C6+LK8gf_bbDnhY0q3xk)5Del)D^@(D z;Prty37#wzDxvSN*Lo?HO36v6r`S`E?H`bi(SsX?In4LH?|a{SQ$GwH*F#N{D<QJwXZoYH@5z5xiE1x~i7s z55iK%ZAr!Ns*Y_X{zaP&V#TUF$g&d4FoQi2H^XjU(<~y+7|Y{uYK=bi??t-|Z9a&t ztclf!8Co{1UZrr)qd?QvncSF(@;F^isemV?UaA$p1v?PFb@pRrv6Va+%n>^>m<=w; z?#BquUqxv7#n(W1j;*pcY?G4Df*gM?s(l9AU~3}b9f8I2VAKRhh;#51Ogufw;m+a! znJ@L!L7!+`S)b6jOYipx(AXTH-}~1iSWqOKx60IP{z+ncJ8_c?Yel`WFvUl(cG1&F zPw+>S|8XG&MMR3WR${8}AAxqSs@WS7hhJqqJ=nyI_IirF8x|z+LYw&Hp8E=yKF6O!9*mrk`uxuFC=b14zG-ga7~l diff --git a/damus/fa.lproj/Localizable.stringsdict b/damus/fa.lproj/Localizable.stringsdict index 17dc0417..ca3d594f 100644 --- a/damus/fa.lproj/Localizable.stringsdict +++ b/damus/fa.lproj/Localizable.stringsdict @@ -15,7 +15,7 @@ one ... %d یادداشت دیگر ... other - ... %d نوت های دیگر ... + ... %d یادداشت های دیگر ... followers_count @@ -63,7 +63,7 @@ one %2$@ و %1$d نفر دیگر به یک مطلب که شما در آن تگ شده‌اید بازخورد داده‌اند other - %2$@ و %1$d نفر دیگر به یک نوت که شما در آن تگ شده‌اید بازخورد داده‌اند + %2$@ و %1$d نفر دیگر به یک یادداشت که شما در آن تگ شده‌اید واکنش داده‌اند reacted_your_post_3 @@ -79,7 +79,7 @@ one %2$@ و %1$d نفر دیگر به مطلب شما بازخورد داده‌اند other - %2$@ و %1$d نفر دیگر به نوت شما بازخورد داده‌اند + %2$@ و %1$d نفر دیگر به یادداشت شما واکنش داده‌اند reacted_your_profile_3 @@ -95,7 +95,7 @@ one %2$@ و %1$d نفر دیگر به نمایه‌ی شما بازخورد داده‌اند other - %2$@ و %1$d نفر دیگر به پروفایل شما بازخورد داده‌اند + %2$@ و %1$d نفر دیگر به نمایه شما واکنش داده‌اند reactions_count @@ -111,7 +111,7 @@ one بازخورد other - بازخوردها + واکنش ها relays_count @@ -159,7 +159,7 @@ one %2$@ و %1$d نفر دیگر یک مطلب که شما در آن تگ شده‌اید را بازنشر کرده‌اند other - %2$@ و %1$d نفر دیگر یک مطلب که شما در آن تگ شده‌اید را بازنشر کرده‌اند + %2$@ و %1$d نفر دیگر یک یادداشت که شما در آن تگ شده‌اید را بازنشر کرده‌اند reposted_your_post_3 @@ -175,7 +175,7 @@ one %2$@ و %1$d نفر دیگر مطلب شما را بازنشر کرده‌اند other - %2$@ و %1$d نفر دیگر مطلب شما را بازنشر کرده‌اند + %2$@ و %1$d نفر دیگر یادداشت شما را بازنشر کرده‌اند reposted_your_profile_3 @@ -287,7 +287,7 @@ one %2$@ و %1$d نفر دیگر یک مطلب که شما در آن تگ شده‌اید را زپ کرده‌اند other - %2$@ و %1$d نفر دیگر یک مطلب که شما در آن تگ شده‌اید را زپ کرده‌اند + %2$@ و %1$d نفر دیگر یک یادداشت که شما در آن تگ شده‌اید را زپ کرده‌اند zapped_your_post_3 @@ -303,7 +303,7 @@ one %2$@ و %1$d نفر دیگر مطلب شما را زپ کرده‌اند other - %2$@ و %1$d نفر دیگر مطلب شما را زپ کرده‌اند + %2$@ و %1$d نفر دیگر یادداشت شما را زپ کرده‌اند zapped_your_profile_3 diff --git a/damus/fr.lproj/Localizable.stringsdict b/damus/fr.lproj/Localizable.stringsdict index ba36c7c7..d512c525 100644 --- a/damus/fr.lproj/Localizable.stringsdict +++ b/damus/fr.lproj/Localizable.stringsdict @@ -236,6 +236,24 @@ Republications + sats + + NSStringLocalizedFormatKey + %#@SATS@ + SATS + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + d + one + sat + many + sats + other + sats + + sats_count NSStringLocalizedFormatKey diff --git a/damus/hu-HU.lproj/Localizable.strings b/damus/hu-HU.lproj/Localizable.strings index 051632aeed3fb6e0091057624bacf69c61f18c88..9806e9c865cd7cfa0be8440337a686e6f9503589 100644 GIT binary patch delta 2174 zcmb_eO=uit82%>7rZJgB*V)a|CbqLlwy|k!BsOYoV!On~R-##n;H9Fwo5{Gz?2o&% z-6j?T;>nAxFF4YZRs|_mqH_p{N)ZtcQuHLm%kD)`>Olps^?AS9Uu?w&S-zQXzVH42 zdER~dE#I{(zRgP-{V`yAez`5II?4t$p3O^ zqvje%KZOjj^J{prs|9>lWY#S4i&({;a~8*{=0H*{!`>Anz>xGdC<|B_e6NDi23H#L zOquZt?kWf^%iv_0ZDP&o4J*TR@lu~V_t01;eSf5nzTF;i%A-mk%2mR(<@wBYW5CKh zxSx_Xa+-AUEGR5#0oyk#ePI1bVbkxW5Ph_u(#NJsgU2R^|0yvgy(l$gQ1;kCe@^sLmWR9SKoX2BE-NfjqCqz|^IBz=%^FgOxw*5*_?z@V0-SS{!%FDx6A-wDgJ)#YlDMGpsS&yw z3mjTgD4rXqcO#Kq^xY|ya;N%cJZz|z77FpU1FjhKuDRQN zWq!NO{WW%Ru!r92>4)hcFw#pnx1$BX(nSfk+@8(Id$NAZ zIGO?_gS^+oI>rQJYZ2Yl7#nOyZJ;nnSm0X68S`=M)A$udrh`p!KABTAnm4*;OQ1+V z^AMnXDe>~#8|JwCPE#Bk1Jb1~_PnW}wH=vrwUP;Q}vBpr#u+N&7**Tmr zJTmkxVey-NNPr7bqXp}ryM6g;oVE0FG8p}2+UUZM15RvG>3XsN%A1T$=_BVLFJ+n7 z)kXk&Uz?hXt=x3Q*mtLg19dRx(xP`(^{sDIWo~~BlAK2#Q2Oam`N1u0zVg$n*Y{FK taB!Cgl?4i~4d271GFmr;)6Wx~UBlod|Ib06{HH)~e6VjzJ)x+be*?UY{d)ia delta 253 zcmX@NmUYH7)(wk&$YzPYH>#5Z7+EG8=(=sbvgJS`P+DhumpG%F+T?ZX g^``%_Wfa>UW5>8gaB`KOz~lvYG`7DAV0^*~0Gyao00000 diff --git a/damus/ja.lproj/Localizable.strings b/damus/ja.lproj/Localizable.strings index d0b98af10c8330e755636bb0a2c9d8070a413f1f..ba6d59bfe02b59437b1d12ee60cc89c33bffdd51 100644 GIT binary patch delta 1935 zcmcIlZA@EL7(NFJ)M8i#%i0E5X%R98<$;`5-$=ot=cZNSEGchqUMuNs4Gx&iq>Z1EGm8gjy@j0i|7B@}$!`$3^ z-*et`-jC9=5@>% z7>{7~F#ZYr(`*=08d3_Rpm^sc5w9GtXZ32X4gZU^_A<*@`=_1e7lP)ZpO=Iph|Px_WQ=LJ z)Y%}MrBx=GXr<#c^fJ~0LZj_1m0P6kTO*NxhkwIP!=5N`S;otpXyAeIf}E>lawsJG z?K}2~Pj^2ic8fn=INiVPm<0vx42gD9X|lIkTG1hu!qe{cT%rwx+8JIGDG8Q{FkhD$a`Q;vI2COo>;bFFrdU zhQtwi;(>K$vxj@FXYO;}hBLQQ`OYc|hDzF3+AlD|vhG~2r1#WN@AamRe2Y5pj~AZ# zsyw^c9ib1y9_K$ZBjC)wLL2(rfGS}!_ z*kd(3{9KXMy!EQb?5p7pm0fwnPS*x&8w;xhFNtUceH3Z{Y{Clj>e{`Ym?V< z#?HHFZlRvUgSL)1DqeO#mL^v+S~jIzKl<1~bdoN+ik|$~MaM4K*UQctLGMbPB-mk= z&wxFDYbBBCSQ(>jou2UwElYn{!cMA4bda(JX3db`e#Nzh@+gZeL*6o4Sz?*4mVPyq zS}?EwUKT%z%i?$Of$Bj=uOY%_OK&jr?)2KdlLMCcqE37*-WP9R$*lMmiw)5f*Wy`{ zY1NjZ=AI&6O^Hi)S@VaE2h`D+Hv+u5G}HM8ojX-WOFve$43v+AL#ogLSimwHY&*SK?I1HCbj6*XC8eT}?+ap^ToA}Hd5%AaGE@S|NSH{r!P4YewUGwhlNVMfZc<*yJ_W#kMCX SF^2I^&T|*oUSP!dgB1Y8DNnlq diff --git a/damus/nl.lproj/Localizable.strings b/damus/nl.lproj/Localizable.strings index ef91a2ec040f595e6ca2368a0cd5e00b7ce76512..d8e9366c996029bb884778f6663263ff3f4a8cc6 100644 GIT binary patch delta 2249 zcmcImU1(Eh82&zMV@$icnkJC`Y^O~#r=@9AZHv>c4Hi14_GhBPkl8dj&EnG2l0>Ve zt}5PcY`(AqyHfFD2=niNg)tcOLUyS)V}qd%3L6tPdN*&>=lxDjeq0qRj6;(5yx%$R z^Zq_>E`3pX`sL(}5#PMy2oQR1eBuz|Z5^8xAKoE1l*p233|9BE<@k4-_Bg`9xVWB8SK-l6R9Rj*4!40AVC+RR6fwylkGc7jXU*t6^IQb5b!)k3&{XAZu5kkhI(FN@~(^oHjHT5?oQe8|K>cOjD{B^ zfxYlrMs&1_4&C?E+ViJ@1BL-rhcTV4#7xMHH3uCt8&_dUk(I%y=Jj3DyI`{oL+R++aLH~d0+muC z?+6`a$U0`GA{8<*tOTEhp4pJkY)Z9hR2S_c-c#-qJm#&*4;IO^oPn8r!ZbD#w7SmT z2+_5rPI~Z>Yn@GR26bF+Ha~F7jonTcW>qcYusX?6!!qvem`Gb%pKLMDc1l0h-0xY0Fjq?-K74~dt#3513`v*FlDrjc;y)Uj6uCRK Ye2GCaHn zNGc3AE9%N~OCR#`Sz{(MbLZT9 z&pG!y=X_^As)cTT8oHN{x!=b6B2zPzr3FgkSt6TE%3)+snda~`DTB*`+=8Tp?;@nj zcvtYY=?X?RzjwQ5wp!mW(lX|%w1_noj29p==oD7YLsrE&gSA(27cpCb)U$jLGY9Ap zg=vryG)W0IM`NJ`9id_O`+aR4?wJ>_?r=I!sdnf1Y1PZdM#Q-hSCQGYcFl$%i&8LQ zL!s}anEMY7@%gdXt0nB+8h{Ec{1aqZ>`4Qdx(94NiaD1iRp(gCi17WRu@g2GaJ(hD z4uusMEpJB3z>G=nDnT;`xqoC+kIFzA(d7F-cE2QhC3Xh%RH4Px&RZHL(YOqGneu=x z7$?)cJ8o^`vwuYS((R}d-mk)JAMbRYO{mT(gk99vPb!I!1^Nq6a2Be>w2UueMG;pP z-v*t;ObJ&OyKFxHaW^0SHF`4_;kAh)Lw{Fj>0DWAsNnCC`$i?BGBmA1DhZ#e$bPENb1>uAnVsCC799T@4saC`LG)uiDiBR|v5EeIfu1$3}Yp*X98}WvXC*gf-Chpuws__4e>dt^K;z37~2aY+W_VU{J9`}>U zYdiVR^>9~~uIcOw26upKXQD}u7wY*2d|C#-%>br_b`^J@fAfc@+}g}!-;+mvr*L$n z<#0y%>%#7l=JIGH(78Z<19W|4_nTt4M{EzD5N?xl_o&#T>1IOpPa34$B~+*-)ylUX zbo1I5?JH8TMU;$?f)M-_3KH;Zo3p~G7FK4k(yMa=twC6J5jEau72<>6L>9Dn778@0 z_emt?HT_GTrZH=;U?6lLokpI%>LqMR!IcIcQc&~s3z!a-3!@o)3oA(F3nK~(RPmfv zWqMnQbQx>5a>m;y36ZB!p-)k~2=kVv(lo@D#zWed&|@0UgKQRt+@C%S?-%^Xw|lfy z;*)mBqRd~UjzH{NSPN)unM2J#{ zj^EfATKTtbAy^4-FCq~(8;xd5zkkkRZtL#b!1W@$6Q4Z<^YE^bFeZOl9N^#cy{iwy zs+Q1H=6rBiJ;V3b$5%19+{KSKp5xEreNOGP+NP>(+Cwi(9*t;6{BTOs7f0pS!oXX8 a6C{&-(7a!XD*v|L&%>Yhu12QSxL}h$xOOlys8Wi3`z_N47Nb5G?Np0?N2xEo_D|l3JZ*~iL$HeZ) zki(F~P`UZED>LKd1yL-M?}Twp)(Pie1xrjmA1)75GPyBPeR5Zr(BwHgI3^#can?<&SB~1Td!Kkx6PLy$i b>f{r5^`=W$Gm34mvS#!W+@9ylsKN#St$t5Z diff --git a/damus/sv-SE.lproj/Localizable.strings b/damus/sv-SE.lproj/Localizable.strings index 59a508001868c9b96e83aafb9b28138eb102495d..294d823963664317ad6b67074e43b9d3d8946552 100644 GIT binary patch delta 2068 zcmbtVOKcNY6n(FWo#1@P*RH{kA(0c3CTZOtg2p6KKuswEDk56C0o$>Yu^q>bcL(*ltTnRgGr+ z{N}xT&OP_OGZ#)fZk~5s`lHF3Z0@YxvQI=sMnv#T2}6X$1V);e7BM_SVjP!_c^xw; z{7+)`D1OuQ-tlI-)>Ca>2ugQ%R?LVru4#lauw)08OkpmI@i+p0ggc41X}U4r>hOyp z;iqMvm(I6;VEx$UuC>N`KVL&jzxJC+x2)Y8QD_E&=^~6UhT^1fRY(KxHH`iP=-b2K z|E!GCi2p1NqDUnoCb3uHn`TgUoLn_DRLBe4jsQX2?cyNP;e;|!M2FlVj97ABr$NIE zk}H-YrSxOjLr_t}jrFx6uD0YW@pZS?d}X_AFkf$yO@2c>!In6dm6w54LJI^xbMCmr;^2uXXI*%P&~-d!0U7T67ijIdj=b zJ&AQy&+iAi8gG%sQdPy!dT2UTpmeWh{gU|5&9SNB?z%xfy%V6$RavplGS{=om-=E3 z9j&vq(8BkvTO)-k^AStpBxoXsw|wOKG42sB3m3d&)PBphsAJ_g$q{cLZ`X{u;v5;p zMu%xdVFw!Kr`4I7fs8;k7lvdreH*DhnZtrGK#B%63ir5IAi>Vht;S8rwxy{SoKUvK z^rl2v)}+93{H$CcoeRm0Jv2OY2do7;1R*s*6IiT#lfS+lKLdb>&;xTx>`vhNm{ zP+;6Uw^Sv+^V8wSmDDiXX)g9jx5EXJ8qMwu%;E3uM*8yMM!G^i^U@CKlvxVoI<|6) pbSX>CyEA7srReb2Tmuqo&U&vXQ}~L~;13(9^ZMqwU4!zr<4<(V^3VVP delta 256 zcmcb#h;>O9>xO?qlX-N!cvTr37?c9UxOd*RG{?wlbArhQL9giL?mgpA@Nn)PB z>Phq|^tzb9NXICH(UizyH-cG+x-T{z&0wCwHwEc@=4G5cfwc_A5$MRFC9s>qYEgF= zcD$ln*n~$sC-$Owurh?k^Iq|+T-%ABYM*|?_`1E%W<)!etn@|7O6pfzDEP4MR9G6S zLup)upiY6u8MsUD^sB zJN_vxE<-+DzE3~zYPJ>TJ_$!AuYF(%(8@qNEnjXm_6{tr<5*gvke;SrUuy1OE3Ha< zT74Csl`&(@6zm(^Qz<--kjAQad1Ivtqy$Zkwpdw#bSyROd9_jr=L~0#4>*Rz+mE5T z9rJOtj7*>$V!)&<(TsZM8>KmZEU1;udD5G zscZ?871X7TffkDV-9Yp2*qhcmWJ-ZsPIhZmE^bN}ucqApSGYD*s9uAku`*a^qnmea z^_qA~rk|N0FANpwUP{DOAgV|dMWd0kErxSnu#Ik~Y-MNY^QOfDi6Mf_C$rG5Er@13D@*1yc*z+tLHDvvsB*lE`@sb)g2p}5C%E=Sn>n7-M#6ODij@XB@ezNyO>aw;dz@R*l)cv=kh}$mActD(15l zfYORbkIo-yvYK_6miO;A?*B2{LNmk8r?MhRe{Op|f77be<$tp(4fN*u;f2#y<)noY zecrs+uDB?9cVGd7MLR|BZKl(%_Vp!-lkY&siyZJySzJxr`GsY_`6XA;GwC_xCU&J~ TkmiOq(ah&t7kV}+*Da3#g=X}1 delta 409 zcmZp;#q#PX%LXff$#Uvm;;IY|3`z_N47Nb5!~o%0GjMG-6nM@(xklb=vx%$;7oP!= z8Uu#S0_r-PlNZCg zS_pAaG0;Jo40#OcKz;#3DNwusC<|1%dGEP{ypta!a7{LQ*D%@OjtqARP(lHSOBgaH zZ!8v`{O=Bri~@rnSR@t6#X0>KgGH)0bWeU@CNX(kwbbSXcX*^W$9-QTIl1qV#^eVf i9NU)(GFGupzTvDl-AkNNZ2KB<#vOdy`P3Odu>t^+A9Upa diff --git a/damus/zh-HK.lproj/Localizable.strings b/damus/zh-HK.lproj/Localizable.strings index ffae72086da1d8f61d3b6c75679f68c1beded39a..fddd8b88f389adde65260aa8db1317924b6db379 100644 GIT binary patch delta 2106 zcmcguT}+#06n;OUKQgEYZK3?x7AABkFwLg8Y{+EO%m}reS%|=(Kd_EcD*a)bVT*Gy zGbW_j39I44EM{JqWp(qr!00Y!4BnU^nrSq~xp-;FT;eXwOg5kMwxu627}w33@f{s6r=ba5dnNcI1k|@ zf_)gL$MBBg9T$hNienYSYEmR14PfUZW!~b7VIRe3659Dr5AIIjEQWOeHj;S4kVff| z!mH{r2*9S`YA*naLB{I?xDm!Dh`n7rC!vjC=a>61 zZM|4Yv)4aA_XMd?cw zicvQTV^j3Y@v6?Xitod{sx~;kMYM|&nD2(`xxlG)Pd%9u*13#g?$TNbA>U*72Sx2V zwxDfw=H`@~V~wb(x<F(UUplmv^Ll;ZeuhxJ?yEZyBS%GKX@AFNGhDwL8!tW+M*)6;`BtsB%VyX)4VlY0BD#)V*G zN|w%WbDO#{blaR>awsi3#*p|d6K7d^4V$heUCX!AXm>85L*#hM(&^8Y9#cSAGWj&5 zObOareqmM0tQx}20A1W+>nzyJ&x|SQO$cQdl!e0(Pe`thNUL0Ym>;AZsBD|Y9Utrz z_-%aXL(iQdd+IaqN4 zcFGLYsIxbZ*=WdRE=q_9{nYAecm*BFqmxHl;dkGAPfrY_|45(Nw|$|*sGQX0sV#k@ zOtI=p5UhRSqcTN#h+4jBpp(`*LyidO^FU)gXS`Vwc6JyrG!xQs{12elW-Nx>45bra M)-PPGQ-0O`4Xs4{8UO$Q delta 430 zcmdmSlV#CSmJL<{leyHr#8nv_7?c}Jkinrq<5IyErZMCLt%EqH7-(%KLmopqkYB)13KTB@$^unRK6p!b^R#oTcqbnS z;+ov@L}0R?k;?QAMn;~=M(-RZf4VKhT>@080K_E>8Ivd8mR10nrNH0^7D+{NvS)uy znOn6)*Jgt|7E+V%Bf2O7DeLN?U0uINfx* zm>H9$+sUZ$VNdhHxGlr`fYE6%hWOxv(~u= z68i*BkKvuhJ1dT2mBlK9RZ2`ksuZ4cn_R&RHfelPP|X)Qad8}H8LSl;$l*yqnx^`v z9o}u?5wT79A=`(?k9D(XkY^9#tz|vaHm!I27uFeo#%&5H|JgK9(Scuy{b;Ac0`$w0>I!?=;aN5$SJ9+S8xv5U!lfp2`GUg{sMEhm8xq{NOR?7%%P0+U|YFk$8 zy#w!R8{zv-(IjlJ-T}{xaZ_l29W5uFtcousj^!H0Smn;`r4vFH1G`3h8=P4=S{>jkUOKv2_9+EKPre6GM z{8A4s1AAHMV|R--skyqk0)0ql->t1RT4B!=+(R4FPrWYkw`>V0@|XVrszD#Zt>JLX z79f+MADP|yzvOon-e?DHL(7kM)ARdm9R2|g*Ua_Q+4gC|K6Wufzr5BRAl|3(8#j19fjyENp>zaM#^`{Spak#~+q zkDtoz9DSwdu3di7Dr|HpSf~A-S*~Y1_EhSelce^Udiw7BExSt%kU?C`TX9S-j@&1y zm6<@n!*6cEVfcf2L5~lHw6>WWjr7Ms8|6Q-X%`kQR`Gn%5{pYN&T18d#?&Z_g}(W~ zO&6|PyZ(*YIY{o(NsvoZmLZ)wz=%)1Q{V0Ku5|+yDRo delta 404 zcmbPslcnJ(%LXff$z1AQf~pJ-3`z_N47Nb5!~oJjtMNhd4~NP&dGmPb8WWq3Xz`t z?}x~wRJO@p>$#K@ftHjoq%u?hNd<;9hD;zkham;XP6X1a3`GnjlMPda#d3i%#XyK~ z*x&fn(#;8JN}`hsszf%wsQITjxo;WUWC3-T&D-W*)0^DlDZ&mlZF;UOqw?epDJ=S- zKqFGY#-}ml0}Y2*UJSH6lOc~G9mp?WC Date: Sat, 20 May 2023 13:38:06 -0400 Subject: [PATCH 47/51] Add Damus splash screen Changelog-Updated: Add Damus splash screen --- damus.xcodeproj/project.pbxproj | 6 ++ .../gradient.imageset/Contents.json | 12 ++++ .../gradient.imageset/gradient.jpg | Bin 0 -> 120742 bytes damus/Views/Launch.storyboard | 55 ++++++++++++++++++ 4 files changed, 73 insertions(+) create mode 100644 damus/Assets.xcassets/gradient.imageset/Contents.json create mode 100644 damus/Assets.xcassets/gradient.imageset/gradient.jpg create mode 100644 damus/Views/Launch.storyboard diff --git a/damus.xcodeproj/project.pbxproj b/damus.xcodeproj/project.pbxproj index 61f9627a..72e7cb3b 100644 --- a/damus.xcodeproj/project.pbxproj +++ b/damus.xcodeproj/project.pbxproj @@ -269,6 +269,7 @@ 501F8C822A0224EB001AFC1D /* KeychainStorageTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 501F8C812A0224EB001AFC1D /* KeychainStorageTests.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 */; }; @@ -700,6 +701,7 @@ 501F8C812A0224EB001AFC1D /* KeychainStorageTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeychainStorageTests.swift; sourceTree = ""; }; 50A50A8C29A09E1C00C01BE7 /* RequestTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RequestTests.swift; sourceTree = ""; }; 50B5685229F97CB400A23243 /* CredentialHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CredentialHandler.swift; sourceTree = ""; }; + 50DA11252A16A23F00236234 /* Launch.storyboard */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; path = Launch.storyboard; sourceTree = ""; }; 5C42E78B29DB76D90086AAC1 /* EmptyUserSearchView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmptyUserSearchView.swift; sourceTree = ""; }; 5C513FB9297F72980072348F /* CustomPicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomPicker.swift; sourceTree = ""; }; 5C513FCB2984ACA60072348F /* QRCodeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QRCodeView.swift; sourceTree = ""; }; @@ -994,6 +996,7 @@ 4CF0ABD529817F5B00D66079 /* ReportView.swift */, 4CF0ABE42981EE0C00D66079 /* EULAView.swift */, 3AA247FE297E3D900090C62D /* RepostsView.swift */, + 50DA11252A16A23F00236234 /* Launch.storyboard */, 5C513FCB2984ACA60072348F /* QRCodeView.swift */, 643EA5C7296B764E005081BB /* RelayFilterView.swift */, ); @@ -1558,6 +1561,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 */, @@ -2151,6 +2155,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 = ( @@ -2198,6 +2203,7 @@ INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UILaunchStoryboardName = Launch.storyboard; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; LD_RUNPATH_SEARCH_PATHS = ( diff --git a/damus/Assets.xcassets/gradient.imageset/Contents.json b/damus/Assets.xcassets/gradient.imageset/Contents.json new file mode 100644 index 00000000..52666f80 --- /dev/null +++ b/damus/Assets.xcassets/gradient.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "gradient.jpg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/damus/Assets.xcassets/gradient.imageset/gradient.jpg b/damus/Assets.xcassets/gradient.imageset/gradient.jpg new file mode 100644 index 0000000000000000000000000000000000000000..908466e5f3913b522d2ba09642085401848c77bb GIT binary patch literal 120742 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 oY?nph$<7nKi|ETpu~Dso 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≻wBusv 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%0D+(Bv;0W}wPjh9X% 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 literal 0 HcmV?d00001 diff --git a/damus/Views/Launch.storyboard b/damus/Views/Launch.storyboard new file mode 100644 index 00000000..c069ec81 --- /dev/null +++ b/damus/Views/Launch.storyboard @@ -0,0 +1,55 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + From 1769b081474287396a14580f2522a785b180c644 Mon Sep 17 00:00:00 2001 From: Terry Yiu <963907+tyiu@users.noreply.github.com> Date: Tue, 16 May 2023 10:54:04 -0400 Subject: [PATCH 48/51] Fix reaction notification title to be consistent with ReactionView Changelog-Fixed: Fix reaction notification title to be consistent with ReactionView Closes: #1137 --- damus/Models/HomeModel.swift | 2 +- damus/Nostr/NostrEvent.swift | 26 ++++++++++++++++++++++-- damus/Views/Reactions/ReactionView.swift | 5 +---- damusTests/LikeTests.swift | 22 ++++++++++++++++++++ 4 files changed, 48 insertions(+), 7 deletions(-) diff --git a/damus/Models/HomeModel.swift b/damus/Models/HomeModel.swift index d2414764..b89d0017 100644 --- a/damus/Models/HomeModel.swift +++ b/damus/Models/HomeModel.swift @@ -1202,7 +1202,7 @@ func create_local_notification(profiles: Profiles, notify: LocalNotification) { title = String(format: NSLocalizedString("Reposted by %@", comment: "Reposted by heading in local notification"), displayName) identifier = "myBoostNotification" case .like: - title = String(format: NSLocalizedString("%@ reacted with %@", comment: "Reacted by heading in local notification"), displayName, notify.event.content) + title = String(format: NSLocalizedString("%@ reacted with %@", comment: "Reacted by heading in local notification"), displayName, to_reaction_emoji(ev: notify.event) ?? "") identifier = "myLikeNotification" case .dm: title = displayName diff --git a/damus/Nostr/NostrEvent.swift b/damus/Nostr/NostrEvent.swift index c807d239..2879ddef 100644 --- a/damus/Nostr/NostrEvent.swift +++ b/damus/Nostr/NostrEvent.swift @@ -492,11 +492,11 @@ func make_boost_event(pubkey: String, privkey: String, boosted: NostrEvent) -> N return ev } -func make_like_event(pubkey: String, privkey: String, liked: NostrEvent) -> NostrEvent { +func make_like_event(pubkey: String, privkey: String, liked: NostrEvent, content: String = "🤙") -> NostrEvent { var tags: [[String]] = liked.tags.filter { tag in tag.count >= 2 && (tag[0] == "e" || tag[0] == "p") } tags.append(["e", liked.id]) tags.append(["p", liked.pubkey]) - let ev = NostrEvent(content: "🤙", pubkey: pubkey, kind: 7, tags: tags) + let ev = NostrEvent(content: content, pubkey: pubkey, kind: 7, tags: tags) ev.calculate_id() ev.sign(privkey: privkey) @@ -966,6 +966,28 @@ func first_eref_mention(ev: NostrEvent, privkey: String?) -> Mention? { return nil } +/** + Transforms a `NostrEvent` of known kind `NostrKind.like`to a human-readable emoji. + If the known kind is not a `NostrKind.like`, it will return `nil`. + If the event content is an empty string or `+`, it will map that to a heart ❤️ emoji. + If the event content is a "-", it will map that to a dislike 👎 emoji. + Otherwise, it will return the event content at face value without transforming it. + */ +func to_reaction_emoji(ev: NostrEvent) -> String? { + guard ev.known_kind == NostrKind.like else { + return nil + } + + switch ev.content { + case "", "+": + return "❤️" + case "-": + return "👎" + default: + return ev.content + } +} + extension [ReferencedId] { var pRefs: [ReferencedId] { get { diff --git a/damus/Views/Reactions/ReactionView.swift b/damus/Views/Reactions/ReactionView.swift index 32ab33c2..6b99fc79 100644 --- a/damus/Views/Reactions/ReactionView.swift +++ b/damus/Views/Reactions/ReactionView.swift @@ -12,10 +12,7 @@ struct ReactionView: View { let reaction: NostrEvent var content: String { - if reaction.content == "" || reaction.content == "+" { - return "❤️" - } - return reaction.content + return to_reaction_emoji(ev: reaction) ?? "" } var body: some View { diff --git a/damusTests/LikeTests.swift b/damusTests/LikeTests.swift index 9d408ad3..93c5ec98 100644 --- a/damusTests/LikeTests.swift +++ b/damusTests/LikeTests.swift @@ -32,4 +32,26 @@ class LikeTests: XCTestCase { XCTAssertEqual(like_ev.last_refid()!.ref_id, id) } + func testToReactionEmoji() { + let privkey = "0fc2092231f958f8d57d66f5e238bb45b6a2571f44c0ce024bbc6f3a9c8a15fe" + let pubkey = "30c6d1dc7f7c156794fa15055e651b758a61b99f50fcf759de59386050bf6ae2" + let liked = NostrEvent(content: "awesome #[0] post", pubkey: "orig_pk", tags: [["p", "cindy"], ["e", "bob"]]) + liked.calculate_id() + let id = liked.id + + let emptyReaction = make_like_event(pubkey: pubkey, privkey: privkey, liked: liked, content: "") + let plusReaction = make_like_event(pubkey: pubkey, privkey: privkey, liked: liked, content: "+") + let minusReaction = make_like_event(pubkey: pubkey, privkey: privkey, liked: liked, content: "-") + let heartReaction = make_like_event(pubkey: pubkey, privkey: privkey, liked: liked, content: "❤️") + let thumbsUpReaction = make_like_event(pubkey: pubkey, privkey: privkey, liked: liked, content: "👍") + let shakaReaction = make_like_event(pubkey: pubkey, privkey: privkey, liked: liked, content: "🤙") + + XCTAssertEqual(to_reaction_emoji(ev: emptyReaction), "❤️") + XCTAssertEqual(to_reaction_emoji(ev: plusReaction), "❤️") + XCTAssertEqual(to_reaction_emoji(ev: minusReaction), "👎") + XCTAssertEqual(to_reaction_emoji(ev: heartReaction), "❤️") + XCTAssertEqual(to_reaction_emoji(ev: thumbsUpReaction), "👍") + XCTAssertEqual(to_reaction_emoji(ev: shakaReaction), "🤙") + } + } From 52ca33ef6ac5e2ee7d1b4afc081de47748005896 Mon Sep 17 00:00:00 2001 From: William Casarin Date: Wed, 24 May 2023 18:26:18 -0700 Subject: [PATCH 49/51] script: fetch popular users --- devtools/fetch-popular-users | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100755 devtools/fetch-popular-users diff --git a/devtools/fetch-popular-users b/devtools/fetch-popular-users new file mode 100755 index 00000000..f7ab7c48 --- /dev/null +++ b/devtools/fetch-popular-users @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +set -e + +curl $(curl -s 'https://storage.googleapis.com/storage/v1/b/nostrdb-backups/o?prefix=ndjson' | jq -r '.items | last | .mediaLink') > nostr-directory.json + +jq -rc '.data | {url: .profileImageUrl, pk: .hexPubKey, userName: .userName, twitterFollowers: .user.followers_count, nostrFollowers: .nFollowerCount}' nostr-directory.json | jq -cs 'sort_by(.twitterFollowers + .nostrFollowers) | .[]' | tail -n1000 | tac > popular_users.json + +printf "saved popular_users.json\n" >&2 From 0f805d7ea7e7dcf6ce5caeda593ceb3cc6ac6558 Mon Sep 17 00:00:00 2001 From: gladiusKatana Date: Tue, 9 May 2023 18:31:45 -0400 Subject: [PATCH 50/51] override .isScrollEnabled in TextViewWrapper (ie, set to false at UITextView creation) --- damus/Views/TextViewWrapper.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/damus/Views/TextViewWrapper.swift b/damus/Views/TextViewWrapper.swift index 41d4c7c8..0080b982 100644 --- a/damus/Views/TextViewWrapper.swift +++ b/damus/Views/TextViewWrapper.swift @@ -15,6 +15,7 @@ struct TextViewWrapper: UIViewRepresentable { func makeUIView(context: Context) -> UITextView { let textView = UITextView() textView.delegate = context.coordinator + textView.isScrollEnabled = false textView.showsVerticalScrollIndicator = false TextViewWrapper.setTextProperties(textView) return textView From f9a572faa28422a4df30af98b26da155df96a0d0 Mon Sep 17 00:00:00 2001 From: gladiusKatana Date: Tue, 9 May 2023 19:14:03 -0400 Subject: [PATCH 51/51] dynamically set .isScrollEnabled in TextViewWrapper (true if UserSearch is present) --- damus/Views/PostView.swift | 5 +++-- damus/Views/Posting/UserSearch.swift | 11 ++++++++++- damus/Views/TextViewWrapper.swift | 4 +++- 3 files changed, 16 insertions(+), 4 deletions(-) diff --git a/damus/Views/PostView.swift b/damus/Views/PostView.swift index 5791b375..9e554b2f 100644 --- a/damus/Views/PostView.swift +++ b/damus/Views/PostView.swift @@ -45,6 +45,7 @@ struct PostView: View { @State var references: [ReferencedId] = [] @State var focusWordAttributes: (String?, NSRange?) = (nil, nil) @State var newCursorIndex: Int? + @State var postTextViewCanScroll: Bool = true @State var mediaToUpload: MediaUpload? = nil @@ -203,7 +204,7 @@ struct PostView: View { var TextEntry: some View { ZStack(alignment: .topLeading) { - TextViewWrapper(attributedText: $post, cursorIndex: newCursorIndex, getFocusWordForMention: { word, range in + TextViewWrapper(attributedText: $post, postTextViewCanScroll: $postTextViewCanScroll, cursorIndex: newCursorIndex, getFocusWordForMention: { word, range in focusWordAttributes = (word, range) self.newCursorIndex = nil }) @@ -335,7 +336,7 @@ struct PostView: View { // This if-block observes @ for tagging if let searching { - UserSearch(damus_state: damus_state, search: searching, focusWordAttributes: $focusWordAttributes, newCursorIndex: $newCursorIndex, post: $post) + UserSearch(damus_state: damus_state, search: searching, focusWordAttributes: $focusWordAttributes, newCursorIndex: $newCursorIndex, postTextViewCanScroll: $postTextViewCanScroll, post: $post) .frame(maxHeight: .infinity) } else { Divider() diff --git a/damus/Views/Posting/UserSearch.swift b/damus/Views/Posting/UserSearch.swift index 67c6b8ba..8873aa09 100644 --- a/damus/Views/Posting/UserSearch.swift +++ b/damus/Views/Posting/UserSearch.swift @@ -22,6 +22,7 @@ struct UserSearch: View { let search: String @Binding var focusWordAttributes: (String?, NSRange?) @Binding var newCursorIndex: Int? + @Binding var postTextViewCanScroll: Bool @Binding var post: NSMutableAttributedString @@ -92,7 +93,14 @@ struct UserSearch: View { .padding() } } + .onAppear() { + postTextViewCanScroll = false + } + .onDisappear() { + postTextViewCanScroll = true + } } + } struct UserSearch_Previews: PreviewProvider { @@ -100,9 +108,10 @@ struct UserSearch_Previews: PreviewProvider { @State static var post: NSMutableAttributedString = NSMutableAttributedString(string: "some @jb55") @State static var word: (String?, NSRange?) = (nil, nil) @State static var newCursorIndex: Int? + @State static var postTextViewCanScroll: Bool = false static var previews: some View { - UserSearch(damus_state: test_damus_state(), search: search, focusWordAttributes: $word, newCursorIndex: $newCursorIndex, post: $post) + UserSearch(damus_state: test_damus_state(), search: search, focusWordAttributes: $word, newCursorIndex: $newCursorIndex, postTextViewCanScroll: $postTextViewCanScroll, post: $post) } } diff --git a/damus/Views/TextViewWrapper.swift b/damus/Views/TextViewWrapper.swift index 0080b982..7a6d1bc7 100644 --- a/damus/Views/TextViewWrapper.swift +++ b/damus/Views/TextViewWrapper.swift @@ -9,13 +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 = false + textView.isScrollEnabled = postTextViewCanScroll textView.showsVerticalScrollIndicator = false TextViewWrapper.setTextProperties(textView) return textView @@ -30,6 +31,7 @@ struct TextViewWrapper: UIViewRepresentable { } func updateUIView(_ uiView: UITextView, context: Context) { + uiView.isScrollEnabled = postTextViewCanScroll uiView.attributedText = attributedText TextViewWrapper.setTextProperties(uiView) setCursorPosition(textView: uiView)