damus

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

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 }