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>
This commit is contained in:
Daniel D’Aquino
2025-06-13 19:25:22 -07:00
parent f4b1a504a5
commit c92094823e
12 changed files with 769 additions and 47 deletions

View File

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

View File

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

View File

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

View File

@@ -202,3 +202,13 @@ extension Block {
}
}
}
extension Block {
var asInvoice: Invoice? {
switch self {
case .invoice(let invoice):
return invoice
default:
return nil
}
}
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,246 @@
//
// LnurlAmountView.swift
// damus
//
// Created by Daniel DAquino 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)
}
}

View File

@@ -0,0 +1,371 @@
//
// SendPaymentView.swift
// damus
//
// Created by Daniel DAquino 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
}
}

View File

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