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