NoteZapButton.swift (11778B)
1 // 2 // NoteZapButton.swift 3 // damus 4 // 5 // Created by William Casarin on 2023-01-17. 6 // 7 8 import SwiftUI 9 10 enum ZappingEventType { 11 case failed(ZappingError) 12 case got_zap_invoice(String) 13 case sent_from_nwc 14 } 15 16 enum ZappingError { 17 case fetching_invoice 18 case bad_lnurl 19 case canceled 20 case send_failed 21 22 func humanReadableMessage() -> String { 23 switch self { 24 case .fetching_invoice: 25 return NSLocalizedString("Error fetching lightning invoice", comment: "Message to display when there was an error fetching a lightning invoice while attempting to zap.") 26 case .bad_lnurl: 27 return NSLocalizedString("Invalid lightning address", comment: "Message to display when there was an error attempting to zap due to an invalid lightning address.") 28 case .canceled: 29 return NSLocalizedString("Zap attempt from connected wallet was canceled.", comment: "Message to display when a zap from the user's connected wallet was canceled.") 30 case .send_failed: 31 return NSLocalizedString("Zap attempt from connected wallet failed.", comment: "Message to display when sending a zap from the user's connected wallet failed.") 32 } 33 } 34 } 35 36 struct ZappingEvent { 37 let is_custom: Bool 38 let type: ZappingEventType 39 let target: ZapTarget 40 } 41 42 struct NoteZapButton: View { 43 let damus_state: DamusState 44 let target: ZapTarget 45 let lnurl: String 46 47 @ObservedObject var zaps: ZapsDataModel 48 49 var our_zap: Zapping? { 50 zaps.zaps.first(where: { z in z.request.ev.pubkey == damus_state.pubkey }) 51 } 52 53 var zap_img: String { 54 switch our_zap { 55 case .none: 56 return "zap" 57 case .zap: 58 return "zap.fill" 59 case .pending: 60 return "zap.fill" 61 } 62 } 63 64 var zap_color: Color { 65 if our_zap == nil { 66 return Color.gray 67 } 68 69 // always orange ! 70 return Color.orange 71 } 72 73 func tap() { 74 guard let our_zap else { 75 send_zap(damus_state: damus_state, target: target, lnurl: lnurl, is_custom: false, comment: nil, amount_sats: nil, zap_type: damus_state.settings.default_zap_type) 76 return 77 } 78 79 // we've tapped and we have a zap already... cancel if we can 80 switch our_zap { 81 case .zap: 82 // can't undo a zap we've already sent 83 // if we want to send more zaps we will need to long-press 84 print("cancel_zap: we already have a real zap, can't cancel") 85 break 86 case .pending(let pzap): 87 guard let res = cancel_zap(zap: pzap, box: damus_state.postbox, zapcache: damus_state.zaps, evcache: damus_state.events) else { 88 89 UIImpactFeedbackGenerator(style: .soft).impactOccurred() 90 return 91 } 92 93 UIImpactFeedbackGenerator(style: .rigid).impactOccurred() 94 95 switch res { 96 case .send_err(let cancel_err): 97 switch cancel_err { 98 case .nothing_to_cancel: 99 print("cancel_zap: got nothing_to_cancel in pending") 100 break 101 case .not_delayed: 102 print("cancel_zap: got not_delayed in pending") 103 break 104 case .too_late: 105 print("cancel_zap: got too_late in pending") 106 break 107 } 108 case .already_confirmed: 109 print("cancel_zap: got already_confirmed in pending") 110 break 111 case .not_nwc: 112 print("cancel_zap: got not_nwc in pending") 113 break 114 } 115 } 116 117 118 } 119 120 var body: some View { 121 HStack(spacing: 4) { 122 if !damus_state.settings.nozaps || zaps.zap_total > 0 { 123 Button(action: { 124 }, label: { 125 Image(zap_img) 126 .resizable() 127 .foregroundColor(zap_color) 128 .font(.footnote.weight(.medium)) 129 .aspectRatio(contentMode: .fit) 130 .frame(width:20, height: 20) 131 }) 132 } 133 134 if zaps.zap_total > 0 { 135 Text(verbatim: format_msats_abbrev(zaps.zap_total)) 136 .font(.footnote) 137 .foregroundColor(zap_color) 138 } 139 } 140 .accessibilityLabel(NSLocalizedString("Zap", comment: "Accessibility label for zap button")) 141 .simultaneousGesture(LongPressGesture().onEnded {_ in 142 guard !damus_state.settings.nozaps else { return } 143 144 present_sheet(.zap(target: target, lnurl: lnurl)) 145 }) 146 .highPriorityGesture(TapGesture().onEnded { 147 guard !damus_state.settings.nozaps else { return } 148 149 tap() 150 }) 151 } 152 } 153 154 155 struct ZapButton_Previews: PreviewProvider { 156 static var previews: some View { 157 let pending_zap = PendingZap(amount_msat: 1000, target: ZapTarget.note(id: test_note.id, author: test_note.pubkey), request: .normal(test_zap_request), type: .pub, state: .external(.init(state: .fetching_invoice))) 158 let zaps = ZapsDataModel([.pending(pending_zap)]) 159 160 NoteZapButton(damus_state: test_damus_state, target: ZapTarget.note(id: test_note.id, author: test_note.pubkey), lnurl: "lnurl", zaps: zaps) 161 } 162 } 163 164 165 166 func initial_pending_zap_state(settings: UserSettingsStore) -> PendingZapState { 167 if let url = settings.nostr_wallet_connect, 168 let nwc = WalletConnectURL(str: url) 169 { 170 return .nwc(NWCPendingZapState(state: .fetching_invoice, url: nwc)) 171 } 172 173 return .external(ExtPendingZapState(state: .fetching_invoice)) 174 } 175 176 func send_zap(damus_state: DamusState, target: ZapTarget, lnurl: String, is_custom: Bool, comment: String?, amount_sats: Int?, zap_type: ZapType) { 177 guard let keypair = damus_state.keypair.to_full() else { 178 return 179 } 180 181 // Only take the first 10 because reasons 182 let relays = Array(damus_state.pool.our_descriptors.prefix(10)) 183 let content = comment ?? "" 184 185 guard let mzapreq = make_zap_request_event(keypair: keypair, content: content, relays: relays, target: target, zap_type: zap_type) else { 186 // this should never happen 187 return 188 } 189 190 let amount_msat = Int64(amount_sats ?? damus_state.settings.default_zap_amount) * 1000 191 let pending_zap_state = initial_pending_zap_state(settings: damus_state.settings) 192 let pending_zap = PendingZap(amount_msat: amount_msat, target: target, request: mzapreq, type: zap_type, state: pending_zap_state) 193 let zapreq = mzapreq.potentially_anon_outer_request.ev 194 let reqid = ZapRequestId(from_makezap: mzapreq) 195 196 UIImpactFeedbackGenerator(style: .heavy).impactOccurred() 197 damus_state.add_zap(zap: .pending(pending_zap)) 198 199 Task { @MainActor in 200 guard let payreq = await damus_state.lnurls.lookup_or_fetch(pubkey: target.pubkey, lnurl: lnurl) else { 201 // TODO: show error 202 remove_zap(reqid: reqid, zapcache: damus_state.zaps, evcache: damus_state.events) 203 let typ = ZappingEventType.failed(.bad_lnurl) 204 let ev = ZappingEvent(is_custom: is_custom, type: typ, target: target) 205 notify(.zapping(ev)) 206 return 207 } 208 209 guard let inv = await fetch_zap_invoice(payreq, zapreq: zapreq, msats: amount_msat, zap_type: zap_type, comment: comment) else { 210 remove_zap(reqid: reqid, zapcache: damus_state.zaps, evcache: damus_state.events) 211 let typ = ZappingEventType.failed(.fetching_invoice) 212 let ev = ZappingEvent(is_custom: is_custom, type: typ, target: target) 213 notify(.zapping(ev)) 214 return 215 } 216 217 switch pending_zap_state { 218 case .nwc(let nwc_state): 219 // don't both continuing, user has canceled 220 if case .cancel_fetching_invoice = nwc_state.state { 221 remove_zap(reqid: reqid, zapcache: damus_state.zaps, evcache: damus_state.events) 222 let typ = ZappingEventType.failed(.canceled) 223 let ev = ZappingEvent(is_custom: is_custom, type: typ, target: target) 224 notify(.zapping(ev)) 225 return 226 } 227 228 var flusher: OnFlush? = nil 229 230 // donations are only enabled on one-tap zaps and off appstore 231 if !damus_state.settings.nozaps && !is_custom && damus_state.settings.donation_percent > 0 { 232 flusher = .once({ pe in 233 // send donation zap when the pending zap is flushed, this allows user to cancel and not send a donation 234 Task { @MainActor in 235 await send_donation_zap(pool: damus_state.pool, postbox: damus_state.postbox, nwc: nwc_state.url, percent: damus_state.settings.donation_percent, base_msats: amount_msat) 236 } 237 }) 238 } 239 240 // we don't have a delay on one-tap nozaps (since this will be from customize zap view) 241 let delay = damus_state.settings.nozaps ? nil : 5.0 242 243 let nwc_req = nwc_pay(url: nwc_state.url, pool: damus_state.pool, post: damus_state.postbox, invoice: inv, delay: delay, on_flush: flusher) 244 245 guard let nwc_req, case .nwc(let pzap_state) = pending_zap_state else { 246 print("nwc: failed to send nwc request for zapreq \(reqid.reqid)") 247 248 let typ = ZappingEventType.failed(.send_failed) 249 let ev = ZappingEvent(is_custom: is_custom, type: typ, target: target) 250 notify(.zapping(ev)) 251 return 252 } 253 254 print("nwc: sending request \(nwc_req.id) zap_req_id \(reqid.reqid)") 255 256 if pzap_state.update_state(state: .postbox_pending(nwc_req)) { 257 // we don't need to trigger a ZapsDataModel update here 258 } 259 260 let ev = ZappingEvent(is_custom: is_custom, type: .sent_from_nwc, target: target) 261 notify(.zapping(ev)) 262 263 case .external(let pending_ext): 264 pending_ext.state = .done 265 let ev = ZappingEvent(is_custom: is_custom, type: .got_zap_invoice(inv), target: target) 266 notify(.zapping(ev)) 267 } 268 } 269 270 return 271 } 272 273 enum CancelZapErr { 274 case send_err(CancelSendErr) 275 case already_confirmed 276 case not_nwc 277 } 278 279 func cancel_zap(zap: PendingZap, box: PostBox, zapcache: Zaps, evcache: EventCache) -> CancelZapErr? { 280 guard case .nwc(let nwc_state) = zap.state else { 281 return .not_nwc 282 } 283 284 switch nwc_state.state { 285 case .fetching_invoice: 286 if nwc_state.update_state(state: .cancel_fetching_invoice) { 287 // we don't need to update the ZapsDataModel here 288 } 289 // let the code that retrieves the invoice remove the zap, because 290 // it still needs access to this pending zap to know to cancel 291 292 case .cancel_fetching_invoice: 293 // already cancelling? 294 print("cancel_zap: already cancelling") 295 return nil 296 297 case .confirmed: 298 return .already_confirmed 299 300 case .postbox_pending(let nwc_req): 301 if let err = box.cancel_send(evid: nwc_req.id) { 302 return .send_err(err) 303 } 304 let reqid = ZapRequestId(from_pending: zap) 305 remove_zap(reqid: reqid, zapcache: zapcache, evcache: evcache) 306 307 case .failed: 308 let reqid = ZapRequestId(from_pending: zap) 309 remove_zap(reqid: reqid, zapcache: zapcache, evcache: evcache) 310 } 311 312 return nil 313 }