From 692d29942b540e51a525b10c158738a8799ffe24 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20D=E2=80=99Aquino?= Date: Mon, 23 Oct 2023 23:32:55 +0000 Subject: [PATCH] zaps: Implement single-tap zap on profile action sheet and fix zap fallthrough on default settings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Reviewed-by: William Casarin Signed-off-by: William Casarin --- damus/Components/InvoiceView.swift | 22 ++- damus/Components/NoteZapButton.swift | 13 ++ damus/ContentView.swift | 7 +- damus/Models/Wallet.swift | 2 +- .../Images/ImageContextMenuModifier.swift | 7 +- damus/Views/ProfileActionSheetView.swift | 155 ++++++++++++++++-- damus/Views/SelectWalletView.swift | 6 +- damus/Views/Zaps/CustomizeZapView.swift | 20 +-- 8 files changed, 195 insertions(+), 37 deletions(-) diff --git a/damus/Components/InvoiceView.swift b/damus/Components/InvoiceView.swift index a38f7bfc..cc99f741 100644 --- 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 index 84f464b1..3feef660 100644 --- 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 index 2ab1cd68..05a23781 100644 --- 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 index 30682dfc..a90b25f0 100644 --- 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 index 167fce03..bf0093a7 100644 --- 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 index d9cc023a..3de8276e 100644 --- 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 index 62433904..310e63be 100644 --- 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 index fb4cef39..e7cce83c 100644 --- 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()