damus

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

commit c92094823e2b50e3fa767c1bed2fa50aa20fc74d
parent f4b1a504a59fc4819ce7a3fbd28336316dd3c1e0
Author: Daniel D’Aquino <daniel@daquino.me>
Date:   Fri, 13 Jun 2025 19:25:22 -0700

Add send feature

Closes: https://github.com/damus-io/damus/issues/2988
Changelog-Added: Added send feature to the wallet view
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>

Diffstat:
Mdamus.xcodeproj/project.pbxproj | 16++++++++++++++++
Mdamus/Models/HomeModel.swift | 18+++---------------
Mdamus/Models/Mentions.swift | 22++++++++++++++++++++++
Mdamus/Models/WalletModel.swift | 54++++++++++++++++++++++++++++++++++++++++++++++++++----
Mdamus/Types/Block.swift | 10++++++++++
Mdamus/Util/WalletConnect/Response.swift | 4++--
Mdamus/Util/WalletConnect/WalletConnect+.swift | 22++++++++++++++++++++++
Mdamus/Views/Wallet/BalanceView.swift | 9+++++++--
Adamus/Views/Wallet/LnurlAmountView.swift | 246+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Adamus/Views/Wallet/SendPaymentView.swift | 371+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mdamus/Views/Wallet/WalletView.swift | 32++++++++++++++++++++------------
MdamusTests/InvoiceTests.swift | 12------------
12 files changed, 769 insertions(+), 47 deletions(-)

