1
0
mirror of git://jb55.com/damus synced 2024-10-06 03:33:22 +00:00

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 export TODO_FILE=$PWD/TODO

View File

@ -23,45 +23,90 @@ struct ZappingEvent {
let event: NostrEvent 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 { struct ZapButton: View {
let damus_state: DamusState let damus_state: DamusState
let event: NostrEvent let event: NostrEvent
let lnurl: String let lnurl: String
@ObservedObject var bar: ActionBarModel @ObservedObject var zaps: ZapsDataModel
@StateObject var button: ZapButtonModel = ZapButtonModel()
@State var zapping: Bool = false var our_zap: Zapping? {
@State var invoice: String = "" zaps.zaps.first(where: { z in z.request.pubkey == damus_state.pubkey })
@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 zap_color: Color? { var zap_img: String {
if bar.zapped { 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 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 { // we've tapped and we have a zap already... cancel if we can
return nil 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 { var body: some View {
@ -69,37 +114,32 @@ struct ZapButton: View {
Button(action: { Button(action: {
}, label: { }, label: {
Image(systemName: zap_img) Image(systemName: zap_img)
.foregroundColor(zap_color == nil ? Color.gray : zap_color!) .foregroundColor(zap_color)
.font(.footnote.weight(.medium)) .font(.footnote.weight(.medium))
}) })
.simultaneousGesture(LongPressGesture().onEnded {_ in .simultaneousGesture(LongPressGesture().onEnded {_ in
guard !zapping else { guard our_zap == nil else {
return return
} }
self.showing_zap_customizer = true button.showing_zap_customizer = true
}) })
.highPriorityGesture(TapGesture().onEnded {_ in .highPriorityGesture(TapGesture().onEnded {
guard !zapping else { tap()
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
}) })
.accessibilityLabel(NSLocalizedString("Zap", comment: "Accessibility label for zap button")) .accessibilityLabel(NSLocalizedString("Zap", comment: "Accessibility label for zap button"))
if bar.zap_total > 0 { if zaps.zap_total > 0 {
Text(verbatim: format_msats_abbrev(bar.zap_total)) Text(verbatim: format_msats_abbrev(zaps.zap_total))
.font(.footnote) .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) CustomizeZapView(state: damus_state, event: event, lnurl: lnurl)
} }
.sheet(isPresented: $showing_select_wallet, onDismiss: {showing_select_wallet = false}) { .sheet(isPresented: $button.showing_select_wallet, onDismiss: {button.showing_select_wallet = false}) {
SelectWalletView(default_wallet: damus_state.settings.default_wallet, showingSelectWallet: $showing_select_wallet, our_pubkey: damus_state.pubkey, invoice: invoice) 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 .onReceive(handle_notify(.zapping)) { notif in
let zap_ev = notif.object as! ZappingEvent let zap_ev = notif.object as! ZappingEvent
@ -117,15 +157,13 @@ struct ZapButton: View {
break break
case .got_zap_invoice(let inv): case .got_zap_invoice(let inv):
if damus_state.settings.show_wallet_selector { if damus_state.settings.show_wallet_selector {
self.invoice = inv self.button.invoice = inv
self.showing_select_wallet = true self.button.showing_select_wallet = true
} else { } else {
let wallet = damus_state.settings.default_wallet.model let wallet = damus_state.settings.default_wallet.model
open_with_wallet(wallet: wallet, invoice: inv) open_with_wallet(wallet: wallet, invoice: inv)
} }
} }
self.zapping = false
} }
} }
} }
@ -133,13 +171,25 @@ struct ZapButton: View {
struct ZapButton_Previews: PreviewProvider { struct ZapButton_Previews: PreviewProvider {
static var previews: some View { 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) 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)))
ZapButton(damus_state: test_damus_state(), event: test_event, lnurl: "lnurl", bar: bar) 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) { 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 { guard let keypair = damus_state.keypair.to_full() else {
return 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 target = ZapTarget.note(id: event.id, author: event.pubkey)
let content = comment ?? "" 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 { Task {
var mpayreq = damus_state.lnurls.lookup(target.pubkey) 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 { guard let payreq = mpayreq else {
// TODO: show error // TODO: show error
DispatchQueue.main.async { DispatchQueue.main.async {
remove_zap(reqid: zapreq.id, zapcache: damus_state.zaps, evcache: damus_state.events)
let typ = ZappingEventType.failed(.bad_lnurl) let typ = ZappingEventType.failed(.bad_lnurl)
let ev = ZappingEvent(is_custom: is_custom, type: typ, event: event) let ev = ZappingEvent(is_custom: is_custom, type: typ, event: event)
notify(.zapping, ev) 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 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 { guard let inv = await fetch_zap_invoice(payreq, zapreq: zapreq, sats: zap_amount, zap_type: zap_type, comment: comment) else {
DispatchQueue.main.async { DispatchQueue.main.async {
remove_zap(reqid: zapreq.id, zapcache: damus_state.zaps, evcache: damus_state.events)
let typ = ZappingEventType.failed(.fetching_invoice) let typ = ZappingEventType.failed(.fetching_invoice)
let ev = ZappingEvent(is_custom: is_custom, type: typ, event: event) let ev = ZappingEvent(is_custom: is_custom, type: typ, event: event)
notify(.zapping, ev) notify(.zapping, ev)
@ -184,10 +245,24 @@ func send_zap(damus_state: DamusState, event: NostrEvent, lnurl: String, is_cust
} }
DispatchQueue.main.async { DispatchQueue.main.async {
if let url = damus_state.settings.nostr_wallet_connect,
let nwc = WalletConnectURL(str: url) { switch pending_zap_state {
nwc_pay(url: nwc, pool: damus_state.pool, post: damus_state.postbox, invoice: inv) case .nwc(let nwc_state):
} else { // 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) let ev = ZappingEvent(is_custom: is_custom, type: .got_zap_invoice(inv), event: event)
notify(.zapping, ev) notify(.zapping, ev)
} }
@ -196,3 +271,41 @@ func send_zap(damus_state: DamusState, event: NostrEvent, lnurl: String, is_cust
return 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 let new_relay_filters = load_relay_filters(pubkey) == nil
for relay in bootstrap_relays { for relay in bootstrap_relays {
if let url = RelayURL(relay) { 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() let settings = UserSettingsStore()
UserSettingsStore.shared = settings 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, self.damus_state = DamusState(pool: pool,
keypair: keypair, keypair: keypair,
likes: EventCounter(our_pubkey: pubkey), likes: EventCounter(our_pubkey: pubkey),

View File

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

View File

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

View File

@ -129,6 +129,25 @@ class HomeModel: ObservableObject {
handle_zap_event(ev) handle_zap_event(ev)
case .zap_request: case .zap_request:
break 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 return
} }
damus_state.add_zap(zap: zap) damus_state.add_zap(zap: .zap(zap))
guard zap.target.pubkey == our_keypair.pubkey else { guard zap.target.pubkey == our_keypair.pubkey else {
return return
} }
if !notifications.insert_zap(zap) { if !notifications.insert_zap(.zap(zap)) {
return return
} }
@ -301,6 +320,16 @@ class HomeModel: ObservableObject {
//remove_bootstrap_nodes(damus_state) //remove_bootstrap_nodes(damus_state)
send_home_filters(relay_id: relay_id) 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): case .error(let merr):
let desc = String(describing: merr) let desc = String(describing: merr)
if desc.contains("Software caused connection abort") { 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]) 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: 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: contacts_filters, sub_id: contacts_subid)), to: [relay_id])
pool.send(.subscribe(.init(filters: notifications_filters, sub_id: notifications_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 changed = true
if new.contains(d) { if new.contains(d) {
if let url = RelayURL(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 { } else {
state.pool.remove_relay(d) 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) { func add_new_relay(relay_filters: RelayFilters, metadatas: RelayMetadatas, pool: RelayPool, descriptor: RelayDescriptor, new_relay_filters: Bool) {
try? pool.add_relay(url, info: info) try? pool.add_relay(descriptor)
let url = descriptor.url
let relay_id = url.id let relay_id = url.id
guard metadatas.lookup(relay_id: relay_id) == nil else { guard metadatas.lookup(relay_id: relay_id) == nil else {

View File

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

View File

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

View File

@ -19,7 +19,7 @@ class ZapsModel: ObservableObject {
self.target = target self.target = target
} }
var zaps: [Zap] { var zaps: [Zapping] {
return state.events.lookup_zaps(target: target) return state.events.lookup_zaps(target: target)
} }
@ -53,7 +53,7 @@ class ZapsModel: ObservableObject {
case .notice: case .notice:
break break
case .eose: 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) load_profiles(profiles_subid: profiles_subid, relay_id: relay_id, load: .from_events(events), damus_state: state)
case .event(_, let ev): case .event(_, let ev):
guard ev.kind == 9735 else { guard ev.kind == 9735 else {
@ -61,22 +61,19 @@ class ZapsModel: ObservableObject {
} }
if let zap = state.zaps.zaps[ev.id] { if let zap = state.zaps.zaps[ev.id] {
if state.events.store_zap(zap: zap) { state.events.store_zap(zap: zap)
objectWillChange.send() return
}
} 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()
}
} }
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 list = 30000
case zap = 9735 case zap = 9735
case zap_request = 9734 case zap_request = 9734
case nwc_request = 23194
case nwc_response = 23195
} }

View File

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

View File

@ -43,7 +43,7 @@ class RelayPool {
} }
var our_descriptors: [RelayDescriptor] { 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] { 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) let relay_id = get_relay_id(url)
if get_relay(relay_id) != nil { if get_relay(relay_id) != nil {
throw RelayError.RelayAlreadyExists throw RelayError.RelayAlreadyExists
@ -99,8 +100,7 @@ class RelayPool {
let conn = RelayConnection(url: url) { event in let conn = RelayConnection(url: url) { event in
self.handle_event(relay_id: relay_id, event: event) self.handle_event(relay_id: relay_id, event: event)
} }
let descriptor = RelayDescriptor(url: url, info: info) let relay = Relay(descriptor: desc, connection: conn)
let relay = Relay(descriptor: descriptor, connection: conn)
self.relays.append(relay) self.relays.append(relay)
} }
@ -196,7 +196,7 @@ class RelayPool {
continue continue
} }
if (relay.descriptor.info.ephemeral ?? false) && skip_ephemeral { if relay.descriptor.ephemeral && skip_ephemeral {
continue continue
} }
@ -266,7 +266,7 @@ func add_rw_relay(_ pool: RelayPool, _ url: String) {
guard let url = RelayURL(url) else { guard let url = RelayURL(url) else {
return 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 { class ZapsDataModel: ObservableObject {
@Published var zaps: [Zap] @Published var zaps: [Zapping]
init(_ zaps: [Zap]) { init(_ zaps: [Zapping]) {
self.zaps = zaps 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 { class RelativeTimeModel: ObservableObject {
@ -86,7 +117,7 @@ class EventData {
return preview_model.state return preview_model.state
} }
init(zaps: [Zap] = []) { init(zaps: [Zapping] = []) {
self.translations_model = .init(state: .havent_tried) self.translations_model = .init(state: .havent_tried)
self.artifacts_model = .init(state: .not_loaded) self.artifacts_model = .init(state: .not_loaded)
self.zaps_model = .init(zaps) self.zaps_model = .init(zaps)
@ -131,12 +162,23 @@ class EventCache {
} }
@discardableResult @discardableResult
func store_zap(zap: Zap) -> Bool { func store_zap(zap: Zapping) -> Bool {
let data = get_cache_data(zap.target.id).zaps_model let data = get_cache_data(zap.target.id).zaps_model
return insert_uniq_sorted_zap_by_amount(zaps: &data.zaps, new_zap: zap) 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 return get_cache_data(target.id).zaps_model.zaps
} }

View File

@ -7,12 +7,17 @@
import Foundation 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 var i: Int = 0
for zap in zaps { for zap in zaps {
// don't insert duplicate events if new_zap.request.id == zap.request.id {
if new_zap.event.id == zap.event.id { // replace pending
if !new_zap.is_pending && zap.is_pending {
zaps[i] = new_zap
return true
}
// don't insert duplicate events
return false return false
} }
@ -28,16 +33,16 @@ func insert_uniq_sorted_zap(zaps: inout [Zap], new_zap: Zap, cmp: (Zap, Zap) ->
} }
@discardableResult @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 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 @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 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 event: NostrEvent
let skip_ephemeral: Bool let skip_ephemeral: Bool
var remaining: [Relayer] 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.event = event
self.skip_ephemeral = skip_ephemeral self.skip_ephemeral = skip_ephemeral
self.flush_after = flush_after
self.remaining = remaining.map { self.remaining = remaining.map {
Relayer(relay: $0, attempts: 0, retry_after: 2.0) Relayer(relay: $0, attempts: 0, retry_after: 2.0)
} }
} }
} }
enum CancelSendErr {
case nothing_to_cancel
case not_delayed
case too_late
}
class PostBox { class PostBox {
let pool: RelayPool let pool: RelayPool
var events: [String: PostedEvent] var events: [String: PostedEvent]
@ -46,12 +54,37 @@ class PostBox {
pool.register_handler(sub_id: "postbox", handler: handle_event) 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() { func try_flushing_events() {
let now = Int64(Date().timeIntervalSince1970) let now = Int64(Date().timeIntervalSince1970)
for kv in events { for kv in events {
let event = kv.value let event = kv.value
// some are delayed
if let after = event.flush_after, Date.now.timeIntervalSince1970 < after.timeIntervalSince1970 {
continue
}
for relayer in event.remaining { 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") print("attempt #\(relayer.attempts) to flush event '\(event.event.content)' to \(relayer.relay) after \(relayer.retry_after) seconds")
flush_event(event, to_relay: relayer) 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 // Don't add event if we already have it
if events[event.id] != nil { if events[event.id] != nil {
return return
} }
let remaining = to ?? pool.our_descriptors.map { $0.url.id } 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 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? 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> { func make_wallet_pay_invoice_request(invoice: String) -> WalletRequest<PayInvoiceRequest> {
let data = PayInvoiceRequest(invoice: invoice) let data = PayInvoiceRequest(invoice: invoice)
return WalletRequest(method: "pay_invoice", params: data) 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) 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) 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 { 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) try? pool.add_relay(.nwc(url: url.relay))
post.send(ev, to: [url.relay.id], skip_ephemeral: false) 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 import Foundation
public struct NoteZapTarget: Equatable { public struct NoteZapTarget: Equatable, Hashable {
public let note_id: String public let note_id: String
public let author: String public let author: String
} }
@ -41,6 +41,148 @@ public enum ZapTarget: Equatable {
struct ZapRequest { struct ZapRequest {
let ev: NostrEvent 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 { struct Zap {
@ -246,7 +388,7 @@ func fetch_static_payreq(_ lnurl: String) async -> LNUrlPayRequest? {
return endpoint 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 { guard var base_url = payreq.callback.flatMap({ URLComponents(string: $0) }) else {
return nil return nil
} }
@ -256,7 +398,7 @@ func fetch_zap_invoice(_ payreq: LNUrlPayRequest, zapreq: NostrEvent?, sats: Int
var query = [URLQueryItem(name: "amount", value: "\(amount)")] 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)") print("zapreq json: \(json)")
query.append(URLQueryItem(name: "nostr", value: json)) query.append(URLQueryItem(name: "nostr", value: json))
} }

View File

@ -8,9 +8,9 @@
import Foundation import Foundation
class Zaps { class Zaps {
var zaps: [String: Zap] var zaps: [String: Zapping]
let our_pubkey: String let our_pubkey: String
var our_zaps: [String: [Zap]] var our_zaps: [String: [Zapping]]
var event_counts: [String: Int] var event_counts: [String: Int]
var event_totals: [String: Int64] var event_totals: [String: Int64]
@ -22,15 +22,42 @@ class Zaps {
self.event_counts = [:] self.event_counts = [:]
self.event_totals = [:] 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) { func add_zap(zap: Zapping) {
if zaps[zap.event.id] != nil { if zaps[zap.request.id] != nil {
return return
} }
self.zaps[zap.event.id] = zap self.zaps[zap.request.id] = zap
// record our zaps for an event // record our zaps for an event
if zap.request.ev.pubkey == our_pubkey { if zap.request.pubkey == our_pubkey {
switch zap.target { switch zap.target {
case .note(let note_target): case .note(let note_target):
if our_zaps[note_target.note_id] == nil { if our_zaps[note_target.note_id] == nil {
@ -44,7 +71,7 @@ class Zaps {
} }
// don't count tips to self. lame. // 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 return
} }
@ -58,8 +85,15 @@ class Zaps {
} }
event_counts[id] = event_counts[id]! + 1 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) 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 { if let lnurl = self.lnurl {
Spacer() 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() 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 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 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 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) { VStack(spacing: 50) {
EventActionBar(damus_state: ds, event: ev, bar: bar) EventActionBar(damus_state: ds, event: ev, bar: bar)

View File

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

View File

@ -9,30 +9,30 @@ import SwiftUI
struct ZapEvent: View { struct ZapEvent: View {
let damus: DamusState let damus: DamusState
let zap: Zap let zap: Zapping
var body: some View { var body: some View {
VStack(alignment: .leading) { VStack(alignment: .leading) {
HStack(alignment: .center) { 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) .font(.headline)
.padding([.top], 2) .padding([.top], 2)
if zap.private_request != nil { if zap.is_private {
Image(systemName: "lock.fill") Image(systemName: "lock.fill")
.foregroundColor(DamusColors.green) .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.")) .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: zap.request, pubkey: zap.request.pubkey, options: [.no_action_bar, .no_replying_to])
.padding([.top], 1)
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)
}
} }
} }
} }
@ -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_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 { struct ZapEvent_Previews: PreviewProvider {
static var previews: some View { static var previews: some View {
VStack { 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 { if let zapgrp = group.zap_group {
let zap = zapgrp.zaps[ind] let zap = zapgrp.zaps[ind]
if let privzap = zap.private_request {
return event_author_name(profiles: profiles, pubkey: privzap.pubkey)
}
if zap.is_anon { if zap.is_anon {
return NSLocalizedString("Anonymous", comment: "Placeholder author name of the anonymous person who zapped an event.") 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 { } else {
let ev = group.events[ind] let ev = group.events[ind]
return event_author_name(profiles: profiles, pubkey: ev.pubkey) return event_author_name(profiles: profiles, pubkey: ev.pubkey)

View File

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

View File

@ -9,17 +9,20 @@ import SwiftUI
struct ZapsView: View { struct ZapsView: View {
let state: DamusState let state: DamusState
@StateObject var model: ZapsModel var model: ZapsModel
@ObservedObject var zaps: ZapsDataModel
init(state: DamusState, target: ZapTarget) { init(state: DamusState, target: ZapTarget) {
self.state = state 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 { var body: some View {
ScrollView { ScrollView {
LazyVStack { LazyVStack {
ForEach(model.zaps, id: \.event.id) { zap in ForEach(zaps.zaps, id: \.request.id) { zap in
ZapEvent(damus: state, zap: zap) ZapEvent(damus: state, zap: zap)
.padding([.horizontal]) .padding([.horizontal])
} }