This commit implements a new layer called NostrNetworkManager, responsible for managing interactions with the Nostr network, and providing a higher level API that is easier and more secure to use for the layer above it. It also integrates it with the rest of the app, by moving RelayPool and PostBox into NostrNetworkManager, along with all their usages. Changelog-Added: Added NIP-65 relay list support Changelog-Changed: Improved robustness of relay list handling Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
314 lines
12 KiB
Swift
314 lines
12 KiB
Swift
//
|
|
// NoteZapButton.swift
|
|
// damus
|
|
//
|
|
// Created by William Casarin on 2023-01-17.
|
|
//
|
|
|
|
import SwiftUI
|
|
|
|
enum ZappingEventType {
|
|
case failed(ZappingError)
|
|
case got_zap_invoice(String)
|
|
case sent_from_nwc
|
|
}
|
|
|
|
enum ZappingError {
|
|
case fetching_invoice
|
|
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 {
|
|
let is_custom: Bool
|
|
let type: ZappingEventType
|
|
let target: ZapTarget
|
|
}
|
|
|
|
struct NoteZapButton: View {
|
|
let damus_state: DamusState
|
|
let target: ZapTarget
|
|
let lnurl: String
|
|
|
|
@ObservedObject var zaps: ZapsDataModel
|
|
|
|
var our_zap: Zapping? {
|
|
zaps.zaps.first(where: { z in z.request.ev.pubkey == damus_state.pubkey })
|
|
}
|
|
|
|
var zap_img: String {
|
|
switch our_zap {
|
|
case .none:
|
|
return "zap"
|
|
case .zap:
|
|
return "zap.fill"
|
|
case .pending:
|
|
return "zap.fill"
|
|
}
|
|
}
|
|
|
|
var zap_color: Color {
|
|
if our_zap == nil {
|
|
return Color.gray
|
|
}
|
|
|
|
// always orange !
|
|
return Color.orange
|
|
}
|
|
|
|
func tap() {
|
|
guard let our_zap else {
|
|
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)
|
|
return
|
|
}
|
|
|
|
// we've tapped and we have a zap already... cancel if we can
|
|
switch our_zap {
|
|
case .zap:
|
|
// can't undo a zap we've already sent
|
|
// if we want to send more zaps we will need to long-press
|
|
print("cancel_zap: we already have a real zap, can't cancel")
|
|
break
|
|
case .pending(let pzap):
|
|
guard let res = cancel_zap(zap: pzap, box: damus_state.nostrNetwork.postbox, zapcache: damus_state.zaps, evcache: damus_state.events) else {
|
|
|
|
UIImpactFeedbackGenerator(style: .soft).impactOccurred()
|
|
return
|
|
}
|
|
|
|
UIImpactFeedbackGenerator(style: .rigid).impactOccurred()
|
|
|
|
switch res {
|
|
case .send_err(let cancel_err):
|
|
switch cancel_err {
|
|
case .nothing_to_cancel:
|
|
print("cancel_zap: got nothing_to_cancel in pending")
|
|
break
|
|
case .not_delayed:
|
|
print("cancel_zap: got not_delayed in pending")
|
|
break
|
|
case .too_late:
|
|
print("cancel_zap: got too_late in pending")
|
|
break
|
|
}
|
|
case .already_confirmed:
|
|
print("cancel_zap: got already_confirmed in pending")
|
|
break
|
|
case .not_nwc:
|
|
print("cancel_zap: got not_nwc in pending")
|
|
break
|
|
}
|
|
}
|
|
|
|
|
|
}
|
|
|
|
var body: some View {
|
|
HStack(spacing: 4) {
|
|
if !damus_state.settings.nozaps || zaps.zap_total > 0 {
|
|
Button(action: {
|
|
}, label: {
|
|
Image(zap_img)
|
|
.resizable()
|
|
.foregroundColor(zap_color)
|
|
.font(.footnote.weight(.medium))
|
|
.aspectRatio(contentMode: .fit)
|
|
.frame(width:20, height: 20)
|
|
})
|
|
}
|
|
|
|
if zaps.zap_total > 0 {
|
|
Text(verbatim: format_msats_abbrev(zaps.zap_total))
|
|
.font(.footnote)
|
|
.foregroundColor(zap_color)
|
|
}
|
|
}
|
|
.accessibilityLabel(NSLocalizedString("Zap", comment: "Accessibility label for zap button"))
|
|
.simultaneousGesture(LongPressGesture().onEnded {_ in
|
|
guard !damus_state.settings.nozaps else { return }
|
|
|
|
present_sheet(.zap(target: target, lnurl: lnurl))
|
|
})
|
|
.highPriorityGesture(TapGesture().onEnded {
|
|
guard !damus_state.settings.nozaps else { return }
|
|
|
|
tap()
|
|
})
|
|
}
|
|
}
|
|
|
|
|
|
struct ZapButton_Previews: PreviewProvider {
|
|
static var previews: some View {
|
|
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)))
|
|
let zaps = ZapsDataModel([.pending(pending_zap)])
|
|
|
|
NoteZapButton(damus_state: test_damus_state, target: ZapTarget.note(id: test_note.id, author: test_note.pubkey), lnurl: "lnurl", zaps: zaps)
|
|
}
|
|
}
|
|
|
|
|
|
|
|
func initial_pending_zap_state(settings: UserSettingsStore) -> PendingZapState {
|
|
if let url = settings.nostr_wallet_connect,
|
|
let nwc = WalletConnectURL(str: url)
|
|
{
|
|
return .nwc(NWCPendingZapState(state: .fetching_invoice, url: nwc))
|
|
}
|
|
|
|
return .external(ExtPendingZapState(state: .fetching_invoice))
|
|
}
|
|
|
|
func send_zap(damus_state: DamusState, target: ZapTarget, lnurl: String, is_custom: Bool, comment: String?, amount_sats: Int?, zap_type: ZapType) {
|
|
guard let keypair = damus_state.keypair.to_full() else {
|
|
return
|
|
}
|
|
|
|
// Only take the first 10 because reasons
|
|
let relays = Array(damus_state.nostrNetwork.pool.our_descriptors.prefix(10))
|
|
let content = comment ?? ""
|
|
|
|
guard let mzapreq = make_zap_request_event(keypair: keypair, content: content, relays: relays, target: target, zap_type: zap_type) else {
|
|
// this should never happen
|
|
return
|
|
}
|
|
|
|
let amount_msat = Int64(amount_sats ?? damus_state.settings.default_zap_amount) * 1000
|
|
let pending_zap_state = initial_pending_zap_state(settings: damus_state.settings)
|
|
let pending_zap = PendingZap(amount_msat: amount_msat, target: target, request: mzapreq, type: zap_type, state: pending_zap_state)
|
|
let zapreq = mzapreq.potentially_anon_outer_request.ev
|
|
let reqid = ZapRequestId(from_makezap: mzapreq)
|
|
|
|
UIImpactFeedbackGenerator(style: .heavy).impactOccurred()
|
|
damus_state.add_zap(zap: .pending(pending_zap))
|
|
|
|
Task { @MainActor in
|
|
guard let payreq = await damus_state.lnurls.lookup_or_fetch(pubkey: target.pubkey, lnurl: lnurl) else {
|
|
// TODO: show error
|
|
remove_zap(reqid: reqid, zapcache: damus_state.zaps, evcache: damus_state.events)
|
|
let typ = ZappingEventType.failed(.bad_lnurl)
|
|
let ev = ZappingEvent(is_custom: is_custom, type: typ, target: target)
|
|
notify(.zapping(ev))
|
|
return
|
|
}
|
|
|
|
guard let inv = await fetch_zap_invoice(payreq, zapreq: zapreq, msats: amount_msat, zap_type: zap_type, comment: comment) else {
|
|
remove_zap(reqid: reqid, zapcache: damus_state.zaps, evcache: damus_state.events)
|
|
let typ = ZappingEventType.failed(.fetching_invoice)
|
|
let ev = ZappingEvent(is_custom: is_custom, type: typ, target: target)
|
|
notify(.zapping(ev))
|
|
return
|
|
}
|
|
|
|
switch pending_zap_state {
|
|
case .nwc(let nwc_state):
|
|
// don't both continuing, user has canceled
|
|
if case .cancel_fetching_invoice = nwc_state.state {
|
|
remove_zap(reqid: reqid, zapcache: damus_state.zaps, evcache: damus_state.events)
|
|
let typ = ZappingEventType.failed(.canceled)
|
|
let ev = ZappingEvent(is_custom: is_custom, type: typ, target: target)
|
|
notify(.zapping(ev))
|
|
return
|
|
}
|
|
|
|
var flusher: OnFlush? = nil
|
|
|
|
// donations are only enabled on one-tap zaps and off appstore
|
|
if !damus_state.settings.nozaps && !is_custom && damus_state.settings.donation_percent > 0 {
|
|
flusher = .once({ pe in
|
|
// send donation zap when the pending zap is flushed, this allows user to cancel and not send a donation
|
|
Task { @MainActor in
|
|
await WalletConnect.send_donation_zap(pool: damus_state.nostrNetwork.pool, postbox: damus_state.nostrNetwork.postbox, nwc: nwc_state.url, percent: damus_state.settings.donation_percent, base_msats: amount_msat)
|
|
}
|
|
})
|
|
}
|
|
|
|
// we don't have a delay on one-tap nozaps (since this will be from customize zap view)
|
|
let delay = damus_state.settings.nozaps ? nil : 5.0
|
|
|
|
let nwc_req = WalletConnect.pay(url: nwc_state.url, pool: damus_state.nostrNetwork.pool, post: damus_state.nostrNetwork.postbox, invoice: inv, delay: delay, on_flush: flusher)
|
|
|
|
guard let nwc_req, case .nwc(let pzap_state) = pending_zap_state else {
|
|
print("nwc: failed to send nwc request for zapreq \(reqid.reqid)")
|
|
|
|
let typ = ZappingEventType.failed(.send_failed)
|
|
let ev = ZappingEvent(is_custom: is_custom, type: typ, target: target)
|
|
notify(.zapping(ev))
|
|
return
|
|
}
|
|
|
|
print("nwc: sending request \(nwc_req.id) zap_req_id \(reqid.reqid)")
|
|
|
|
if pzap_state.update_state(state: .postbox_pending(nwc_req)) {
|
|
// we don't need to trigger a ZapsDataModel update here
|
|
}
|
|
|
|
let ev = ZappingEvent(is_custom: is_custom, type: .sent_from_nwc, target: target)
|
|
notify(.zapping(ev))
|
|
|
|
case .external(let pending_ext):
|
|
pending_ext.state = .done
|
|
let ev = ZappingEvent(is_custom: is_custom, type: .got_zap_invoice(inv), target: target)
|
|
notify(.zapping(ev))
|
|
}
|
|
}
|
|
|
|
return
|
|
}
|
|
|
|
enum CancelZapErr {
|
|
case send_err(CancelSendErr)
|
|
case already_confirmed
|
|
case not_nwc
|
|
}
|
|
|
|
func cancel_zap(zap: PendingZap, box: PostBox, zapcache: Zaps, evcache: EventCache) -> CancelZapErr? {
|
|
guard case .nwc(let nwc_state) = zap.state else {
|
|
return .not_nwc
|
|
}
|
|
|
|
switch nwc_state.state {
|
|
case .fetching_invoice:
|
|
if nwc_state.update_state(state: .cancel_fetching_invoice) {
|
|
// we don't need to update the ZapsDataModel here
|
|
}
|
|
// let the code that retrieves the invoice remove the zap, because
|
|
// it still needs access to this pending zap to know to cancel
|
|
|
|
case .cancel_fetching_invoice:
|
|
// already cancelling?
|
|
print("cancel_zap: already cancelling")
|
|
return nil
|
|
|
|
case .confirmed:
|
|
return .already_confirmed
|
|
|
|
case .postbox_pending(let nwc_req):
|
|
if let err = box.cancel_send(evid: nwc_req.id) {
|
|
return .send_err(err)
|
|
}
|
|
let reqid = ZapRequestId(from_pending: zap)
|
|
remove_zap(reqid: reqid, zapcache: zapcache, evcache: evcache)
|
|
|
|
case .failed:
|
|
let reqid = ZapRequestId(from_pending: zap)
|
|
remove_zap(reqid: reqid, zapcache: zapcache, evcache: evcache)
|
|
}
|
|
|
|
return nil
|
|
}
|