damus

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

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 }