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