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:
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -202,3 +202,13 @@ extension Block {
|
||||
}
|
||||
}
|
||||
}
|
||||
extension Block {
|
||||
var asInvoice: Invoice? {
|
||||
switch self {
|
||||
case .invoice(let invoice):
|
||||
return invoice
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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?
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: "*****")
|
||||
|
||||
246
damus/Views/Wallet/LnurlAmountView.swift
Normal file
246
damus/Views/Wallet/LnurlAmountView.swift
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
371
damus/Views/Wallet/SendPaymentView.swift
Normal file
371
damus/Views/Wallet/SendPaymentView.swift
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user