damus

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

commit 692d29942b540e51a525b10c158738a8799ffe24
parent 139df33cb7ffa317ea0969638b13ba59cd168789
Author: Daniel D’Aquino <daniel@daquino.me>
Date:   Mon, 23 Oct 2023 23:32:55 +0000

zaps: Implement single-tap zap on profile action sheet and fix zap fallthrough on default settings

This commit implements a single-tap zap on the profile action sheet and fixes an issue where zapping would silently fail on default settings if the user had no lightning wallet installed in their system.

Testing
-------

Configurations:
- iPhone 13 Mini (physical device) on iOS 17.0.2 with NWC wallet attached
- iPhone 15 Pro (simulator) on iOS 17.0.1 with no lightning wallet nor NWC

Damus: This commit

Coverage:
- Zapping using NWC connected wallet: PASS (Zaps and shows UX feedback of the completed action)
- Zapping under default settings and no lightning wallet: PASS (Shows the wallet selector invoice view)
- Long press on zap button brings custom zap view

Regression testing
------------------

Preconditions: iPhone 15 Pro (simulator) on iOS 17.0.1 with no lightning wallet nor NWC

Coverage:
- Zapping user on their full profile shows wallet selector. PASS
- On-post invoice shows wallet selector. PASS

Closes: https://github.com/damus-io/damus/issues/1634
Changelog-Changed: Zap button on profile action sheet now zaps with a single click, while a long press brings custom zap view
Changelog-Fixed: Fixed an issue where zapping would silently fail on default settings if the user does not have a lightning wallet preinstalled on their device.
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
Reviewed-by: William Casarin <jb55@jb55.com>
Signed-off-by: William Casarin <jb55@jb55.com>

Diffstat:
Mdamus/Components/InvoiceView.swift | 22++++++++++++++++------
Mdamus/Components/NoteZapButton.swift | 13+++++++++++++
Mdamus/ContentView.swift | 7++++++-
Mdamus/Models/Wallet.swift | 2+-
Mdamus/Views/Images/ImageContextMenuModifier.swift | 7++++++-
Mdamus/Views/ProfileActionSheetView.swift | 155+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------
Mdamus/Views/SelectWalletView.swift | 6++++--
Mdamus/Views/Zaps/CustomizeZapView.swift | 20++++++++------------
8 files changed, 195 insertions(+), 37 deletions(-)

