Pending Zaps

A fairly large change that replaces Zaps in the codebase with "Zapping"
which is a tagged union consisting of a resolved Zap and a Pending Zap.
These are both counted as Zaps everywhere in Damus, except pending zaps
can be cancelled (most of the time).
This commit is contained in:
William Casarin 2023-05-13 21:33:34 -07:00
parent 1518a0a16c
commit 03691d0369
24 changed files with 738 additions and 179 deletions

2
.envrc
View File

@ -1,4 +1,4 @@
use nix
#use nix
export TODO_FILE=$PWD/TODO

View File

@ -23,45 +23,90 @@ struct ZappingEvent {
let event: NostrEvent
}
class ZapButtonModel: ObservableObject {
var invoice: String? = nil
@Published var zapping: String = ""
@Published var showing_select_wallet: Bool = false
@Published var showing_zap_customizer: Bool = false
}
struct ZapButton: View {
let damus_state: DamusState
let event: NostrEvent
let lnurl: String
@ObservedObject var bar: ActionBarModel
@ObservedObject var zaps: ZapsDataModel
@StateObject var button: ZapButtonModel = ZapButtonModel()
@State var zapping: Bool = false
@State var invoice: String = ""
@State var showing_select_wallet: Bool = false
@State var showing_zap_customizer: Bool = false
@State var is_charging: Bool = false
var zap_img: String {
if bar.zapped {
return "bolt.fill"
}
if !zapping {
return "bolt"
}
return "bolt.fill"
var our_zap: Zapping? {
zaps.zaps.first(where: { z in z.request.pubkey == damus_state.pubkey })
}
var zap_color: Color? {
if bar.zapped {
var zap_img: String {
switch our_zap {
case .none:
return "bolt"
case .zap:
return "bolt.fill"
case .pending:
return "bolt.fill"
}
}
var zap_color: Color {
switch our_zap {
case .none:
return Color.gray
case .pending:
return Color.yellow
case .zap:
return Color.orange
}
if is_charging {
return Color.yellow
}
func tap() {
guard let our_zap else {
send_zap(damus_state: damus_state, event: event, lnurl: lnurl, is_custom: false, comment: nil, amount_sats: nil, zap_type: damus_state.settings.default_zap_type)
return
}
if !zapping {
return nil
// 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.postbox, zapcache: damus_state.zaps, evcache: damus_state.events) else {
UIImpactFeedbackGenerator(style: .soft).impactOccurred()
return
}
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
}
}
return Color.yellow
}
var body: some View {
@ -69,37 +114,32 @@ struct ZapButton: View {
Button(action: {
}, label: {
Image(systemName: zap_img)
.foregroundColor(zap_color == nil ? Color.gray : zap_color!)
.foregroundColor(zap_color)
.font(.footnote.weight(.medium))
})
.simultaneousGesture(LongPressGesture().onEnded {_ in
guard !zapping else {
guard our_zap == nil else {
return
}
self.showing_zap_customizer = true
button.showing_zap_customizer = true
})
.highPriorityGesture(TapGesture().onEnded {_ in
guard !zapping else {
return
}
send_zap(damus_state: damus_state, event: event, lnurl: lnurl, is_custom: false, comment: nil, amount_sats: nil, zap_type: damus_state.settings.default_zap_type)
self.zapping = true
.highPriorityGesture(TapGesture().onEnded {
tap()
})
.accessibilityLabel(NSLocalizedString("Zap", comment: "Accessibility label for zap button"))
if bar.zap_total > 0 {
Text(verbatim: format_msats_abbrev(bar.zap_total))
if zaps.zap_total > 0 {
Text(verbatim: format_msats_abbrev(zaps.zap_total))
.font(.footnote)
.foregroundColor(bar.zapped ? Color.orange : Color.gray)
.foregroundColor(zap_color)
}
}
.sheet(isPresented: $showing_zap_customizer) {
.sheet(isPresented: $button.showing_zap_customizer) {
CustomizeZapView(state: damus_state, event: event, lnurl: lnurl)
}
.sheet(isPresented: $showing_select_wallet, onDismiss: {showing_select_wallet = false}) {
SelectWalletView(default_wallet: damus_state.settings.default_wallet, showingSelectWallet: $showing_select_wallet, our_pubkey: damus_state.pubkey, invoice: invoice)
.sheet(isPresented: $button.showing_select_wallet, onDismiss: {button.showing_select_wallet = false}) {
SelectWalletView(default_wallet: damus_state.settings.default_wallet, showingSelectWallet: $button.showing_select_wallet, our_pubkey: damus_state.pubkey, invoice: button.invoice ?? "")
}
.onReceive(handle_notify(.zapping)) { notif in
let zap_ev = notif.object as! ZappingEvent
@ -117,15 +157,13 @@ struct ZapButton: View {
break
case .got_zap_invoice(let inv):
if damus_state.settings.show_wallet_selector {
self.invoice = inv
self.showing_select_wallet = true
self.button.invoice = inv
self.button.showing_select_wallet = true
} else {
let wallet = damus_state.settings.default_wallet.model
open_with_wallet(wallet: wallet, invoice: inv)
}
}
self.zapping = false
}
}
}
@ -133,13 +171,25 @@ struct ZapButton: View {
struct ZapButton_Previews: PreviewProvider {
static var previews: some View {
let bar = ActionBarModel(likes: 0, boosts: 0, zaps: 10, zap_total: 15623414, replies: 2, our_like: nil, our_boost: nil, our_zap: nil, our_reply: nil)
ZapButton(damus_state: test_damus_state(), event: test_event, lnurl: "lnurl", bar: bar)
let pending_zap = PendingZap(amount_msat: 1000, target: ZapTarget.note(id: "noteid", author: "author"), request: test_zap_request, type: .pub, state: .external(.init(state: .fetching_invoice)))
let zaps = ZapsDataModel([.pending(pending_zap)])
ZapButton(damus_state: test_damus_state(), event: test_event, 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, event: NostrEvent, lnurl: String, is_custom: Bool, comment: String?, amount_sats: Int?, zap_type: ZapType) {
guard let keypair = damus_state.keypair.to_full() else {
return
@ -150,7 +200,18 @@ func send_zap(damus_state: DamusState, event: NostrEvent, lnurl: String, is_cust
let target = ZapTarget.note(id: event.id, author: event.pubkey)
let content = comment ?? ""
let zapreq = make_zap_request_event(keypair: keypair, content: content, relays: relays, target: target, zap_type: zap_type)
guard let zapreq = make_zap_request_event(keypair: keypair, content: content, relays: relays, target: target, zap_type: zap_type) else {
// this should never happen
return
}
let zap_amount = amount_sats ?? damus_state.settings.default_zap_amount
let amount_msat = Int64(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: ZapRequest(ev: zapreq), type: zap_type, state: pending_zap_state)
UIImpactFeedbackGenerator(style: .heavy).impactOccurred()
damus_state.add_zap(zap: .pending(pending_zap))
Task {
var mpayreq = damus_state.lnurls.lookup(target.pubkey)
@ -161,6 +222,7 @@ func send_zap(damus_state: DamusState, event: NostrEvent, lnurl: String, is_cust
guard let payreq = mpayreq else {
// TODO: show error
DispatchQueue.main.async {
remove_zap(reqid: zapreq.id, zapcache: damus_state.zaps, evcache: damus_state.events)
let typ = ZappingEventType.failed(.bad_lnurl)
let ev = ZappingEvent(is_custom: is_custom, type: typ, event: event)
notify(.zapping, ev)
@ -172,10 +234,9 @@ func send_zap(damus_state: DamusState, event: NostrEvent, lnurl: String, is_cust
damus_state.lnurls.endpoints[target.pubkey] = payreq
}
let zap_amount = amount_sats ?? damus_state.settings.default_zap_amount
guard let inv = await fetch_zap_invoice(payreq, zapreq: zapreq, sats: zap_amount, zap_type: zap_type, comment: comment) else {
DispatchQueue.main.async {
remove_zap(reqid: zapreq.id, zapcache: damus_state.zaps, evcache: damus_state.events)
let typ = ZappingEventType.failed(.fetching_invoice)
let ev = ZappingEvent(is_custom: is_custom, type: typ, event: event)
notify(.zapping, ev)
@ -184,10 +245,24 @@ func send_zap(damus_state: DamusState, event: NostrEvent, lnurl: String, is_cust
}
DispatchQueue.main.async {
if let url = damus_state.settings.nostr_wallet_connect,
let nwc = WalletConnectURL(str: url) {
nwc_pay(url: nwc, pool: damus_state.pool, post: damus_state.postbox, invoice: inv)
} else {
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: zapreq.id, zapcache: damus_state.zaps, evcache: damus_state.events)
return
}
guard let nwc_req = nwc_pay(url: nwc_state.url, pool: damus_state.pool, post: damus_state.postbox, invoice: inv),
case .nwc(let pzap_state) = pending_zap_state
else {
return
}
pzap_state.state = .postbox_pending(nwc_req)
case .external(let pending_ext):
pending_ext.state = .done
let ev = ZappingEvent(is_custom: is_custom, type: .got_zap_invoice(inv), event: event)
notify(.zapping, ev)
}
@ -196,3 +271,41 @@ func send_zap(damus_state: DamusState, event: NostrEvent, lnurl: String, is_cust
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:
nwc_state.state = .cancel_fetching_invoice
// 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)
}
remove_zap(reqid: zap.request.ev.id, zapcache: zapcache, evcache: evcache)
case .failed:
remove_zap(reqid: zap.request.ev.id, zapcache: zapcache, evcache: evcache)
}
return nil
}

View File

@ -581,7 +581,8 @@ struct ContentView: View {
let new_relay_filters = load_relay_filters(pubkey) == nil
for relay in bootstrap_relays {
if let url = RelayURL(relay) {
add_new_relay(relay_filters: relay_filters, metadatas: metadatas, pool: pool, url: url, info: .rw, new_relay_filters: new_relay_filters)
let descriptor = RelayDescriptor(url: url, info: .rw)
add_new_relay(relay_filters: relay_filters, metadatas: metadatas, pool: pool, descriptor: descriptor, new_relay_filters: new_relay_filters)
}
}
@ -592,6 +593,11 @@ struct ContentView: View {
let settings = UserSettingsStore()
UserSettingsStore.shared = settings
if let nwc_str = settings.nostr_wallet_connect,
let nwc = WalletConnectURL(str: nwc_str) {
try? pool.add_relay(.nwc(url: nwc.relay))
}
self.damus_state = DamusState(pool: pool,
keypair: keypair,
likes: EventCounter(our_pubkey: pubkey),

View File

@ -7,12 +7,17 @@
import Foundation
enum Zapped {
case not_zapped
case pending
case zapped
}
class ActionBarModel: ObservableObject {
@Published var our_like: NostrEvent?
@Published var our_boost: NostrEvent?
@Published var our_reply: NostrEvent?
@Published var our_zap: Zap?
@Published var our_zap: Zapping?
@Published var likes: Int
@Published var boosts: Int
@Published var zaps: Int
@ -35,7 +40,7 @@ class ActionBarModel: ObservableObject {
self.replies = 0
}
init(likes: Int, boosts: Int, zaps: Int, zap_total: Int64, replies: Int, our_like: NostrEvent?, our_boost: NostrEvent?, our_zap: Zap?, our_reply: NostrEvent?) {
init(likes: Int, boosts: Int, zaps: Int, zap_total: Int64, replies: Int, our_like: NostrEvent?, our_boost: NostrEvent?, our_zap: Zapping?, our_reply: NostrEvent?) {
self.likes = likes
self.boosts = boosts
self.zaps = zaps
@ -64,10 +69,6 @@ class ActionBarModel: ObservableObject {
return likes == 0 && boosts == 0 && zaps == 0
}
var zapped: Bool {
return our_zap != nil
}
var liked: Bool {
return our_like != nil
}

View File

@ -32,7 +32,7 @@ struct DamusState {
let wallet: WalletModel
@discardableResult
func add_zap(zap: Zap) -> Bool {
func add_zap(zap: Zapping) -> Bool {
// store generic zap mapping
self.zaps.add_zap(zap: zap)
// associate with events as well

View File

@ -129,6 +129,25 @@ class HomeModel: ObservableObject {
handle_zap_event(ev)
case .zap_request:
break
case .nwc_request:
break
case .nwc_response:
handle_nwc_response(ev)
}
}
func handle_nwc_response(_ ev: NostrEvent) {
Task { @MainActor in
guard let resp = await FullWalletResponse(from: ev) else {
return
}
if resp.response.error == nil {
nwc_success(zapcache: self.damus_state.zaps, resp: resp)
return
}
nwc_error(zapcache: self.damus_state.zaps, evcache: self.damus_state.events, resp: resp)
}
}
@ -137,13 +156,13 @@ class HomeModel: ObservableObject {
return
}
damus_state.add_zap(zap: zap)
damus_state.add_zap(zap: .zap(zap))
guard zap.target.pubkey == our_keypair.pubkey else {
return
}
if !notifications.insert_zap(zap) {
if !notifications.insert_zap(.zap(zap)) {
return
}
@ -301,6 +320,16 @@ class HomeModel: ObservableObject {
//remove_bootstrap_nodes(damus_state)
send_home_filters(relay_id: relay_id)
}
// connect to nwc relays when connected
if let nwc_str = damus_state.settings.nostr_wallet_connect,
let r = pool.get_relay(relay_id),
r.descriptor.variant == .nwc,
let nwc = WalletConnectURL(str: nwc_str),
nwc.relay.id == relay_id
{
subscribe_to_nwc(url: nwc, pool: pool)
}
case .error(let merr):
let desc = String(describing: merr)
if desc.contains("Software caused connection abort") {
@ -431,7 +460,7 @@ class HomeModel: ObservableObject {
print_filters(relay_id: relay_id, filters: [home_filters, contacts_filters, notifications_filters, dms_filters])
if let relay_id = relay_id {
if let relay_id {
pool.send(.subscribe(.init(filters: home_filters, sub_id: home_subid)), to: [relay_id])
pool.send(.subscribe(.init(filters: contacts_filters, sub_id: contacts_subid)), to: [relay_id])
pool.send(.subscribe(.init(filters: notifications_filters, sub_id: notifications_subid)), to: [relay_id])
@ -836,7 +865,8 @@ func load_our_relays(state: DamusState, m_old_ev: NostrEvent?, ev: NostrEvent) {
changed = true
if new.contains(d) {
if let url = RelayURL(d) {
add_new_relay(relay_filters: state.relay_filters, metadatas: state.relay_metadata, pool: state.pool, url: url, info: decoded[d] ?? .rw, new_relay_filters: new_relay_filters)
let descriptor = RelayDescriptor(url: url, info: decoded[d] ?? .rw)
add_new_relay(relay_filters: state.relay_filters, metadatas: state.relay_metadata, pool: state.pool, descriptor: descriptor, new_relay_filters: new_relay_filters)
}
} else {
state.pool.remove_relay(d)
@ -849,8 +879,9 @@ func load_our_relays(state: DamusState, m_old_ev: NostrEvent?, ev: NostrEvent) {
}
}
func add_new_relay(relay_filters: RelayFilters, metadatas: RelayMetadatas, pool: RelayPool, url: RelayURL, info: RelayInfo, new_relay_filters: Bool) {
try? pool.add_relay(url, info: info)
func add_new_relay(relay_filters: RelayFilters, metadatas: RelayMetadatas, pool: RelayPool, descriptor: RelayDescriptor, new_relay_filters: Bool) {
try? pool.add_relay(descriptor)
let url = descriptor.url
let relay_id = url.id
guard metadatas.lookup(relay_id: relay_id) == nil else {

View File

@ -8,7 +8,7 @@
import Foundation
class ZapGroup {
var zaps: [Zap]
var zaps: [Zapping]
var msat_total: Int64
var zappers: Set<String>
@ -17,22 +17,16 @@ class ZapGroup {
return 0
}
return first.event.created_at
return first.created_at
}
func zap_requests() -> [NostrEvent] {
zaps.map { z in
if let priv = z.private_request {
return priv
} else {
return z.request.ev
}
}
zaps.map { z in z.request }
}
func would_filter(_ isIncluded: (NostrEvent) -> Bool) -> Bool {
for zap in zaps {
if !isIncluded(zap.request_ev) {
if !isIncluded(zap.request) {
return true
}
}
@ -41,7 +35,7 @@ class ZapGroup {
}
func filter(_ isIncluded: (NostrEvent) -> Bool) -> ZapGroup? {
let new_zaps = zaps.filter { isIncluded($0.request_ev) }
let new_zaps = zaps.filter { isIncluded($0.request) }
guard new_zaps.count > 0 else {
return nil
}
@ -59,15 +53,15 @@ class ZapGroup {
}
@discardableResult
func insert(_ zap: Zap) -> Bool {
func insert(_ zap: Zapping) -> Bool {
if !insert_uniq_sorted_zap_by_created(zaps: &zaps, new_zap: zap) {
return false
}
msat_total += zap.invoice.amount
msat_total += zap.amount
if !zappers.contains(zap.request.ev.pubkey) {
zappers.insert(zap.request.ev.pubkey)
if !zappers.contains(zap.request.pubkey) {
zappers.insert(zap.request.pubkey)
}
return true

View File

@ -99,7 +99,7 @@ enum NotificationItem {
}
class NotificationsModel: ObservableObject, ScrollQueue {
var incoming_zaps: [Zap]
var incoming_zaps: [Zapping]
var incoming_events: [NostrEvent]
var should_queue: Bool
@ -150,7 +150,7 @@ class NotificationsModel: ObservableObject, ScrollQueue {
}
for zap in incoming_zaps {
pks.insert(zap.request.ev.pubkey)
pks.insert(zap.request.pubkey)
}
return Array(pks)
@ -249,7 +249,7 @@ class NotificationsModel: ObservableObject, ScrollQueue {
return false
}
private func insert_zap_immediate(_ zap: Zap) -> Bool {
private func insert_zap_immediate(_ zap: Zapping) -> Bool {
switch zap.target {
case .note(let notezt):
let id = notezt.note_id
@ -285,7 +285,7 @@ class NotificationsModel: ObservableObject, ScrollQueue {
return false
}
func insert_zap(_ zap: Zap) -> Bool {
func insert_zap(_ zap: Zapping) -> Bool {
if should_queue {
return insert_uniq_sorted_zap_by_created(zaps: &incoming_zaps, new_zap: zap)
}
@ -307,7 +307,7 @@ class NotificationsModel: ObservableObject, ScrollQueue {
changed = changed || incoming_events.count != count
count = profile_zaps.zaps.count
profile_zaps.zaps = profile_zaps.zaps.filter { zap in isIncluded(zap.request.ev) }
profile_zaps.zaps = profile_zaps.zaps.filter { zap in isIncluded(zap.request) }
changed = changed || profile_zaps.zaps.count != count
for el in reactions {
@ -325,7 +325,7 @@ class NotificationsModel: ObservableObject, ScrollQueue {
for el in zaps {
count = el.value.zaps.count
el.value.zaps = el.value.zaps.filter {
isIncluded($0.request.ev)
isIncluded($0.request)
}
changed = changed || el.value.zaps.count != count
}

View File

@ -19,7 +19,7 @@ class ZapsModel: ObservableObject {
self.target = target
}
var zaps: [Zap] {
var zaps: [Zapping] {
return state.events.lookup_zaps(target: target)
}
@ -53,7 +53,7 @@ class ZapsModel: ObservableObject {
case .notice:
break
case .eose:
let events = state.events.lookup_zaps(target: target).map { $0.request_ev }
let events = state.events.lookup_zaps(target: target).map { $0.request }
load_profiles(profiles_subid: profiles_subid, relay_id: relay_id, load: .from_events(events), damus_state: state)
case .event(_, let ev):
guard ev.kind == 9735 else {
@ -61,22 +61,19 @@ class ZapsModel: ObservableObject {
}
if let zap = state.zaps.zaps[ev.id] {
if state.events.store_zap(zap: zap) {
objectWillChange.send()
}
} else {
guard let zapper = state.profiles.lookup_zapper(pubkey: target.pubkey) else {
return
}
guard let zap = Zap.from_zap_event(zap_ev: ev, zapper: zapper, our_privkey: state.keypair.privkey) else {
return
}
if self.state.add_zap(zap: zap) {
objectWillChange.send()
}
state.events.store_zap(zap: zap)
return
}
guard let zapper = state.profiles.lookup_zapper(pubkey: target.pubkey) else {
return
}
guard let zap = Zap.from_zap_event(zap_ev: ev, zapper: zapper, our_privkey: state.keypair.privkey) else {
return
}
self.state.add_zap(zap: .zap(zap))
}

View File

@ -22,4 +22,6 @@ enum NostrKind: Int {
case list = 30000
case zap = 9735
case zap_request = 9734
case nwc_request = 23194
case nwc_response = 23195
}

View File

@ -10,21 +10,46 @@ import Foundation
public struct RelayInfo: Codable {
let read: Bool?
let write: Bool?
let ephemeral: Bool?
init(read: Bool, write: Bool, ephemeral: Bool = false) {
init(read: Bool, write: Bool) {
self.read = read
self.write = write
self.ephemeral = ephemeral
}
static let rw = RelayInfo(read: true, write: true, ephemeral: false)
static let ephemeral = RelayInfo(read: true, write: true, ephemeral: true)
static let rw = RelayInfo(read: true, write: true)
}
enum RelayVariant {
case regular
case ephemeral
case nwc
}
public struct RelayDescriptor {
public let url: RelayURL
public let info: RelayInfo
let url: RelayURL
let info: RelayInfo
let variant: RelayVariant
init(url: RelayURL, info: RelayInfo, variant: RelayVariant = .regular) {
self.url = url
self.info = info
self.variant = variant
}
var ephemeral: Bool {
switch variant {
case .regular:
return false
case .ephemeral:
return true
case .nwc:
return true
}
}
static func nwc(url: RelayURL) -> RelayDescriptor {
return RelayDescriptor(url: url, info: .rw, variant: .nwc)
}
}
enum RelayFlags: Int {

View File

@ -43,7 +43,7 @@ class RelayPool {
}
var our_descriptors: [RelayDescriptor] {
return all_descriptors.filter { d in !(d.info.ephemeral ?? false) }
return all_descriptors.filter { d in !d.ephemeral }
}
var all_descriptors: [RelayDescriptor] {
@ -91,7 +91,8 @@ class RelayPool {
}
}
func add_relay(_ url: RelayURL, info: RelayInfo) throws {
func add_relay(_ desc: RelayDescriptor) throws {
let url = desc.url
let relay_id = get_relay_id(url)
if get_relay(relay_id) != nil {
throw RelayError.RelayAlreadyExists
@ -99,8 +100,7 @@ class RelayPool {
let conn = RelayConnection(url: url) { event in
self.handle_event(relay_id: relay_id, event: event)
}
let descriptor = RelayDescriptor(url: url, info: info)
let relay = Relay(descriptor: descriptor, connection: conn)
let relay = Relay(descriptor: desc, connection: conn)
self.relays.append(relay)
}
@ -196,7 +196,7 @@ class RelayPool {
continue
}
if (relay.descriptor.info.ephemeral ?? false) && skip_ephemeral {
if relay.descriptor.ephemeral && skip_ephemeral {
continue
}
@ -266,7 +266,7 @@ func add_rw_relay(_ pool: RelayPool, _ url: String) {
guard let url = RelayURL(url) else {
return
}
try? pool.add_relay(url, info: RelayInfo.rw)
try? pool.add_relay(RelayDescriptor(url: url, info: .rw))
}

View File

@ -55,11 +55,42 @@ class PreviewModel: ObservableObject {
}
class ZapsDataModel: ObservableObject {
@Published var zaps: [Zap]
@Published var zaps: [Zapping]
init(_ zaps: [Zap]) {
init(_ zaps: [Zapping]) {
self.zaps = zaps
}
func update_state(reqid: String, state: PendingZapState) {
guard let zap = zaps.first(where: { z in z.request.id == reqid }),
case .pending(let pzap) = zap,
pzap.state != state
else {
return
}
pzap.state = state
self.objectWillChange.send()
}
var zap_total: Int64 {
zaps.reduce(0) { total, zap in total + zap.amount }
}
func from(_ pubkey: String) -> [Zapping] {
return self.zaps.filter { z in z.request.pubkey == pubkey }
}
@discardableResult
func remove(reqid: String) -> Bool {
guard zaps.first(where: { z in z.request.id == reqid }) != nil else {
return false
}
self.zaps = zaps.filter { z in z.request.id != reqid }
return true
}
}
class RelativeTimeModel: ObservableObject {
@ -86,7 +117,7 @@ class EventData {
return preview_model.state
}
init(zaps: [Zap] = []) {
init(zaps: [Zapping] = []) {
self.translations_model = .init(state: .havent_tried)
self.artifacts_model = .init(state: .not_loaded)
self.zaps_model = .init(zaps)
@ -131,12 +162,23 @@ class EventCache {
}
@discardableResult
func store_zap(zap: Zap) -> Bool {
func store_zap(zap: Zapping) -> Bool {
let data = get_cache_data(zap.target.id).zaps_model
return insert_uniq_sorted_zap_by_amount(zaps: &data.zaps, new_zap: zap)
}
func lookup_zaps(target: ZapTarget) -> [Zap] {
func remove_zap(zap: Zapping) {
switch zap.target {
case .note(let note_target):
let zaps = get_cache_data(note_target.note_id).zaps_model
zaps.remove(reqid: zap.request.id)
case .profile:
// these aren't stored anywhere yet
break
}
}
func lookup_zaps(target: ZapTarget) -> [Zapping] {
return get_cache_data(target.id).zaps_model.zaps
}

View File

@ -7,12 +7,17 @@
import Foundation
func insert_uniq_sorted_zap(zaps: inout [Zap], new_zap: Zap, cmp: (Zap, Zap) -> Bool) -> Bool {
func insert_uniq_sorted_zap(zaps: inout [Zapping], new_zap: Zapping, cmp: (Zapping, Zapping) -> Bool) -> Bool {
var i: Int = 0
for zap in zaps {
// don't insert duplicate events
if new_zap.event.id == zap.event.id {
if new_zap.request.id == zap.request.id {
// replace pending
if !new_zap.is_pending && zap.is_pending {
zaps[i] = new_zap
return true
}
// don't insert duplicate events
return false
}
@ -28,16 +33,16 @@ func insert_uniq_sorted_zap(zaps: inout [Zap], new_zap: Zap, cmp: (Zap, Zap) ->
}
@discardableResult
func insert_uniq_sorted_zap_by_created(zaps: inout [Zap], new_zap: Zap) -> Bool {
func insert_uniq_sorted_zap_by_created(zaps: inout [Zapping], new_zap: Zapping) -> Bool {
return insert_uniq_sorted_zap(zaps: &zaps, new_zap: new_zap) { (a, b) in
a.event.created_at > b.event.created_at
a.created_at > b.created_at
}
}
@discardableResult
func insert_uniq_sorted_zap_by_amount(zaps: inout [Zap], new_zap: Zap) -> Bool {
func insert_uniq_sorted_zap_by_amount(zaps: inout [Zapping], new_zap: Zapping) -> Bool {
return insert_uniq_sorted_zap(zaps: &zaps, new_zap: new_zap) { (a, b) in
a.invoice.amount > b.invoice.amount
a.amount > b.amount
}
}

View File

@ -26,16 +26,24 @@ class PostedEvent {
let event: NostrEvent
let skip_ephemeral: Bool
var remaining: [Relayer]
let flush_after: Date?
init(event: NostrEvent, remaining: [String], skip_ephemeral: Bool) {
init(event: NostrEvent, remaining: [String], skip_ephemeral: Bool, flush_after: Date? = nil) {
self.event = event
self.skip_ephemeral = skip_ephemeral
self.flush_after = flush_after
self.remaining = remaining.map {
Relayer(relay: $0, attempts: 0, retry_after: 2.0)
}
}
}
enum CancelSendErr {
case nothing_to_cancel
case not_delayed
case too_late
}
class PostBox {
let pool: RelayPool
var events: [String: PostedEvent]
@ -46,12 +54,37 @@ class PostBox {
pool.register_handler(sub_id: "postbox", handler: handle_event)
}
// only works reliably on delay-sent events
func cancel_send(evid: String) -> CancelSendErr? {
guard let ev = events[evid] else {
return .nothing_to_cancel
}
guard let after = ev.flush_after else {
return .not_delayed
}
guard Date.now < after else {
return .too_late
}
events.removeValue(forKey: evid)
return nil
}
func try_flushing_events() {
let now = Int64(Date().timeIntervalSince1970)
for kv in events {
let event = kv.value
// some are delayed
if let after = event.flush_after, Date.now.timeIntervalSince1970 < after.timeIntervalSince1970 {
continue
}
for relayer in event.remaining {
if relayer.last_attempt == nil || (now >= (relayer.last_attempt! + Int64(relayer.retry_after))) {
if relayer.last_attempt == nil ||
(now >= (relayer.last_attempt! + Int64(relayer.retry_after))) {
print("attempt #\(relayer.attempts) to flush event '\(event.event.content)' to \(relayer.relay) after \(relayer.retry_after) seconds")
flush_event(event, to_relay: relayer)
}
@ -99,16 +132,20 @@ class PostBox {
}
}
func send(_ event: NostrEvent, to: [String]? = nil, skip_ephemeral: Bool = true) {
func send(_ event: NostrEvent, to: [String]? = nil, skip_ephemeral: Bool = true, delay: TimeInterval? = nil) {
// Don't add event if we already have it
if events[event.id] != nil {
return
}
let remaining = to ?? pool.our_descriptors.map { $0.url.id }
let posted_ev = PostedEvent(event: event, remaining: remaining, skip_ephemeral: skip_ephemeral)
let after = delay.map { d in Date.now.addingTimeInterval(d) }
let posted_ev = PostedEvent(event: event, remaining: remaining, skip_ephemeral: skip_ephemeral, flush_after: after)
events[event.id] = posted_ev
flush_event(posted_ev)
if after == nil {
flush_event(posted_ev)
}
}
}

View File

@ -67,6 +67,80 @@ struct WalletRequest<T: Codable>: Codable {
let params: T?
}
struct WalletResponseErr: Codable {
let code: String?
let message: String?
}
struct PayInvoiceResponse: Decodable {
let preimage: String
}
enum WalletResponseResultType: String {
case pay_invoice
}
enum WalletResponseResult {
case pay_invoice(PayInvoiceResponse)
}
struct FullWalletResponse {
let req_id: String
let response: WalletResponse
init?(from: NostrEvent) async {
guard let req_id = from.referenced_ids.first else {
return nil
}
self.req_id = req_id.ref_id
let ares = Task {
guard let resp: WalletResponse = decode_json(from.content) else {
let resp: WalletResponse? = nil
return resp
}
return resp
}
guard let res = await ares.value else {
return nil
}
self.response = res
}
}
struct WalletResponse: Decodable {
let result_type: WalletResponseResultType
let error: WalletResponseErr?
let result: WalletResponseResult
private enum CodingKeys: CodingKey {
case result_type, error, result
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
let result_type_str = try container.decode(String.self, forKey: .result_type)
guard let result_type = WalletResponseResultType(rawValue: result_type_str) else {
throw DecodingError.typeMismatch(WalletResponseResultType.self, .init(codingPath: decoder.codingPath, debugDescription: "result_type \(result_type_str) is unknown"))
}
self.result_type = result_type
self.error = try container.decodeIfPresent(WalletResponseErr.self, forKey: .error)
switch result_type {
case .pay_invoice:
let res = try container.decode(PayInvoiceResponse.self, forKey: .result)
self.result = .pay_invoice(res)
}
}
}
func make_wallet_pay_invoice_request(invoice: String) -> WalletRequest<PayInvoiceRequest> {
let data = PayInvoiceRequest(invoice: invoice)
return WalletRequest(method: "pay_invoice", params: data)
@ -92,12 +166,65 @@ func make_wallet_connect_request<T>(req: WalletRequest<T>, to_pk: String, keypai
return create_encrypted_event(content, to_pk: to_pk, tags: tags, keypair: keypair, created_at: created_at, kind: 23194)
}
func nwc_pay(url: WalletConnectURL, pool: RelayPool, post: PostBox, invoice: String) {
func subscribe_to_nwc(url: WalletConnectURL, pool: RelayPool) {
var filter: NostrFilter = .filter_kinds([NostrKind.nwc_response.rawValue])
filter.authors = [url.pubkey]
filter.limit = 0
let sub = NostrSubscribe(filters: [filter], sub_id: "nwc")
pool.send(.subscribe(sub), to: [url.relay.id])
}
func nwc_pay(url: WalletConnectURL, pool: RelayPool, post: PostBox, invoice: String) -> NostrEvent? {
let req = make_wallet_pay_invoice_request(invoice: invoice)
guard let ev = make_wallet_connect_request(req: req, to_pk: url.pubkey, keypair: url.keypair) else {
return
return nil
}
try? pool.add_relay(url.relay, info: .ephemeral)
post.send(ev, to: [url.relay.id], skip_ephemeral: false)
try? pool.add_relay(.nwc(url: url.relay))
subscribe_to_nwc(url: url, pool: pool)
post.send(ev, to: [url.relay.id], skip_ephemeral: false, delay: 5.0)
return ev
}
func nwc_success(zapcache: Zaps, resp: FullWalletResponse) {
// find the pending zap and mark it as pending-confirmed
for kv in zapcache.our_zaps {
let zaps = kv.value
for zap in zaps {
guard case .pending(let pzap) = zap,
case .nwc(let nwc_state) = pzap.state,
case .postbox_pending(let nwc_req) = nwc_state.state,
nwc_req.id == resp.req_id
else {
continue
}
nwc_state.state = .confirmed
return
}
}
}
func nwc_error(zapcache: Zaps, evcache: EventCache, resp: FullWalletResponse) {
// find a pending zap with the nwc request id associated with this response and remove it
for kv in zapcache.our_zaps {
let zaps = kv.value
for zap in zaps {
guard case .pending(let pzap) = zap,
case .nwc(let nwc_state) = pzap.state,
case .postbox_pending(let req) = nwc_state.state,
req.id == resp.req_id
else {
continue
}
// remove the pending zap if there was an error
remove_zap(reqid: pzap.request.ev.id, zapcache: zapcache, evcache: evcache)
return
}
}
}

View File

@ -7,7 +7,7 @@
import Foundation
public struct NoteZapTarget: Equatable {
public struct NoteZapTarget: Equatable, Hashable {
public let note_id: String
public let author: String
}
@ -41,6 +41,148 @@ public enum ZapTarget: Equatable {
struct ZapRequest {
let ev: NostrEvent
}
enum ExtPendingZapStateType {
case fetching_invoice
case done
}
class ExtPendingZapState: Equatable {
static func == (lhs: ExtPendingZapState, rhs: ExtPendingZapState) -> Bool {
return lhs.state == rhs.state
}
var state: ExtPendingZapStateType
init(state: ExtPendingZapStateType) {
self.state = state
}
}
enum PendingZapState: Equatable {
case nwc(NWCPendingZapState)
case external(ExtPendingZapState)
}
enum NWCStateType: Equatable {
case fetching_invoice
case cancel_fetching_invoice
case postbox_pending(NostrEvent)
case confirmed
case failed
}
class NWCPendingZapState: Equatable {
var state: NWCStateType
let url: WalletConnectURL
init(state: NWCStateType, url: WalletConnectURL) {
self.state = state
self.url = url
}
static func == (lhs: NWCPendingZapState, rhs: NWCPendingZapState) -> Bool {
return lhs.state == rhs.state && lhs.url == rhs.url
}
}
class PendingZap {
let amount_msat: Int64
let target: ZapTarget
let request: ZapRequest
let type: ZapType
var state: PendingZapState
init(amount_msat: Int64, target: ZapTarget, request: ZapRequest, type: ZapType, state: PendingZapState) {
self.amount_msat = amount_msat
self.target = target
self.request = request
self.type = type
self.state = state
}
}
enum Zapping {
case zap(Zap)
case pending(PendingZap)
var is_pending: Bool {
switch self {
case .zap:
return false
case .pending:
return true
}
}
var is_private: Bool {
switch self {
case .zap(let zap):
return zap.private_request != nil
case .pending(let pzap):
return pzap.type == .priv
}
}
var amount: Int64 {
switch self {
case .zap(let zap):
return zap.invoice.amount
case .pending(let pzap):
return pzap.amount_msat
}
}
var target: ZapTarget {
switch self {
case .zap(let zap):
return zap.target
case .pending(let pzap):
return pzap.target
}
}
var request: NostrEvent {
switch self {
case .zap(let zap):
return zap.request_ev
case .pending(let pzap):
return pzap.request.ev
}
}
var created_at: Int64 {
switch self {
case .zap(let zap):
return zap.event.created_at
case .pending(let pzap):
// pending zaps are created right away
return pzap.request.ev.created_at
}
}
var event: NostrEvent? {
switch self {
case .zap(let zap):
return zap.event
case .pending:
// pending zaps don't have a zap event
return nil
}
}
var is_anon: Bool {
switch self {
case .zap(let zap):
return zap.is_anon
case .pending(let pzap):
return pzap.type == .anon
}
}
}
struct Zap {
@ -246,7 +388,7 @@ func fetch_static_payreq(_ lnurl: String) async -> LNUrlPayRequest? {
return endpoint
}
func fetch_zap_invoice(_ payreq: LNUrlPayRequest, zapreq: NostrEvent?, sats: Int, zap_type: ZapType, comment: String?) async -> String? {
func fetch_zap_invoice(_ payreq: LNUrlPayRequest, zapreq: NostrEvent, sats: Int, zap_type: ZapType, comment: String?) async -> String? {
guard var base_url = payreq.callback.flatMap({ URLComponents(string: $0) }) else {
return nil
}
@ -256,7 +398,7 @@ func fetch_zap_invoice(_ payreq: LNUrlPayRequest, zapreq: NostrEvent?, sats: Int
var query = [URLQueryItem(name: "amount", value: "\(amount)")]
if let zapreq, zappable && zap_type != .non_zap, let json = encode_json(zapreq) {
if zappable && zap_type != .non_zap, let json = encode_json(zapreq) {
print("zapreq json: \(json)")
query.append(URLQueryItem(name: "nostr", value: json))
}

View File

@ -8,9 +8,9 @@
import Foundation
class Zaps {
var zaps: [String: Zap]
var zaps: [String: Zapping]
let our_pubkey: String
var our_zaps: [String: [Zap]]
var our_zaps: [String: [Zapping]]
var event_counts: [String: Int]
var event_totals: [String: Int64]
@ -22,15 +22,42 @@ class Zaps {
self.event_counts = [:]
self.event_totals = [:]
}
func remove_zap(reqid: String) -> Zapping? {
var res: Zapping? = nil
for kv in our_zaps {
let ours = kv.value
guard let zap = ours.first(where: { z in z.request.id == reqid }) else {
continue
}
res = zap
our_zaps[kv.key] = ours.filter { z in z.request.id != reqid }
if let count = event_counts[zap.target.id] {
event_counts[zap.target.id] = count - 1
}
if let total = event_totals[zap.target.id] {
event_totals[zap.target.id] = total - zap.amount
}
// we found the request id, we can stop looking
break
}
self.zaps.removeValue(forKey: reqid)
return res
}
func add_zap(zap: Zap) {
if zaps[zap.event.id] != nil {
func add_zap(zap: Zapping) {
if zaps[zap.request.id] != nil {
return
}
self.zaps[zap.event.id] = zap
self.zaps[zap.request.id] = zap
// record our zaps for an event
if zap.request.ev.pubkey == our_pubkey {
if zap.request.pubkey == our_pubkey {
switch zap.target {
case .note(let note_target):
if our_zaps[note_target.note_id] == nil {
@ -44,7 +71,7 @@ class Zaps {
}
// don't count tips to self. lame.
guard zap.request.ev.pubkey != zap.target.pubkey else {
guard zap.request.pubkey != zap.target.pubkey else {
return
}
@ -58,8 +85,15 @@ class Zaps {
}
event_counts[id] = event_counts[id]! + 1
event_totals[id] = event_totals[id]! + zap.invoice.amount
event_totals[id] = event_totals[id]! + zap.amount
notify(.update_stats, zap.target.id)
}
}
func remove_zap(reqid: String, zapcache: Zaps, evcache: EventCache) {
guard let zap = zapcache.remove_zap(reqid: reqid) else {
return
}
evcache.get_cache_data(zap.target.id).zaps_model.remove(reqid: reqid)
}

View File

@ -88,7 +88,7 @@ struct EventActionBar: View {
if let lnurl = self.lnurl {
Spacer()
ZapButton(damus_state: damus_state, event: event, lnurl: lnurl, bar: bar)
ZapButton(damus_state: damus_state, event: event, lnurl: lnurl, zaps: self.damus_state.events.get_cache_data(self.event.id).zaps_model)
}
Spacer()
@ -227,7 +227,7 @@ struct EventActionBar_Previews: PreviewProvider {
let likedbar_ours = ActionBarModel(likes: 10, boosts: 0, zaps: 0, zap_total: 0, replies: 0, our_like: test_event, our_boost: nil, our_zap: nil, our_reply: nil)
let maxed_bar = ActionBarModel(likes: 999, boosts: 999, zaps: 999, zap_total: 99999999, replies: 999, our_like: test_event, our_boost: test_event, our_zap: nil, our_reply: nil)
let extra_max_bar = ActionBarModel(likes: 9999, boosts: 9999, zaps: 9999, zap_total: 99999999, replies: 9999, our_like: test_event, our_boost: test_event, our_zap: nil, our_reply: test_event)
let mega_max_bar = ActionBarModel(likes: 9999999, boosts: 99999, zaps: 9999, zap_total: 99999999, replies: 9999999, our_like: test_event, our_boost: test_event, our_zap: test_zap, our_reply: test_event)
let mega_max_bar = ActionBarModel(likes: 9999999, boosts: 99999, zaps: 9999, zap_total: 99999999, replies: 9999999, our_like: test_event, our_boost: test_event, our_zap: .zap(test_zap), our_reply: test_event)
VStack(spacing: 50) {
EventActionBar(damus_state: ds, event: ev, bar: bar)

View File

@ -181,7 +181,9 @@ struct TextEvent: View {
VStack(alignment: .leading) {
TopPart(is_anon: is_anon)
ReplyPart
if !options.contains(.no_replying_to) {
ReplyPart
}
EvBody(options: self.options)

View File

@ -9,30 +9,30 @@ import SwiftUI
struct ZapEvent: View {
let damus: DamusState
let zap: Zap
let zap: Zapping
var body: some View {
VStack(alignment: .leading) {
HStack(alignment: .center) {
Text("⚡️ \(format_msats(zap.invoice.amount))", comment: "Text indicating the zap amount. i.e. number of satoshis that were tipped to a user")
Text("⚡️ \(format_msats(zap.amount))", comment: "Text indicating the zap amount. i.e. number of satoshis that were tipped to a user")
.font(.headline)
.padding([.top], 2)
if zap.private_request != nil {
if zap.is_private {
Image(systemName: "lock.fill")
.foregroundColor(DamusColors.green)
.help(NSLocalizedString("Only you can see this message and who sent it.", comment: "Help text on green lock icon that explains that only the current user can see the message of a zap event and who sent the zap."))
}
if zap.is_pending {
Image(systemName: "clock.arrow.circlepath")
.foregroundColor(DamusColors.yellow)
.help(NSLocalizedString("Only you can see this message and who sent it.", comment: "Help text on green lock icon that explains that only the current user can see the message of a zap event and who sent the zap."))
}
}
if let priv = zap.private_request {
TextEvent(damus: damus, event: priv, pubkey: priv.pubkey, options: [.no_action_bar, .no_replying_to])
.padding([.top], 1)
} else {
TextEvent(damus: damus, event: zap.request.ev, pubkey: zap.request.ev.pubkey, options: [.no_action_bar, .no_replying_to])
.padding([.top], 1)
}
TextEvent(damus: damus, event: zap.request, pubkey: zap.request.pubkey, options: [.no_action_bar, .no_replying_to])
.padding([.top], 1)
}
}
}
@ -45,12 +45,14 @@ let test_zap = Zap(event: test_event, invoice: test_zap_invoice, zapper: "zapper
let test_private_zap = Zap(event: test_event, invoice: test_zap_invoice, zapper: "zapper", target: .profile("pk"), request: test_zap_request, is_anon: false, private_request: test_event)
let test_pending_zap = PendingZap(amount_msat: 10000, target: .note(id: "id", author: "pk"), request: test_zap_request, type: .pub, state: .external(.init(state: .fetching_invoice)))
struct ZapEvent_Previews: PreviewProvider {
static var previews: some View {
VStack {
ZapEvent(damus: test_damus_state(), zap: test_zap)
ZapEvent(damus: test_damus_state(), zap: .zap(test_zap))
ZapEvent(damus: test_damus_state(), zap: test_private_zap)
ZapEvent(damus: test_damus_state(), zap: .zap(test_private_zap))
}
}
}

View File

@ -68,15 +68,11 @@ func event_group_author_name(profiles: Profiles, ind: Int, group: EventGroupType
if let zapgrp = group.zap_group {
let zap = zapgrp.zaps[ind]
if let privzap = zap.private_request {
return event_author_name(profiles: profiles, pubkey: privzap.pubkey)
}
if zap.is_anon {
return NSLocalizedString("Anonymous", comment: "Placeholder author name of the anonymous person who zapped an event.")
}
return event_author_name(profiles: profiles, pubkey: zap.request.ev.pubkey)
return event_author_name(profiles: profiles, pubkey: zap.request.pubkey)
} else {
let ev = group.events[ind]
return event_author_name(profiles: profiles, pubkey: ev.pubkey)

View File

@ -88,8 +88,8 @@ struct RelayConfigView: View {
}
let info = RelayInfo.rw
guard (try? state.pool.add_relay(url, info: info)) != nil else {
let descriptor = RelayDescriptor(url: url, info: info)
guard (try? state.pool.add_relay(descriptor)) != nil else {
return
}

View File

@ -9,17 +9,20 @@ import SwiftUI
struct ZapsView: View {
let state: DamusState
@StateObject var model: ZapsModel
var model: ZapsModel
@ObservedObject var zaps: ZapsDataModel
init(state: DamusState, target: ZapTarget) {
self.state = state
self._model = StateObject(wrappedValue: ZapsModel(state: state, target: target))
self.model = ZapsModel(state: state, target: target)
self._zaps = ObservedObject(wrappedValue: state.events.get_cache_data(target.id).zaps_model)
}
var body: some View {
ScrollView {
LazyVStack {
ForEach(model.zaps, id: \.event.id) { zap in
ForEach(zaps.zaps, id: \.request.id) { zap in
ZapEvent(damus: state, zap: zap)
.padding([.horizontal])
}