diff --git a/damus.xcodeproj/project.pbxproj b/damus.xcodeproj/project.pbxproj @@ -1590,6 +1590,9 @@ D7A0D8752D1FE67900DCBE59 /* EditPictureControlTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7A0D8742D1FE66A00DCBE59 /* EditPictureControlTests.swift */; }; D7A343EE2AD0D77C00CED48B /* InlineSnapshotTesting in Frameworks */ = {isa = PBXBuildFile; productRef = D7A343ED2AD0D77C00CED48B /* InlineSnapshotTesting */; }; D7A343F02AD0D77C00CED48B /* SnapshotTesting in Frameworks */ = {isa = PBXBuildFile; productRef = D7A343EF2AD0D77C00CED48B /* SnapshotTesting */; }; + D7AACFFF2E0387B800FB7699 /* LnurlAmountView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7AACFFE2E0387B800FB7699 /* LnurlAmountView.swift */; }; + D7AAD0002E0387B800FB7699 /* LnurlAmountView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7AACFFE2E0387B800FB7699 /* LnurlAmountView.swift */; }; + D7AAD0012E0387B800FB7699 /* LnurlAmountView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7AACFFE2E0387B800FB7699 /* LnurlAmountView.swift */; }; D7ADD3DE2B53854300F104C4 /* DamusPurpleURL.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7ADD3DD2B53854300F104C4 /* DamusPurpleURL.swift */; }; D7ADD3E02B538D4200F104C4 /* DamusPurpleURLSheetView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7ADD3DF2B538D4200F104C4 /* DamusPurpleURLSheetView.swift */; }; D7ADD3E22B538E3500F104C4 /* DamusPurpleVerifyNpubView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7ADD3E12B538E3500F104C4 /* DamusPurpleVerifyNpubView.swift */; }; @@ -1720,6 +1723,9 @@ D7DB930C2D69486700DA1EE5 /* NIP65.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7DB93092D69485A00DA1EE5 /* NIP65.swift */; }; D7DBD41F2B02F15E002A6197 /* NostrKind.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C3BEFD32819DE8F00B3DE84 /* NostrKind.swift */; }; D7DEEF2F2A8C021E00E0C99F /* NostrEventTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7DEEF2E2A8C021E00E0C99F /* NostrEventTests.swift */; }; + D7DF58322DFCF18D00E9AD28 /* SendPaymentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7DF58312DFCF18800E9AD28 /* SendPaymentView.swift */; }; + D7DF58332DFCF18D00E9AD28 /* SendPaymentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7DF58312DFCF18800E9AD28 /* SendPaymentView.swift */; }; + D7DF58342DFCF18D00E9AD28 /* SendPaymentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7DF58312DFCF18800E9AD28 /* SendPaymentView.swift */; }; D7EB00B02CD59C8D00660C07 /* PresentFullScreenItemNotify.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7EB00AF2CD59C8300660C07 /* PresentFullScreenItemNotify.swift */; }; D7EB00B12CD59C8D00660C07 /* PresentFullScreenItemNotify.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7EB00AF2CD59C8300660C07 /* PresentFullScreenItemNotify.swift */; }; D7EDED152B11776B0018B19C /* LibreTranslateServer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3AE45AF5297BB2E700C1D842 /* LibreTranslateServer.swift */; }; @@ -2609,6 +2615,7 @@ D79C4C182AFEB061003A41B4 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; }; D79C4C1C2AFEB061003A41B4 /* DamusNotificationService.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = DamusNotificationService.entitlements; sourceTree = "<group>"; }; D7A0D8742D1FE66A00DCBE59 /* EditPictureControlTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditPictureControlTests.swift; sourceTree = "<group>"; }; + D7AACFFE2E0387B800FB7699 /* LnurlAmountView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LnurlAmountView.swift; sourceTree = "<group>"; }; D7ADD3DD2B53854300F104C4 /* DamusPurpleURL.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DamusPurpleURL.swift; sourceTree = "<group>"; }; D7ADD3DF2B538D4200F104C4 /* DamusPurpleURLSheetView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DamusPurpleURLSheetView.swift; sourceTree = "<group>"; }; D7ADD3E12B538E3500F104C4 /* DamusPurpleVerifyNpubView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DamusPurpleVerifyNpubView.swift; sourceTree = "<group>"; }; @@ -2633,6 +2640,7 @@ D7DB93042D66A43B00DA1EE5 /* Undistractor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Undistractor.swift; sourceTree = "<group>"; }; D7DB93092D69485A00DA1EE5 /* NIP65.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NIP65.swift; sourceTree = "<group>"; }; D7DEEF2E2A8C021E00E0C99F /* NostrEventTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NostrEventTests.swift; sourceTree = "<group>"; }; + D7DF58312DFCF18800E9AD28 /* SendPaymentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SendPaymentView.swift; sourceTree = "<group>"; }; D7EB00AF2CD59C8300660C07 /* PresentFullScreenItemNotify.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PresentFullScreenItemNotify.swift; sourceTree = "<group>"; }; D7EDED1B2B1178FE0018B19C /* NoteContent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoteContent.swift; sourceTree = "<group>"; }; D7EDED1D2B11797D0018B19C /* LongformEvent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LongformEvent.swift; sourceTree = "<group>"; }; @@ -3370,6 +3378,8 @@ 4C7D095A2A098C5C00943473 /* Wallet */ = { isa = PBXGroup; children = ( + D7AACFFE2E0387B800FB7699 /* LnurlAmountView.swift */, + D7DF58312DFCF18800E9AD28 /* SendPaymentView.swift */, 5C8498012D5D14FA00F74FEB /* ZapExplainer.swift */, 5CB017302D4422D600A9ED05 /* NWCSettings.swift */, 5CB0172C2D42C76600A9ED05 /* BalanceView.swift */, @@ -4692,6 +4702,7 @@ 4C363A9028247A1D006E126D /* NostrLink.swift in Sources */, 4C3D52B6298DB4E6001C5831 /* ZapEvent.swift in Sources */, 647D9A8D2968520300A295DE /* SideMenuView.swift in Sources */, + D7AAD0012E0387B800FB7699 /* LnurlAmountView.swift in Sources */, F7F0BA272978E54D009531F3 /* ParticipantsView.swift in Sources */, 4CF0ABE32981BC7D00D66079 /* UserView.swift in Sources */, 4CE0E2AF29A2E82100DB4CA2 /* EventHolder.swift in Sources */, @@ -4999,6 +5010,7 @@ 4C3AC7A728369BA200E1F516 /* SearchHomeView.swift in Sources */, E0EE9DD42B8E5FEA00F3002D /* ImageProcessing.swift in Sources */, 4CB883B0297705DD00DC99E7 /* NoteZapButton.swift in Sources */, + D7DF58342DFCF18D00E9AD28 /* SendPaymentView.swift in Sources */, 4C363A922825FCF2006E126D /* ProfileUpdate.swift in Sources */, 4C3BEFDA281DCA1400B3DE84 /* LikeCounter.swift in Sources */, 4C32B9502A9AD44700DC3548 /* FlatBufferBuilder.swift in Sources */, @@ -5273,6 +5285,7 @@ 82D6FAFD2CD99F7900C925F4 /* IdType.swift in Sources */, 82D6FAFE2CD99F7900C925F4 /* Pubkey.swift in Sources */, 82D6FAFF2CD99F7900C925F4 /* NoteId.swift in Sources */, + D7DF58332DFCF18D00E9AD28 /* SendPaymentView.swift in Sources */, 82D6FB002CD99F7900C925F4 /* Referenced.swift in Sources */, 5CB0172D2D42C76A00A9ED05 /* BalanceView.swift in Sources */, 82D6FB012CD99F7900C925F4 /* Block.swift in Sources */, @@ -5602,6 +5615,7 @@ 82D6FC342CD99F7900C925F4 /* BuilderEventView.swift in Sources */, 82D6FC352CD99F7900C925F4 /* EventProfile.swift in Sources */, 82D6FC362CD99F7900C925F4 /* EventMenu.swift in Sources */, + D7AAD0002E0387B800FB7699 /* LnurlAmountView.swift in Sources */, 82D6FC372CD99F7900C925F4 /* EventMutingContainerView.swift in Sources */, 82D6FC382CD99F7900C925F4 /* ZapEvent.swift in Sources */, 82D6FC392CD99F7900C925F4 /* TextEvent.swift in Sources */, @@ -5950,6 +5964,7 @@ D73E5F0F2C6A97F4007EB227 /* CondensedProfilePicturesView.swift in Sources */, D73E5F102C6A97F4007EB227 /* ProfileEditButton.swift in Sources */, D73E5F112C6A97F4007EB227 /* RelayPaidDetail.swift in Sources */, + D7AACFFF2E0387B800FB7699 /* LnurlAmountView.swift in Sources */, D73E5F122C6A97F4007EB227 /* RelayAuthenticationDetail.swift in Sources */, D73E5F132C6A97F4007EB227 /* RelaySoftwareDetail.swift in Sources */, D73E5F142C6A97F4007EB227 /* RelayAdminDetail.swift in Sources */, @@ -6027,6 +6042,7 @@ D73E5F512C6A97F5007EB227 /* EventDetailView.swift in Sources */, D73E5F522C6A97F5007EB227 /* FollowButtonView.swift in Sources */, D73E5F532C6A97F5007EB227 /* FollowingView.swift in Sources */, + D7DF58322DFCF18D00E9AD28 /* SendPaymentView.swift in Sources */, D73E5F542C6A97F5007EB227 /* LoginView.swift in Sources */, D73E5F552C6A97F5007EB227 /* QRScanNSECView.swift in Sources */, D73E5F562C6A97F5007EB227 /* NoteContentView.swift in Sources */, diff --git a/damus/Models/HomeModel.swift b/damus/Models/HomeModel.swift @@ -292,24 +292,12 @@ class HomeModel: ContactsDelegate { Log.debug("HomeModel: got NWC response, %s not found in the postbox, nothing to remove [%s]", for: .nwc, resp.req_id.hex(), relay.absoluteString) } + damus_state.wallet.handle_nwc_response(response: resp) // This can handle success or error cases + guard resp.response.error == nil else { Log.error("HomeModel: NWC wallet raised an error: %s", for: .nwc, String(describing: resp.response)) WalletConnect.handle_error(zapcache: self.damus_state.zaps, evcache: self.damus_state.events, resp: resp) - if let humanReadableError = resp.response.error?.humanReadableError { - present_sheet(.error(humanReadableError)) - } - return - } - - if resp.response.result_type == .list_transactions { - Log.info("Received NWC transaction list from %s", for: .nwc, relay.absoluteString) - damus_state.wallet.handle_nwc_response(response: resp) - return - } - - if resp.response.result_type == .get_balance { - Log.info("Received NWC balance information from %s", for: .nwc, relay.absoluteString) - damus_state.wallet.handle_nwc_response(response: resp) + return } diff --git a/damus/Models/Mentions.swift b/damus/Models/Mentions.swift @@ -163,6 +163,10 @@ struct LightningInvoice<T> { let payment_hash: Data let created_at: UInt64 + var abbreviated: String { + return self.string.prefix(8) + "…" + self.string.suffix(8) + } + var description_string: String { switch description { case .description(let string): @@ -171,6 +175,17 @@ struct LightningInvoice<T> { return "" } } + + static func from(string: String) -> Invoice? { + // This feels a bit hacky at first, but it is actually clean + // because it reuses the same well-tested parsing logic as the rest of the app, + // avoiding code duplication and utilizing the guarantees acquired from age and testing. + // We could also use the C function `parse_invoice`, but it requires extra C bridging logic. + // NDBTODO: This may need updating on the nostrdb upgrade. + let parsedBlocks = parse_note_content(content: .content(string,nil)).blocks + guard parsedBlocks.count == 1 else { return nil } + return parsedBlocks[0].asInvoice + } } func maybe_pointee<T>(_ p: UnsafeMutablePointer<T>!) -> T? { @@ -192,6 +207,13 @@ enum Amount: Equatable { return format_msats(amt) } } + + func amount_sats() -> Int64? { + switch self { + case .any: nil + case .specific(let amount): amount / 1000 + } + } } func format_msats_abbrev(_ msats: Int64) -> String { diff --git a/damus/Models/WalletModel.swift b/damus/Models/WalletModel.swift @@ -27,6 +27,11 @@ class WalletModel: ObservableObject { @Published private(set) var connect_state: WalletConnectState + /// A dictionary listing continuations waiting for a response for each request note id. + /// + /// Please see the `waitForResponse` method for context. + private var continuations: [NoteId: CheckedContinuation<WalletConnect.Response.Result, any Error>] = [:] + init(state: WalletConnectState, settings: UserSettingsStore) { self.connect_state = state self.previous_state = .none @@ -75,12 +80,16 @@ class WalletModel: ObservableObject { /// /// - Parameter response: The NWC response received from the network func handle_nwc_response(response: WalletConnect.FullWalletResponse) { - switch response.response.result { + if let error = response.response.error { + self.resume(request: response.req_id, throwing: error) + return + } + guard let result = response.response.result else { return } + self.resume(request: response.req_id, with: result) + switch result { case .get_balance(let balanceResp): self.balance = balanceResp.balance / 1000 - case .none: - return - case .some(.pay_invoice(_)): + case .pay_invoice(_): return case .list_transactions(let transactionsResp): self.transactions = transactionsResp.transactions @@ -91,4 +100,41 @@ class WalletModel: ObservableObject { self.transactions = nil self.balance = nil } + + + // MARK: - Async wallet response waiting mechanism + + func waitForResponse(for requestId: NoteId, timeout: Duration = .seconds(10)) async throws -> WalletConnect.Response.Result { + return try await withCheckedThrowingContinuation({ continuation in + self.continuations[requestId] = continuation + + let timeoutTask = Task { + try? await Task.sleep(for: timeout) + self.resume(request: requestId, throwing: WaitError.timeout) // Must resume the continuation exactly once even if there is no response + } + }) + } + + private func resume(request requestId: NoteId, with result: WalletConnect.Response.Result) { + continuations[requestId]?.resume(returning: result) + continuations[requestId] = nil // Never resume a continuation twice + } + + private func resume(request requestId: NoteId, throwing error: any Error) { + if let continuation = continuations[requestId] { + continuation.resume(throwing: error) + continuations[requestId] = nil // Never resume a continuation twice + return // Error will be handled by the listener, no need for the generic error sheet + } + + // No listeners to catch the error, show generic error sheet + if let error = error as? WalletConnect.WalletResponseErr, + let humanReadableError = error.humanReadableError { + present_sheet(.error(humanReadableError)) + } + } + + enum WaitError: Error { + case timeout + } } diff --git a/damus/Types/Block.swift b/damus/Types/Block.swift @@ -202,3 +202,13 @@ extension Block { } } } +extension Block { + var asInvoice: Invoice? { + switch self { + case .invoice(let invoice): + return invoice + default: + return nil + } + } +} diff --git a/damus/Util/WalletConnect/Response.swift b/damus/Util/WalletConnect/Response.swift @@ -52,7 +52,7 @@ extension WalletConnect { let req_id: NoteId let response: Response - init(from event: NostrEvent, nwc: WalletConnect.ConnectURL) async throws(InitializationError) { + init(from event: NostrEvent, nwc: WalletConnect.ConnectURL) throws(InitializationError) { guard event.pubkey == nwc.pubkey else { throw .incorrectAuthorPubkey } guard let referencedNoteId = event.referenced_ids.first else { throw .missingRequestIdReference } @@ -85,7 +85,7 @@ extension WalletConnect { } } - struct WalletResponseErr: Codable { + struct WalletResponseErr: Codable, Error { let code: Code? let message: String? diff --git a/damus/Util/WalletConnect/WalletConnect+.swift b/damus/Util/WalletConnect/WalletConnect+.swift @@ -105,6 +105,28 @@ extension WalletConnect { post.send(ev, to: [url.relay], skip_ephemeral: false, delay: delay, on_flush: on_flush) return ev } + + @MainActor + static func refresh_wallet_information(damus_state: DamusState) async { + damus_state.wallet.resetWalletStateInformation() + await Self.update_wallet_information(damus_state: damus_state) + } + + @MainActor + static func update_wallet_information(damus_state: DamusState) async { + guard let url = damus_state.settings.nostr_wallet_connect, + let nwc = WalletConnectURL(str: url) else { + return + } + + let flusher: OnFlush? = nil + + let delay = 0.0 // We don't need a delay when fetching a transaction list or balance + + WalletConnect.request_transaction_list(url: nwc, pool: damus_state.nostrNetwork.pool, post: damus_state.nostrNetwork.postbox, delay: delay, on_flush: flusher) + WalletConnect.request_balance_information(url: nwc, pool: damus_state.nostrNetwork.pool, post: damus_state.nostrNetwork.postbox, delay: delay, on_flush: flusher) + return + } static func handle_zap_success(state: DamusState, resp: WalletConnect.FullWalletResponse) { // find the pending zap and mark it as pending-confirmed diff --git a/damus/Views/Wallet/BalanceView.swift b/damus/Views/Wallet/BalanceView.swift @@ -17,7 +17,7 @@ struct BalanceView: View { Text("Current balance", comment: "Label for displaying current wallet balance") .foregroundStyle(DamusColors.neutral6) if let balance { - self.numericalBalanceView(text: NumberFormatter.localizedString(from: NSNumber(integerLiteral: Int(balance)), number: .decimal)) + NumericalBalanceView(text: NumberFormatter.localizedString(from: NSNumber(integerLiteral: Int(balance)), number: .decimal), hide_balance: $hide_balance) } else { // Make sure we do not show any numeric value to the user when still loading (or when failed to load) @@ -33,8 +33,13 @@ struct BalanceView: View { } } } +} + +struct NumericalBalanceView: View { + let text: String + @Binding var hide_balance: Bool - func numericalBalanceView(text: String) -> some View { + var body: some View { Group { if hide_balance { Text(verbatim: "*****") diff --git a/damus/Views/Wallet/LnurlAmountView.swift b/damus/Views/Wallet/LnurlAmountView.swift @@ -0,0 +1,246 @@ +// +// LnurlAmountView.swift +// damus +// +// Created by Daniel D’Aquino on 2025-06-18 +// + +import SwiftUI +import Combine + +class LnurlAmountModel: ObservableObject { + @Published var custom_amount: String = "0" + @Published var custom_amount_sats: Int? = 0 + @Published var processing: Bool = false + @Published var error: String? = nil + @Published var invoice: String? = nil + @Published var zap_amounts: [ZapAmountItem] = [] + + func set_defaults(settings: UserSettingsStore) { + let default_amount = settings.default_zap_amount + custom_amount = String(default_amount) + custom_amount_sats = default_amount + zap_amounts = get_zap_amount_items(default_amount) + } +} + +/// Enables the user to enter a Bitcoin amount to be sent. Based on `CustomizeZapView`. +struct LnurlAmountView: View { + let damus_state: DamusState + let lnurlString: String + let onInvoiceFetched: (Invoice) -> Void + let onCancel: () -> Void + + @StateObject var model: LnurlAmountModel = LnurlAmountModel() + @Environment(\.colorScheme) var colorScheme + @FocusState var isAmountFocused: Bool + + init(damus_state: DamusState, lnurlString: String, onInvoiceFetched: @escaping (Invoice) -> Void, onCancel: @escaping () -> Void) { + self.damus_state = damus_state + self.lnurlString = lnurlString + self.onInvoiceFetched = onInvoiceFetched + self.onCancel = onCancel + } + + func AmountButton(zapAmountItem: ZapAmountItem) -> some View { + let isSelected = model.custom_amount_sats == zapAmountItem.amount + + return Button(action: { + model.custom_amount_sats = zapAmountItem.amount + model.custom_amount = String(zapAmountItem.amount) + }) { + let fmt = format_msats_abbrev(Int64(zapAmountItem.amount) * 1000) + Text(verbatim: "\(zapAmountItem.icon)\n\(fmt)") + .contentShape(Rectangle()) + .font(.headline) + .frame(width: 70, height: 70) + .foregroundColor(DamusColors.adaptableBlack) + .background(isSelected ? DamusColors.adaptableWhite : DamusColors.adaptableGrey) + .cornerRadius(15) + .overlay(RoundedRectangle(cornerRadius: 15) + .stroke(DamusColors.purple.opacity(isSelected ? 1.0 : 0.0), lineWidth: 2)) + } + } + + func amount_parts(_ n: Int) -> [ZapAmountItem] { + var i: Int = -1 + let start = n * 4 + let end = start + 4 + + return model.zap_amounts.filter { _ in + i += 1 + return i >= start && i < end + } + } + + func AmountsPart(n: Int) -> some View { + HStack(alignment: .center, spacing: 15) { + ForEach(amount_parts(n)) { entry in + AmountButton(zapAmountItem: entry) + } + } + } + + var AmountGrid: some View { + VStack { + AmountsPart(n: 0) + + AmountsPart(n: 1) + } + .padding(10) + } + + var CustomAmountTextField: some View { + VStack(alignment: .center, spacing: 0) { + TextField("", text: $model.custom_amount) + .focused($isAmountFocused) + .task { + self.isAmountFocused = true + } + .font(.system(size: 72, weight: .heavy)) + .minimumScaleFactor(0.01) + .keyboardType(.numberPad) + .multilineTextAlignment(.center) + .onChange(of: model.custom_amount) { newValue in + if let parsed = handle_string_amount(new_value: newValue) { + model.custom_amount = parsed.formatted() + model.custom_amount_sats = parsed + } else { + model.custom_amount = "0" + model.custom_amount_sats = nil + } + } + let noun = pluralizedString(key: "sats", count: model.custom_amount_sats ?? 0) + Text(noun) + .font(.system(size: 18, weight: .heavy)) + } + } + + func fetchInvoice() { + guard let amount = model.custom_amount_sats, amount > 0 else { + model.error = NSLocalizedString("Please enter a valid amount", comment: "Error message when no valid amount is entered for LNURL payment") + return + } + + model.processing = true + model.error = nil + + Task { @MainActor in + // For LNURL payments without zaps, we use nil for zapreq and comment + // We just need the invoice for payment + let msats = Int64(amount) * 1000 + + // First get the payment request from the LNURL + guard let payreq = await fetch_static_payreq(lnurlString) else { + model.processing = false + model.error = NSLocalizedString("Error fetching LNURL payment information", comment: "Error message when LNURL fetch fails") + return + } + + // Then fetch the invoice with the amount + guard let invoiceStr = await fetch_zap_invoice(payreq, zapreq: nil, msats: msats, zap_type: .non_zap, comment: nil) else { + model.processing = false + model.error = NSLocalizedString("Error fetching lightning invoice", comment: "Error message when there was an error fetching a lightning invoice") + return + } + + // Decode the invoice to validate it + guard let invoice = decode_bolt11(invoiceStr) else { + model.processing = false + model.error = NSLocalizedString("Invalid lightning invoice received", comment: "Error message when the lightning invoice received from LNURL is invalid") + return + } + + // All good, pass the invoice back to the parent view + model.processing = false + onInvoiceFetched(invoice) + } + } + + var PayButton: some View { + VStack { + if model.processing { + Text("Processing...", comment: "Text to indicate that the app is in the process of fetching an invoice.") + .padding() + ProgressView() + } else { + Button(action: { + fetchInvoice() + }) { + HStack { + Text("Continue", comment: "Button to proceed with LNURL payment process.") + .font(.system(size: 20, weight: .bold)) + } + .frame(minWidth: 300, maxWidth: .infinity, alignment: .center) + } + .buttonStyle(GradientButtonStyle()) + .disabled(model.custom_amount_sats == 0 || model.custom_amount == "0") + .opacity(model.custom_amount_sats == 0 || model.custom_amount == "0" ? 0.5 : 1.0) + .padding(10) + } + + if let error = model.error { + Text(error) + .foregroundColor(.red) + .padding() + } + } + } + + var CancelButton: some View { + Button(action: onCancel) { + HStack { + Text("Cancel", comment: "Button to cancel the LNURL payment process.") + .font(.headline) + .padding() + } + .frame(minWidth: 300, maxWidth: .infinity, alignment: .center) + } + .buttonStyle(NeutralButtonStyle()) + .padding() + } + + var body: some View { + VStack(alignment: .center, spacing: 20) { + ScrollView { + VStack { + Text("Enter Amount", comment: "Header text for LNURL payment amount entry screen") + .font(.title) + .fontWeight(.bold) + .padding() + + Text("How much would you like to send?", comment: "Instruction text for LNURL payment amount") + .font(.headline) + .multilineTextAlignment(.center) + .padding(.bottom) + + CustomAmountTextField + + AmountGrid + + PayButton + + CancelButton + } + } + } + .onAppear { + model.set_defaults(settings: damus_state.settings) + } + .onTapGesture { + hideKeyboard() + } + } +} + +struct LnurlAmountView_Previews: PreviewProvider { + static var previews: some View { + LnurlAmountView( + damus_state: test_damus_state, + lnurlString: "lnurl1dp68gurn8ghj7um9wfmxjcm99e3k7mf0v9cxj0m385ekvcenxc6r2c35xvukxefcv5mkvv34x5ekzd3ev56nyd3hxqurzepexejxxepnxscrvwfnv9nxzcn9xq6xyefhvgcxxcmyxymnserxfq5fns", + onInvoiceFetched: { _ in }, + onCancel: {} + ) + .frame(width: 400, height: 600) + } +} diff --git a/damus/Views/Wallet/SendPaymentView.swift b/damus/Views/Wallet/SendPaymentView.swift @@ -0,0 +1,371 @@ +// +// SendPaymentView.swift +// damus +// +// Created by Daniel D’Aquino on 2025-06-13. +// + +import SwiftUI +import CodeScanner + +fileprivate let SEND_PAYMENT_TIMEOUT: Duration = .seconds(10) + +/// A view that allows a user to pay a lightning invoice +struct SendPaymentView: View { + + // MARK: - Helper structures + + /// Represents the state of the invoice payment process + enum SendState { + case enterInvoice(scannerMessage: String?) + case confirmPayment(invoice: Invoice) + case enterLnurlAmount(lnurl: String) + case processing + case completed + case failed(error: HumanReadableError) + } + + typealias HumanReadableError = ErrorView.UserPresentableError + + + // MARK: - Immutable members + + let damus_state: DamusState + let model: WalletModel + let nwc: WalletConnectURL + @Environment(\.dismiss) var dismiss + + + // MARK: - State management + + @State private var sendState: SendState = .enterInvoice(scannerMessage: nil) { + didSet { + switch sendState { + case .enterInvoice, .confirmPayment, .processing, .enterLnurlAmount: + break + case .completed: + // Refresh wallet to reflect new balance after payment + Task { await WalletConnect.refresh_wallet_information(damus_state: damus_state) } + case .failed: + // Even when a wallet says it has failed, update balance just in case it is a false negative, + // This might prevent the user from accidentally sending a payment twice in case of a bug. + Task { await WalletConnect.refresh_wallet_information(damus_state: damus_state) } + } + } + } + var isShowingScanner: Bool { + if case .enterInvoice = sendState { true } else { false } + } + + + // MARK: - Views + + var body: some View { + VStack(alignment: .center) { + switch sendState { + case .enterInvoice(let scannerMessage): + invoiceInputView(scannerMessage: scannerMessage) + .padding(40) + case .confirmPayment(let invoice): + confirmationView(invoice: invoice) + .padding(40) + case .enterLnurlAmount(let lnurl): + LnurlAmountView( + damus_state: damus_state, + lnurlString: lnurl, + onInvoiceFetched: { invoice in + sendState = .confirmPayment(invoice: invoice) + }, + onCancel: { + sendState = .enterInvoice(scannerMessage: nil) + } + ) + case .processing: + processingView + .padding(40) + case .completed: + completedView + .padding(40) + case .failed(error: let error): + failedView(error: error) + } + } + } + + func invoiceInputView(scannerMessage: String?) -> some View { + VStack(spacing: 20) { + Text("Scan Lightning Invoice", comment: "Title for the invoice scanning screen") + .font(.title2) + .bold() + + CodeScannerView( + codeTypes: [.qr], + scanMode: .continuous, + showViewfinder: true, // The scan only seems to work if it fits the bounding box, so let's make this visible to hint that to the users + simulatedData: "lightning:lnbc100n1p357sl0sp5t9n56wdztun39lgdqlr30xqwksg3k69q4q2rkr52aplujw0esn0qpp5mrqgljk62z20q4nvgr6lzcyn6fhylzccwdvu4k77apg3zmrkujjqdpzw35xjueqd9ejqcfqv3jhxcmjd9c8g6t0dcxqyjw5qcqpjrzjqt56h4gvp5yx36u2uzqa6qwcsk3e2duunfxppzj9vhypc3wfe2wswz607uqq3xqqqsqqqqqqqqqqqlqqyg9qyysgqagx5h20aeulj3gdwx3kxs8u9f4mcakdkwuakasamm9562ffyr9en8yg20lg0ygnr9zpwp68524kmda0t5xp2wytex35pu8hapyjajxqpsql29r", + completion: handleScan + ) + .frame(height: 300) + .clipShape(RoundedRectangle(cornerRadius: 20)) + .overlay( + RoundedRectangle(cornerRadius: 20) + .stroke(Color.accentColor, lineWidth: 2) + ) + .padding(.horizontal) + + VStack(spacing: 15) { + Button(action: { + if let pastedInvoice = getPasteboardContent() { + processUserInput(pastedInvoice) + } + }) { + HStack { + Image(systemName: "doc.on.clipboard") + Text("Paste from Clipboard", comment: "Button to paste invoice from clipboard") + } + .frame(minWidth: 250, maxWidth: .infinity, alignment: .center) + .padding() + } + .buttonStyle(NeutralButtonStyle()) + .accessibilityLabel(NSLocalizedString("Paste invoice from clipboard", comment: "Accessibility label for the invoice paste button")) + } + .padding(.horizontal) + + if let scannerMessage { + Text(scannerMessage) + .foregroundColor(.secondary) + .padding(.top, 10) + .multilineTextAlignment(.center) + } + + Spacer() + } + } + + func confirmationView(invoice: Invoice) -> some View { + let insufficientFunds: Bool = (invoice.amount.amount_sats() ?? 0) > (model.balance ?? 0) + return VStack(spacing: 20) { + Text("Confirm Payment", comment: "Title for payment confirmation screen") + .font(.title2) + .bold() + + VStack(spacing: 15) { + Text("Amount", comment: "Label for invoice payment amount in confirmation screen") + .font(.headline) + .foregroundStyle(.secondary) + + if case .specific(let amount) = invoice.amount { + NumericalBalanceView(text: NumberFormatter.localizedString(from: NSNumber(value: (Double(amount) / 1000.0)), number: .decimal), hide_balance: .constant(false)) + } + + Text("Bolt11 Invoice", comment: "Label for the bolt11 invoice string in confirmation screen") + .font(.headline) + .foregroundStyle(.secondary) + + Text(verbatim: invoice.abbreviated) + .font(.system(.body, design: .monospaced)) + .padding() + .background(DamusColors.adaptableGrey) + .cornerRadius(10) + .frame(maxWidth: .infinity) + } + + HStack(spacing: 15) { + Button(action: { + sendState = .enterInvoice(scannerMessage: nil) + }) { + Text("Back", comment: "Button to go back to invoice input") + .font(.headline) + .frame(minWidth: 140) + .padding() + } + .buttonStyle(NeutralButtonStyle()) + + Button(action: { + sendState = .processing + + // Process payment + guard let payRequestEv = WalletConnect.pay(url: nwc, pool: damus_state.nostrNetwork.pool, post: damus_state.nostrNetwork.postbox, invoice: invoice.string, zap_request: nil, delay: nil) else { + sendState = .failed(error: .init( + user_visible_description: NSLocalizedString("The payment request could not be made to your wallet provider.", comment: "A human-readable error message"), + tip: NSLocalizedString("Check if your wallet looks configured correctly and try again. If the error persists, please contact support.", comment: "A human-readable tip for an error when a payment request cannot be made to a wallet."), + technical_info: "Cannot form Nostr Event to send to the NWC provider when calling `pay` from the \"send payment\" feature. Wallet provider relay: \"\(nwc.relay)\"" + )) + return + } + Task { + do { + let result = try await model.waitForResponse(for: payRequestEv.id, timeout: SEND_PAYMENT_TIMEOUT) + guard case .pay_invoice(_) = result else { + sendState = .failed(error: .init( + user_visible_description: NSLocalizedString("Received an incorrect or unexpected response from the wallet provider. This looks like an issue with your wallet provider.", comment: "A human-readable error message"), + tip: NSLocalizedString("Try again. If the error persists, please contact your wallet provider and/or our support team.", comment: "A human-readable tip for an error when a payment request cannot be made to a wallet."), + technical_info: "Expected a `pay_invoice` response for the request, but received a different type of response from the NWC wallet provider. Wallet provider relay: \"\(nwc.relay)\"" + )) + return + } + sendState = .completed + } + catch { + if let error = error as? WalletModel.WaitError { + switch error { + case .timeout: + sendState = .failed(error: .init( + user_visible_description: NSLocalizedString("The payment request did not receive a response and the request timed-out.", comment: "A human-readable error message"), + tip: NSLocalizedString("Check if the invoice is valid, your wallet is online, configured correctly, and try again. If the error persists, please contact support and/or your wallet provider.", comment: "A human-readable tip guiding the user on what to do when seeing a timeout error while sending a wallet payment."), + technical_info: "Payment request timed-out. Wallet provider relay: \"\(nwc.relay)\"" + )) + } + } + else { + sendState = .failed(error: .init( + user_visible_description: NSLocalizedString("An unexpected error occurred.", comment: "A human-readable error message"), + tip: NSLocalizedString("Please try again. If the error persists, please contact support.", comment: "A human-readable tip guiding the user on what to do when seeing an unexpected error while sending a wallet payment."), + technical_info: "Unexpected error thrown while waiting for payment request response. Wallet provider relay: \"\(nwc.relay)\"; Error: \(error)" + )) + } + } + } + }) { + Text("Confirm", comment: "Button to confirm payment") + .font(.headline) + .frame(minWidth: 140) + .padding() + } + .buttonStyle(GradientButtonStyle(padding: 0)) + .disabled(insufficientFunds) + .opacity(insufficientFunds ? 0.5 : 1.0) + } + + if insufficientFunds { + Text("You do not have enough funds to pay for this invoice.", comment: "Label on invoice payment screen, indicating user has insufficient funds") + .foregroundColor(.secondary) + .padding(.top, 10) + .multilineTextAlignment(.center) + } + + Spacer() + } + } + + var processingView: some View { + VStack(spacing: 30) { + Text("Processing Payment", comment: "Title for payment processing screen") + .font(.title2) + .bold() + + ProgressView() + .scaleEffect(1.5) + .padding() + + Text("Please wait while your payment is being processed…", comment: "Message while payment is being processed") + .multilineTextAlignment(.center) + .foregroundStyle(.secondary) + .padding() + + Spacer() + } + } + + var completedView: some View { + VStack(spacing: 30) { + Image(systemName: "checkmark.circle.fill") + .resizable() + .frame(width: 80, height: 80) + .foregroundColor(.green) + + Text("Payment Sent!", comment: "Title for successful payment screen") + .font(.title2) + .bold() + + Text("Your payment has been successfully sent.", comment: "Message for successful payment") + .multilineTextAlignment(.center) + .foregroundStyle(.secondary) + .padding() + + Button(action: { + dismiss() + }) { + Text("Done", comment: "Button to dismiss successful payment screen") + .font(.headline) + .frame(minWidth: 200) + } + .buttonStyle(GradientButtonStyle()) + + Spacer() + } + } + + func failedView(error: HumanReadableError) -> some View { + ScrollView { + VStack { + ErrorView(damus_state: damus_state, error: error) + + Button(action: { + sendState = .enterInvoice(scannerMessage: nil) + }) { + Text("Try Again", comment: "Button to retry payment") + .font(.headline) + .frame(minWidth: 200) + .padding() + } + .buttonStyle(GradientButtonStyle(padding: 0)) + } + } + } + + func handleScan(result: Result<ScanResult, ScanError>) { + switch result { + case .success(let result): + processUserInput(result.string) + case .failure(let error): + sendState = .enterInvoice(scannerMessage: NSLocalizedString("Failed to scan QR code, please try again.", comment: "Error message for failed QR scan")) + } + } + + func processUserInput(_ text: String) { + if let result = parseScanData(text) { + switch result { + case .invoice(let invoice): + if invoice.amount == .any { + sendState = .enterInvoice(scannerMessage: NSLocalizedString("Sorry, we do not support paying invoices without amount yet. Please scan an invoice with an amount.", comment: "A user-readable message indicating that the lightning invoice they scanned or pasted is not supported and is missing an amount.")) + } else { + sendState = .confirmPayment(invoice: invoice) + } + case .lnurl(let lnurlString): + sendState = .enterLnurlAmount(lnurl: lnurlString) + } + } else { + sendState = .enterInvoice(scannerMessage: NSLocalizedString("This does not appear to be a valid Lightning invoice or LNURL.", comment: "A user-readable message indicating that the scanned or pasted content was not a valid lightning invoice or LNURL.")) + } + } + + func parseScanData(_ text: String) -> ScanData? { + let processedString = text.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + + if let invoice = Invoice.from(string: processedString) { + return .invoice(invoice) + } + + if let _ = processedString.range(of: "^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$", options: .regularExpression) { + guard let lnurl = lnaddress_to_lnurl(processedString) else { return nil } + return .lnurl(lnurl) + } + + if processedString.hasPrefix("lnurl") { + return .lnurl(processedString) + } + + return nil + } + + enum ScanData { + case invoice(Invoice) + case lnurl(String) + } + + // Helper function to get pasteboard content + func getPasteboardContent() -> String? { + return UIPasteboard.general.string + } +} diff --git a/damus/Views/Wallet/WalletView.swift b/damus/Views/Wallet/WalletView.swift @@ -12,6 +12,7 @@ let WALLET_WARNING_THRESHOLD: UInt64 = 100000 struct WalletView: View { let damus_state: DamusState @State var show_settings: Bool = false + @State var show_send_sheet: Bool = false @ObservedObject var model: WalletModel @ObservedObject var settings: UserSettingsStore @State private var showBalance: Bool = false @@ -59,6 +60,19 @@ struct WalletView: View { VStack(spacing: 5) { BalanceView(balance: model.balance, hide_balance: $settings.hide_wallet_balance) + + Button(action: { + show_send_sheet = true + }) { + HStack { + Image(systemName: "paperplane.fill") + Text("Send", comment: "Button label to send bitcoin payment from wallet") + .font(.headline) + } + .padding(.horizontal, 10) + } + .buttonStyle(GradientButtonStyle()) + .padding(.bottom, 20) TransactionsView(damus_state: damus_state, transactions: model.transactions, hide_balance: $settings.hide_wallet_balance) } @@ -104,23 +118,17 @@ struct WalletView: View { .presentationDragIndicator(.visible) .presentationDetents([.large]) } + .sheet(isPresented: $show_send_sheet) { + SendPaymentView(damus_state: damus_state, model: model, nwc: nwc) + .presentationDragIndicator(.visible) + .presentationDetents([.large]) + } } } @MainActor func updateWalletInformation() async { - guard let url = damus_state.settings.nostr_wallet_connect, - let nwc = WalletConnectURL(str: url) else { - return - } - - let flusher: OnFlush? = nil - - let delay = 0.0 // We don't need a delay when fetching a transaction list or balance - - WalletConnect.request_transaction_list(url: nwc, pool: damus_state.nostrNetwork.pool, post: damus_state.nostrNetwork.postbox, delay: delay, on_flush: flusher) - WalletConnect.request_balance_information(url: nwc, pool: damus_state.nostrNetwork.pool, post: damus_state.nostrNetwork.postbox, delay: delay, on_flush: flusher) - return + await WalletConnect.update_wallet_information(damus_state: damus_state) } } diff --git a/damusTests/InvoiceTests.swift b/damusTests/InvoiceTests.swift @@ -9,18 +9,6 @@ import XCTest @testable import damus -extension Block { - var asInvoice: Invoice? { - switch self { - case .invoice(let invoice): - return invoice - default: - return nil - } - } -} - - final class InvoiceTests: XCTestCase { override func setUpWithError() throws {