diff --git a/damus/Components/InvoiceView.swift b/damus/Components/InvoiceView.swift @@ -39,7 +39,12 @@ struct InvoiceView: View { if settings.show_wallet_selector { present_sheet(.select_wallet(invoice: invoice.string)) } else { - open_with_wallet(wallet: settings.default_wallet.model, invoice: invoice.string) + do { + try open_with_wallet(wallet: settings.default_wallet.model, invoice: invoice.string) + } + catch { + present_sheet(.select_wallet(invoice: invoice.string)) + } } } label: { RoundedRectangle(cornerRadius: 20, style: .circular) @@ -82,21 +87,26 @@ struct InvoiceView: View { } } -func open_with_wallet(wallet: Wallet.Model, invoice: String) { +enum OpenWalletError: Error { + case no_wallet_to_open + case store_link_invalid + case system_cannot_open_store_link +} + +func open_with_wallet(wallet: Wallet.Model, invoice: String) throws { if let url = URL(string: "\(wallet.link)\(invoice)"), UIApplication.shared.canOpenURL(url) { UIApplication.shared.open(url) } else { guard let store_link = wallet.appStoreLink else { - // TODO: do something here if we don't have an appstore link - return + throw OpenWalletError.no_wallet_to_open } guard let url = URL(string: store_link) else { - return + throw OpenWalletError.store_link_invalid } guard UIApplication.shared.canOpenURL(url) else { - return + throw OpenWalletError.system_cannot_open_store_link } UIApplication.shared.open(url) diff --git a/damus/Components/NoteZapButton.swift b/damus/Components/NoteZapButton.swift @@ -18,6 +18,19 @@ enum ZappingError { case bad_lnurl case canceled case send_failed + + func humanReadableMessage() -> String { + switch self { + case .fetching_invoice: + return NSLocalizedString("Error fetching lightning invoice", comment: "Message to display when there was an error fetching a lightning invoice while attempting to zap.") + case .bad_lnurl: + return NSLocalizedString("Invalid lightning address", comment: "Message to display when there was an error attempting to zap due to an invalid lightning address.") + case .canceled: + return NSLocalizedString("Zap attempt from connected wallet was canceled.", comment: "Message to display when a zap from the user's connected wallet was canceled.") + case .send_failed: + return NSLocalizedString("Zap attempt from connected wallet failed.", comment: "Message to display when sending a zap from the user's connected wallet failed.") + } + } } struct ZappingEvent { diff --git a/damus/ContentView.swift b/damus/ContentView.swift @@ -448,7 +448,12 @@ struct ContentView: View { present_sheet(.select_wallet(invoice: inv)) } else { let wallet = damus_state!.settings.default_wallet.model - open_with_wallet(wallet: wallet, invoice: inv) + do { + try open_with_wallet(wallet: wallet, invoice: inv) + } + catch { + present_sheet(.select_wallet(invoice: inv)) + } } case .sent_from_nwc: break diff --git a/damus/Models/Wallet.swift b/damus/Models/Wallet.swift @@ -51,7 +51,7 @@ enum Wallet: String, CaseIterable, Identifiable, StringCodable { switch self { case .system_default_wallet: return .init(index: -1, tag: "systemdefaultwallet", displayName: NSLocalizedString("Local default", comment: "Dropdown option label for system default for Lightning wallet."), - link: "lightning:", appStoreLink: "lightning:", image: "") + link: "lightning:", appStoreLink: nil, image: "") case .strike: return .init(index: 0, tag: "strike", displayName: "Strike", link: "strike:", appStoreLink: "https://apps.apple.com/us/app/strike-bitcoin-payments/id1488724463", image: "strike") diff --git a/damus/Views/Images/ImageContextMenuModifier.swift b/damus/Views/Images/ImageContextMenuModifier.swift @@ -61,7 +61,12 @@ struct ImageContextMenuModifier: ViewModifier { no_link_found.toggle() } else { if qrCodeLink.contains("lnurl") { - open_with_wallet(wallet: settings.default_wallet.model, invoice: qrCodeLink) + do { + try open_with_wallet(wallet: settings.default_wallet.model, invoice: qrCodeLink) + } + catch { + present_sheet(.select_wallet(invoice: qrCodeLink)) + } } else if let _ = URL(string: qrCodeLink) { open_link_confirm.toggle() } diff --git a/damus/Views/ProfileActionSheetView.swift b/damus/Views/ProfileActionSheetView.swift @@ -59,20 +59,7 @@ struct ProfileActionSheetView: View { var zapButton: some View { if let lnurl = self.profile_data()?.lnurl, lnurl != "" { - return AnyView( - VStack(alignment: .center, spacing: 10) { - ProfileZapLinkView(damus_state: damus_state, pubkey: self.profile.pubkey, action: { dismiss() }) { reactions_enabled, lud16, lnurl in - Image(reactions_enabled ? "zap.fill" : "zap") - .foregroundColor(reactions_enabled ? .orange : Color.primary) - .profile_button_style(scheme: colorScheme) - } - .buttonStyle(NeutralCircleButtonStyle()) - - Text(NSLocalizedString("Zap", comment: "Button label that allows the user to zap (i.e. send a Bitcoin tip via the lightning network) the user shown on-screen")) - .foregroundStyle(.secondary) - .font(.caption) - } - ) + return AnyView(ProfileActionSheetZapButton(damus_state: damus_state, profile: profile, lnurl: lnurl)) } else { return AnyView(EmptyView()) @@ -142,6 +129,146 @@ struct ProfileActionSheetView: View { } } +fileprivate struct ProfileActionSheetZapButton: View { + enum ZappingState: Equatable { + case not_zapped + case zapping + case zap_success + case zap_failure(error: ZappingError) + + func error_message() -> String? { + switch self { + case .zap_failure(let error): + return error.humanReadableMessage() + default: + return nil + } + } + } + + let damus_state: DamusState + @StateObject var profile: ProfileModel + let lnurl: String + @State var zap_state: ZappingState = .not_zapped + @State var show_error_alert: Bool = false + + @Environment(\.colorScheme) var colorScheme + + func receive_zap(zap_ev: ZappingEvent) { + print("Received zap event") + guard zap_ev.target == ZapTarget.profile(self.profile.pubkey) else { + return + } + + switch zap_ev.type { + case .failed(let err): + zap_state = .zap_failure(error: err) + show_error_alert = true + break + case .got_zap_invoice(let inv): + if damus_state.settings.show_wallet_selector { + present_sheet(.select_wallet(invoice: inv)) + } else { + let wallet = damus_state.settings.default_wallet.model + do { + try open_with_wallet(wallet: wallet, invoice: inv) + } + catch { + present_sheet(.select_wallet(invoice: inv)) + } + } + break + case .sent_from_nwc: + zap_state = .zap_success + break + } + } + + var button_label: String { + switch zap_state { + case .not_zapped: + return NSLocalizedString("Zap", comment: "Button label that allows the user to zap (i.e. send a Bitcoin tip via the lightning network) the user shown on-screen") + case .zapping: + return NSLocalizedString("Zapping", comment: "Button label indicating that a zap action is in progress (i.e. the user is currently sending a Bitcoin tip via the lightning network to the user shown on-screen) ") + case .zap_success: + return NSLocalizedString("Zapped!", comment: "Button label indicating that a zap action was successful (i.e. the user is successfully sent a Bitcoin tip via the lightning network to the user shown on-screen) ") + case .zap_failure(_): + return NSLocalizedString("Zap failed", comment: "Button label indicating that a zap action was unsuccessful (i.e. the user was unable to send a Bitcoin tip via the lightning network to the user shown on-screen) ") + } + } + + var body: some View { + VStack(alignment: .center, spacing: 10) { + Button( + action: { + send_zap(damus_state: damus_state, target: .profile(self.profile.pubkey), lnurl: lnurl, is_custom: false, comment: nil, amount_sats: nil, zap_type: damus_state.settings.default_zap_type) + zap_state = .zapping + }, + label: { + switch zap_state { + case .not_zapped: + Image("zap") + .foregroundColor(Color.primary) + .profile_button_style(scheme: colorScheme) + case .zapping: + ProgressView() + .foregroundColor(Color.primary) + .profile_button_style(scheme: colorScheme) + case .zap_success: + Image("checkmark") + .foregroundColor(Color.green) + .profile_button_style(scheme: colorScheme) + case .zap_failure: + Image("close") + .foregroundColor(Color.red) + .profile_button_style(scheme: colorScheme) + } + + } + ) + .disabled({ + switch zap_state { + case .not_zapped: + return false + default: + return true + } + }()) + .buttonStyle(NeutralCircleButtonStyle()) + + Text(button_label) + .foregroundStyle(.secondary) + .font(.caption) + } + .onReceive(handle_notify(.zapping)) { zap_ev in + receive_zap(zap_ev: zap_ev) + } + .simultaneousGesture(LongPressGesture().onEnded {_ in + present_sheet(.zap(target: .profile(self.profile.pubkey), lnurl: lnurl)) + }) + .alert(isPresented: $show_error_alert) { + Alert( + title: Text(NSLocalizedString("Zap failed", comment: "Title of an alert indicating that a zap action failed")), + message: Text(zap_state.error_message() ?? ""), + dismissButton: .default(Text(NSLocalizedString("OK", comment: "Button label to dismiss an error dialog"))) + ) + } + .onChange(of: zap_state) { new_zap_state in + switch new_zap_state { + case .zap_success, .zap_failure: + DispatchQueue.main.asyncAfter(deadline: .now() + 5) { + withAnimation { + zap_state = .not_zapped + } + } + break + default: + break + } + } + } +} + struct InnerHeightPreferenceKey: PreferenceKey { static var defaultValue: CGFloat = .zero static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { diff --git a/damus/Views/SelectWalletView.swift b/damus/Views/SelectWalletView.swift @@ -38,7 +38,8 @@ struct SelectWalletView: View { Section(NSLocalizedString("Select a Lightning wallet", comment: "Title of section for selecting a Lightning wallet to pay a Lightning invoice.")) { List{ Button() { - open_with_wallet(wallet: default_wallet.model, invoice: invoice) + // TODO: Handle cases where wallet cannot be opened by the system + try? open_with_wallet(wallet: default_wallet.model, invoice: invoice) } label: { HStack { Text("Default Wallet", comment: "Button to pay a Lightning invoice with the user's default Lightning wallet.").font(.body).foregroundColor(.blue) @@ -47,7 +48,8 @@ struct SelectWalletView: View { List($allWalletModels) { $wallet in if wallet.index >= 0 { Button() { - open_with_wallet(wallet: wallet, invoice: invoice) + // TODO: Handle cases where wallet cannot be opened by the system + try? open_with_wallet(wallet: wallet, invoice: invoice) } label: { HStack { Image(wallet.image).resizable().frame(width: 32.0, height: 32.0,alignment: .center).cornerRadius(5) diff --git a/damus/Views/Zaps/CustomizeZapView.swift b/damus/Views/Zaps/CustomizeZapView.swift @@ -194,16 +194,7 @@ struct CustomizeZapView: View { switch zap_ev.type { case .failed(let err): - switch err { - case .fetching_invoice: - model.error = NSLocalizedString("Error fetching lightning invoice", comment: "Message to display when there was an error fetching a lightning invoice while attempting to zap.") - case .bad_lnurl: - model.error = NSLocalizedString("Invalid lightning address", comment: "Message to display when there was an error attempting to zap due to an invalid lightning address.") - case .canceled: - model.error = NSLocalizedString("Zap attempt from connected wallet was canceled.", comment: "Message to display when a zap from the user's connected wallet was canceled.") - case .send_failed: - model.error = NSLocalizedString("Zap attempt from connected wallet failed.", comment: "Message to display when sending a zap from the user's connected wallet failed.") - } + model.error = err.humanReadableMessage() break case .got_zap_invoice(let inv): if state.settings.show_wallet_selector { @@ -212,8 +203,13 @@ struct CustomizeZapView: View { } else { end_editing() let wallet = state.settings.default_wallet.model - open_with_wallet(wallet: wallet, invoice: inv) - dismiss() + do { + try open_with_wallet(wallet: wallet, invoice: inv) + dismiss() + } + catch { + present_sheet(.select_wallet(invoice: inv)) + } } case .sent_from_nwc: dismiss()