damus

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

commit 006f8d79e02f43b2bba555c397335ead274da546
parent 135432e03c7dc732dc719198451dda66e204ae53
Author: William Casarin <jb55@jb55.com>
Date:   Mon, 16 Jan 2023 12:57:31 -0800

Lightning Zaps

Added initial lightning zaps/tipping integration

Changelog-Added: Receive Lightning Zaps

Diffstat:
Mdamus.xcodeproj/project.pbxproj | 28++++++++++++++++++++++++++++
Mdamus/Components/InvoiceView.swift | 15+++++++--------
Mdamus/Components/InvoicesView.swift | 5+++--
Adamus/Components/ZapButton.swift | 130+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mdamus/ContentView.swift | 4+++-
Mdamus/Models/ActionBarModel.swift | 20+++++++++++---------
Mdamus/Models/DamusState.swift | 4+++-
Mdamus/Models/HomeModel.swift | 53+++++++++++++++++++++++++++++++++++++++++++++++++++++
Mdamus/Models/LikeCounter.swift | 9++++-----
Mdamus/Models/Mentions.swift | 90+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------
Mdamus/Models/UserSettingsStore.swift | 26++++++++++++++++++--------
Mdamus/Nostr/NostrEvent.swift | 28+++++++++++++++++++++++++++-
Mdamus/Nostr/NostrKind.swift | 1+
Mdamus/Nostr/Profiles.swift | 9+++++++++
Mdamus/Nostr/Relay.swift | 8++++----
Mdamus/Util/InsertSort.swift | 20++++++++++++++++++++
Adamus/Util/LNUrlPayRequest.swift | 24++++++++++++++++++++++++
Adamus/Util/LNUrls.swift | 20++++++++++++++++++++
Adamus/Util/Zap.swift | 322+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Adamus/Util/Zaps.swift | 65+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mdamus/Views/ActionBar/EventActionBar.swift | 18++++++++++++++----
Mdamus/Views/ActionBar/EventDetailBar.swift | 4++--
Mdamus/Views/ChatView.swift | 4++--
Mdamus/Views/DMView.swift | 2+-
Mdamus/Views/EventView.swift | 26++++++++++++++++++++++----
Mdamus/Views/NoteContentView.swift | 10+++++-----
Mdamus/Views/ProfileView.swift | 4++--
Mdamus/Views/ReplyQuoteView.swift | 6+++---
Mdamus/Views/SelectWalletView.swift | 6+++---
AdamusTests/FormatTests.swift | 37+++++++++++++++++++++++++++++++++++++
AdamusTests/ZapTests.swift | 45+++++++++++++++++++++++++++++++++++++++++++++
31 files changed, 962 insertions(+), 81 deletions(-)

diff --git a/damus.xcodeproj/project.pbxproj b/damus.xcodeproj/project.pbxproj @@ -131,6 +131,12 @@ 4CB88393296F798300DC99E7 /* ReactionsModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CB88392296F798300DC99E7 /* ReactionsModel.swift */; }; 4CB88396296F7F8B00DC99E7 /* ReactionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CB88395296F7F8B00DC99E7 /* ReactionView.swift */; }; 4CB8839A297322D200DC99E7 /* DMTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CB88399297322D200DC99E7 /* DMTests.swift */; }; + 4CB883A62975F83C00DC99E7 /* LNUrlPayRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CB883A52975F83C00DC99E7 /* LNUrlPayRequest.swift */; }; + 4CB883A82975FC1800DC99E7 /* Zaps.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CB883A72975FC1800DC99E7 /* Zaps.swift */; }; + 4CB883AA297612FF00DC99E7 /* ZapTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CB883A9297612FF00DC99E7 /* ZapTests.swift */; }; + 4CB883AE2976FA9300DC99E7 /* FormatTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CB883AD2976FA9300DC99E7 /* FormatTests.swift */; }; + 4CB883B0297705DD00DC99E7 /* ZapButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CB883AF297705DD00DC99E7 /* ZapButton.swift */; }; + 4CB883B6297730E400DC99E7 /* LNUrls.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CB883B5297730E400DC99E7 /* LNUrls.swift */; }; 4CBCA930297DB57F00EC6B2F /* WebsiteLink.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CBCA92F297DB57F00EC6B2F /* WebsiteLink.swift */; }; 4CC7AAEB297F0AEC00430951 /* BuilderEventView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CC7AAEA297F0AEC00430951 /* BuilderEventView.swift */; }; 4CC7AAED297F0B9E00430951 /* Highlight.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CC7AAEC297F0B9E00430951 /* Highlight.swift */; }; @@ -140,6 +146,7 @@ 4CC7AAF6297F1A6A00430951 /* EventBody.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CC7AAF5297F1A6A00430951 /* EventBody.swift */; }; 4CC7AAF8297F1CEE00430951 /* EventProfile.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CC7AAF7297F1CEE00430951 /* EventProfile.swift */; }; 4CC7AAFA297F64AC00430951 /* EventMenu.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CC7AAF9297F64AC00430951 /* EventMenu.swift */; }; + 4CC7AAE7297EFA7B00430951 /* Zap.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CC7AAE6297EFA7B00430951 /* Zap.swift */; }; 4CD7641B28A1641400B6928F /* EndBlock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CD7641A28A1641400B6928F /* EndBlock.swift */; }; 4CE4F8CD281352B30009DFBB /* Notifications.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CE4F8CC281352B30009DFBB /* Notifications.swift */; }; 4CE4F9DE2852768D00C00DD9 /* ConfigView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CE4F9DD2852768D00C00DD9 /* ConfigView.swift */; }; @@ -400,6 +407,12 @@ 4CB88392296F798300DC99E7 /* ReactionsModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReactionsModel.swift; sourceTree = "<group>"; }; 4CB88395296F7F8B00DC99E7 /* ReactionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReactionView.swift; sourceTree = "<group>"; }; 4CB88399297322D200DC99E7 /* DMTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DMTests.swift; sourceTree = "<group>"; }; + 4CB883A52975F83C00DC99E7 /* LNUrlPayRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LNUrlPayRequest.swift; sourceTree = "<group>"; }; + 4CB883A72975FC1800DC99E7 /* Zaps.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Zaps.swift; sourceTree = "<group>"; }; + 4CB883A9297612FF00DC99E7 /* ZapTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ZapTests.swift; sourceTree = "<group>"; }; + 4CB883AD2976FA9300DC99E7 /* FormatTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FormatTests.swift; sourceTree = "<group>"; }; + 4CB883AF297705DD00DC99E7 /* ZapButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ZapButton.swift; sourceTree = "<group>"; }; + 4CB883B5297730E400DC99E7 /* LNUrls.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LNUrls.swift; sourceTree = "<group>"; }; 4CBCA92F297DB57F00EC6B2F /* WebsiteLink.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebsiteLink.swift; sourceTree = "<group>"; }; 4CC7AAEA297F0AEC00430951 /* BuilderEventView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BuilderEventView.swift; sourceTree = "<group>"; }; 4CC7AAEC297F0B9E00430951 /* Highlight.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Highlight.swift; sourceTree = "<group>"; }; @@ -409,6 +422,7 @@ 4CC7AAF5297F1A6A00430951 /* EventBody.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EventBody.swift; sourceTree = "<group>"; }; 4CC7AAF7297F1CEE00430951 /* EventProfile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EventProfile.swift; sourceTree = "<group>"; }; 4CC7AAF9297F64AC00430951 /* EventMenu.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EventMenu.swift; sourceTree = "<group>"; }; + 4CC7AAE6297EFA7B00430951 /* Zap.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Zap.swift; sourceTree = "<group>"; }; 4CD7641A28A1641400B6928F /* EndBlock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EndBlock.swift; sourceTree = "<group>"; }; 4CE4F8CC281352B30009DFBB /* Notifications.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Notifications.swift; sourceTree = "<group>"; }; 4CE4F9DD2852768D00C00DD9 /* ConfigView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConfigView.swift; sourceTree = "<group>"; }; @@ -694,6 +708,7 @@ isa = PBXGroup; children = ( 4CF0ABEA29844B2F00D66079 /* AnyCodable */, + 4CC7AAE6297EFA7B00430951 /* Zap.swift */, 4C3A1D322960DB0500558C0F /* Markdown.swift */, 4CEE2AF4280B29E600AB5EEF /* TimeAgo.swift */, 4CE4F8CC281352B30009DFBB /* Notifications.swift */, @@ -712,6 +727,9 @@ 4CF0ABEF29857E9200D66079 /* Bech32Object.swift */, 7C60CAEE298471A1009C80D6 /* CoreSVG.swift */, 4CAAD8AC298851D000060CEA /* AccountDeletion.swift */, + 4CB883A52975F83C00DC99E7 /* LNUrlPayRequest.swift */, + 4CB883A72975FC1800DC99E7 /* Zaps.swift */, + 4CB883B5297730E400DC99E7 /* LNUrls.swift */, ); path = Util; sourceTree = "<group>"; @@ -774,6 +792,7 @@ 5C513FB9297F72980072348F /* CustomPicker.swift */, 4CF0ABE22981BC7D00D66079 /* UserView.swift */, 7C902AE22981D55B002AB16E /* ZoomableScrollView.swift */, + 4CB883AF297705DD00DC99E7 /* ZapButton.swift */, ); path = Components; sourceTree = "<group>"; @@ -842,6 +861,8 @@ 3ACBCB77295FE5C70037388A /* TimeAgoTests.swift */, 4CB88399297322D200DC99E7 /* DMTests.swift */, 4CF0ABDB2981A19E00D66079 /* ListTests.swift */, + 4CB883A9297612FF00DC99E7 /* ZapTests.swift */, + 4CB883AD2976FA9300DC99E7 /* FormatTests.swift */, ); path = damusTests; sourceTree = "<group>"; @@ -1081,6 +1102,7 @@ 4C285C8A2838B985008A31F1 /* ProfilePictureSelector.swift in Sources */, 4C75EFB92804A2740006080F /* EventView.swift in Sources */, 3AA247FD297E3CFF0090C62D /* RepostsModel.swift in Sources */, + 4CB883B6297730E400DC99E7 /* LNUrls.swift in Sources */, 4C7FF7D52823313F009601DB /* Mentions.swift in Sources */, 4C633350283D40E500B1C9C3 /* HomeModel.swift in Sources */, 4C987B57283FD07F0042CE38 /* FollowersModel.swift in Sources */, @@ -1095,6 +1117,7 @@ 4C216F362870A9A700040376 /* InputDismissKeyboard.swift in Sources */, 4C216F382871EDE300040376 /* DirectMessageModel.swift in Sources */, 4C75EFA627FF87A20006080F /* Nostr.swift in Sources */, + 4CB883A62975F83C00DC99E7 /* LNUrlPayRequest.swift in Sources */, 4CE4F9DE2852768D00C00DD9 /* ConfigView.swift in Sources */, 4C285C8E28399BFE008A31F1 /* SaveKeysView.swift in Sources */, F7F0BA25297892BD009531F3 /* SwipeToDismiss.swift in Sources */, @@ -1113,6 +1136,7 @@ 4C3BEFDC281DCE6100B3DE84 /* Liked.swift in Sources */, 4CF0ABE7298444FD00D66079 /* MutedEventView.swift in Sources */, 4CF0ABE12981A83900D66079 /* MutelistView.swift in Sources */, + 4CB883A82975FC1800DC99E7 /* Zaps.swift in Sources */, 4C75EFB128049D510006080F /* NostrResponse.swift in Sources */, 4CEE2AF7280B2DEA00AB5EEF /* ProfileName.swift in Sources */, 4CC7AAEB297F0AEC00430951 /* BuilderEventView.swift in Sources */, @@ -1126,6 +1150,7 @@ 4C0A3F8F280F640A000448DE /* ThreadModel.swift in Sources */, 4CC7AAF2297F129C00430951 /* EmbeddedEventView.swift in Sources */, 4C3AC79F2833115300E1F516 /* FollowButtonView.swift in Sources */, + 4CC7AAE7297EFA7B00430951 /* Zap.swift in Sources */, 4C3BEFD22819DB9B00B3DE84 /* ProfileModel.swift in Sources */, 4C0A3F93280F66F5000448DE /* ReplyMap.swift in Sources */, BAB68BED29543FA3007BA466 /* SelectWalletView.swift in Sources */, @@ -1188,6 +1213,7 @@ 4C06670E28FDEAA000038D2A /* utf8.c in Sources */, 4C3EA66D28FF782800C48A62 /* amount.c in Sources */, 4C3AC7A728369BA200E1F516 /* SearchHomeView.swift in Sources */, + 4CB883B0297705DD00DC99E7 /* ZapButton.swift in Sources */, 4C363A922825FCF2006E126D /* ProfileUpdate.swift in Sources */, 4C0A3F95280F6C78000448DE /* ReplyQuoteView.swift in Sources */, 4C3BEFDA281DCA1400B3DE84 /* LikeCounter.swift in Sources */, @@ -1231,7 +1257,9 @@ DD597CBD2963D85A00C64D32 /* MarkdownTests.swift in Sources */, 4C3EA67B28FF7B3900C48A62 /* InvoiceTests.swift in Sources */, 4C363A9E2828A822006E126D /* ReplyTests.swift in Sources */, + 4CB883AA297612FF00DC99E7 /* ZapTests.swift in Sources */, 4CB8839A297322D200DC99E7 /* DMTests.swift in Sources */, + 4CB883AE2976FA9300DC99E7 /* FormatTests.swift in Sources */, 4C363AA02828A8DD006E126D /* LikeTests.swift in Sources */, 4C90BD1C283AC38E008EE7EF /* Bech32Tests.swift in Sources */, 4CE6DEF827F7A08200C66700 /* damusTests.swift in Sources */, diff --git a/damus/Components/InvoiceView.swift b/damus/Components/InvoiceView.swift @@ -31,17 +31,16 @@ func open_with_wallet(wallet: Wallet.Model, invoice: String) { struct InvoiceView: View { @Environment(\.colorScheme) var colorScheme @Environment(\.openURL) private var openURL - + let our_pubkey: String let invoice: Invoice @State var showing_select_wallet: Bool = false - @EnvironmentObject var user_settings: UserSettingsStore var PayButton: some View { Button { - if user_settings.show_wallet_selector { + if should_show_wallet_selector(our_pubkey) { showing_select_wallet = true } else { - open_with_wallet(wallet: user_settings.default_wallet.model, invoice: invoice.string) + open_with_wallet(wallet: get_default_wallet(our_pubkey).model, invoice: invoice.string) } } label: { RoundedRectangle(cornerRadius: 20) @@ -70,7 +69,7 @@ struct InvoiceView: View { Text("Lightning Invoice", comment: "Indicates that the view is for paying a Lightning invoice.") } Divider() - Text(invoice.description) + Text(invoice.description_string) Text(invoice.amount.amount_sats_str()) .font(.title) PayButton @@ -80,16 +79,16 @@ struct InvoiceView: View { .padding(30) } .sheet(isPresented: $showing_select_wallet, onDismiss: {showing_select_wallet = false}) { - SelectWalletView(showingSelectWallet: $showing_select_wallet, invoice: invoice.string).environmentObject(user_settings) + SelectWalletView(showingSelectWallet: $showing_select_wallet, our_pubkey: our_pubkey, invoice: invoice.string) } } } -let test_invoice = Invoice(description: "this is a description", amount: .specific(10000), string: "lnbc100n1p357sl0sp5t9n56wdztun39lgdqlr30xqwksg3k69q4q2rkr52aplujw0esn0qpp5mrqgljk62z20q4nvgr6lzcyn6fhylzccwdvu4k77apg3zmrkujjqdpzw35xjueqd9ejqcfqv3jhxcmjd9c8g6t0dcxqyjw5qcqpjrzjqt56h4gvp5yx36u2uzqa6qwcsk3e2duunfxppzj9vhypc3wfe2wswz607uqq3xqqqsqqqqqqqqqqqlqqyg9qyysgqagx5h20aeulj3gdwx3kxs8u9f4mcakdkwuakasamm9562ffyr9en8yg20lg0ygnr9zpwp68524kmda0t5xp2wytex35pu8hapyjajxqpsql29r", expiry: 604800, payment_hash: Data(), created_at: 1666139119) +let test_invoice = Invoice(description: .description("this is a description"), amount: .specific(10000), string: "lnbc100n1p357sl0sp5t9n56wdztun39lgdqlr30xqwksg3k69q4q2rkr52aplujw0esn0qpp5mrqgljk62z20q4nvgr6lzcyn6fhylzccwdvu4k77apg3zmrkujjqdpzw35xjueqd9ejqcfqv3jhxcmjd9c8g6t0dcxqyjw5qcqpjrzjqt56h4gvp5yx36u2uzqa6qwcsk3e2duunfxppzj9vhypc3wfe2wswz607uqq3xqqqsqqqqqqqqqqqlqqyg9qyysgqagx5h20aeulj3gdwx3kxs8u9f4mcakdkwuakasamm9562ffyr9en8yg20lg0ygnr9zpwp68524kmda0t5xp2wytex35pu8hapyjajxqpsql29r", expiry: 604800, payment_hash: Data(), created_at: 1666139119) struct InvoiceView_Previews: PreviewProvider { static var previews: some View { - InvoiceView(invoice: test_invoice) + InvoiceView(our_pubkey: "", invoice: test_invoice) .frame(width: 200, height: 200) } } diff --git a/damus/Components/InvoicesView.swift b/damus/Components/InvoicesView.swift @@ -8,6 +8,7 @@ import SwiftUI struct InvoicesView: View { + let our_pubkey: String var invoices: [Invoice] @State var open_sheet: Bool = false @@ -16,7 +17,7 @@ struct InvoicesView: View { var body: some View { TabView { ForEach(invoices, id: \.string) { invoice in - InvoiceView(invoice: invoice) + InvoiceView(our_pubkey: our_pubkey, invoice: invoice) .tabItem { Text(invoice.string) } @@ -30,7 +31,7 @@ struct InvoicesView: View { struct InvoicesView_Previews: PreviewProvider { static var previews: some View { - InvoicesView(invoices: [Invoice.init(description: "description", amount: .specific(10000), string: "invstr", expiry: 100000, payment_hash: Data(), created_at: 1000000)]) + InvoicesView(our_pubkey: "", invoices: [Invoice.init(description: .description("description"), amount: .specific(10000), string: "invstr", expiry: 100000, payment_hash: Data(), created_at: 1000000)]) .frame(width: 300) } } diff --git a/damus/Components/ZapButton.swift b/damus/Components/ZapButton.swift @@ -0,0 +1,130 @@ +// +// ZapButton.swift +// damus +// +// Created by William Casarin on 2023-01-17. +// + +import SwiftUI + +struct ZapButton: View { + let damus_state: DamusState + let event: NostrEvent + let lnurl: String + + @ObservedObject var bar: ActionBarModel + + @State var zapping: Bool = false + @State var invoice: String = "" + @State var slider_value: Double = 0.0 + @State var slider_visible: Bool = false + @State var showing_select_wallet: Bool = false + + func send_zap() { + guard let privkey = damus_state.keypair.privkey else { + return + } + + // Only take the first 10 because reasons + let relays = Array(damus_state.pool.descriptors.prefix(10)) + let target = ZapTarget.note(id: event.id, author: event.pubkey) + // TODO: gather comment? + let content = "" + let zapreq = make_zap_request_event(pubkey: damus_state.pubkey, privkey: privkey, content: content, relays: relays, target: target) + + zapping = true + + Task { + var mpayreq = damus_state.lnurls.lookup(target.pubkey) + if mpayreq == nil { + mpayreq = await fetch_static_payreq(lnurl) + } + + guard let payreq = mpayreq else { + // TODO: show error + DispatchQueue.main.async { + zapping = false + } + return + } + + DispatchQueue.main.async { + damus_state.lnurls.endpoints[target.pubkey] = payreq + } + + guard let inv = await fetch_zap_invoice(payreq, zapreq: zapreq, amount: 1000000) else { + DispatchQueue.main.async { + zapping = false + } + return + } + + DispatchQueue.main.async { + zapping = false + + if should_show_wallet_selector(damus_state.pubkey) { + self.invoice = inv + self.showing_select_wallet = true + } else { + open_with_wallet(wallet: get_default_wallet(damus_state.pubkey).model, invoice: inv) + } + } + } + + //damus_state.pool.send(.event(zapreq)) + } + + var zap_img: String { + if bar.zapped { + return "bolt.fill" + } + + if !zapping { + return "bolt" + } + + return "bolt.horizontal.fill" + } + + var zap_color: Color? { + if bar.zapped { + return Color.orange + } + + if !zapping { + return nil + } + + return Color.yellow + } + + var body: some View { + ZStack { + EventActionButton(img: zap_img, col: zap_color) { + if bar.zapped { + //notify(.delete, bar.our_tip) + } else if !zapping { + send_zap() + } + } + + Text("\(bar.zap_total > 0 ? "\(format_msats_abbrev(bar.zap_total))" : "")") + .font(.footnote) + .foregroundColor(bar.zapped ? Color.orange : Color.gray) + } + .sheet(isPresented: $showing_select_wallet, onDismiss: {showing_select_wallet = false}) { + SelectWalletView(showingSelectWallet: $showing_select_wallet, our_pubkey: damus_state.pubkey, invoice: invoice) + } + } +} + + +/* +struct ZapButton_Previews: PreviewProvider { + static var previews: some View { + let bar = ActionBarModel.empty() + ZapButton(damus_state: test_damus_state(), event: NostrEvent(content: "hi", pubkey: "pk"), bar: bar) + } +} + +*/ diff --git a/damus/ContentView.swift b/damus/ContentView.swift @@ -561,7 +561,9 @@ struct ContentView: View { tips: TipCounter(our_pubkey: pubkey), profiles: Profiles(), dms: home.dms, - previews: PreviewCache() + previews: PreviewCache(), + zaps: Zaps(our_pubkey: pubkey), + lnurls: LNUrls() ) home.damus_state = self.damus_state! diff --git a/damus/Models/ActionBarModel.swift b/damus/Models/ActionBarModel.swift @@ -11,30 +11,32 @@ import Foundation class ActionBarModel: ObservableObject { @Published var our_like: NostrEvent? @Published var our_boost: NostrEvent? - @Published var our_tip: NostrEvent? + @Published var our_zap: Zap? @Published var likes: Int @Published var boosts: Int - @Published var tips: Int64 + @Published var zaps: Int + @Published var zap_total: Int64 static func empty() -> ActionBarModel { - return ActionBarModel(likes: 0, boosts: 0, tips: 0, our_like: nil, our_boost: nil, our_tip: nil) + return ActionBarModel(likes: 0, boosts: 0, zaps: 0, zap_total: 0, our_like: nil, our_boost: nil, our_zap: nil) } - init(likes: Int, boosts: Int, tips: Int64, our_like: NostrEvent?, our_boost: NostrEvent?, our_tip: NostrEvent?) { + init(likes: Int, boosts: Int, zaps: Int, zap_total: Int64, our_like: NostrEvent?, our_boost: NostrEvent?, our_zap: Zap?) { self.likes = likes self.boosts = boosts - self.tips = tips + self.zaps = zaps + self.zap_total = zap_total self.our_like = our_like self.our_boost = our_boost - self.our_tip = our_tip + self.our_zap = our_zap } var is_empty: Bool { - return likes == 0 && boosts == 0 && tips == 0 + return likes == 0 && boosts == 0 && zaps == 0 } - var tipped: Bool { - return our_tip != nil + var zapped: Bool { + return our_zap != nil } var liked: Bool { diff --git a/damus/Models/DamusState.swift b/damus/Models/DamusState.swift @@ -18,6 +18,8 @@ struct DamusState { let profiles: Profiles let dms: DirectMessagesModel let previews: PreviewCache + let zaps: Zaps + let lnurls: LNUrls var pubkey: String { return keypair.pubkey @@ -29,6 +31,6 @@ struct DamusState { static var empty: DamusState { - return DamusState.init(pool: RelayPool(), keypair: Keypair(pubkey: "", privkey: ""), likes: EventCounter(our_pubkey: ""), boosts: EventCounter(our_pubkey: ""), contacts: Contacts(our_pubkey: ""), tips: TipCounter(our_pubkey: ""), profiles: Profiles(), dms: DirectMessagesModel(our_pubkey: ""), previews: PreviewCache()) + return DamusState.init(pool: RelayPool(), keypair: Keypair(pubkey: "", privkey: ""), likes: EventCounter(our_pubkey: ""), boosts: EventCounter(our_pubkey: ""), contacts: Contacts(our_pubkey: ""), tips: TipCounter(our_pubkey: ""), profiles: Profiles(), dms: DirectMessagesModel(our_pubkey: ""), previews: PreviewCache(), zaps: Zaps(our_pubkey: ""), lnurls: LNUrls()) } } diff --git a/damus/Models/HomeModel.swift b/damus/Models/HomeModel.swift @@ -112,9 +112,61 @@ class HomeModel: ObservableObject { handle_channel_create(ev) case .channel_meta: handle_channel_meta(ev) + case .zap: + handle_zap_event(ev) } } + func handle_zap_event_with_zapper(_ ev: NostrEvent, zapper: String) { + guard let zap = Zap.from_zap_event(zap_ev: ev, zapper: zapper) else { + return + } + + damus_state.zaps.add_zap(zap: zap) + + if !insert_uniq_sorted_event(events: &notifications, new_ev: ev, cmp: { $0.created_at > $1.created_at }) { + return + } + + handle_last_event(ev: ev, timeline: .notifications) + return + } + + func handle_zap_event(_ ev: NostrEvent) { + // These are zap notifications + guard let ptag = event_tag(ev, name: "p") else { + return + } + + guard ptag == damus_state.pubkey else { + return + } + + if let local_zapper = damus_state.profiles.lookup_zapper(pubkey: damus_state.pubkey) { + handle_zap_event_with_zapper(ev, zapper: local_zapper) + return + } + + guard let profile = damus_state.profiles.lookup(id: damus_state.pubkey) else { + return + } + + guard let lnurl = profile.lnurl else { + return + } + + Task { + guard let zapper = await fetch_zapper_from_lnurl(lnurl) else { + return + } + + DispatchQueue.main.async { + self.handle_zap_event_with_zapper(ev, zapper: zapper) + } + } + + } + func handle_channel_create(_ ev: NostrEvent) { guard ev.is_valid else { return @@ -317,6 +369,7 @@ class HomeModel: ObservableObject { NostrKind.chat.rawValue, NostrKind.like.rawValue, NostrKind.boost.rawValue, + NostrKind.zap.rawValue, ]) notifications_filter.pubkeys = [damus_state.pubkey] notifications_filter.limit = 100 diff --git a/damus/Models/LikeCounter.swift b/damus/Models/LikeCounter.swift @@ -7,6 +7,10 @@ import Foundation +enum CountResult { + case already_counted + case success(Int) +} class EventCounter { var counts: [String: Int] = [:] @@ -14,11 +18,6 @@ class EventCounter { var our_events: [String: NostrEvent] = [:] var our_pubkey: String - enum CountResult { - case already_counted - case success(Int) - } - init (our_pubkey: String) { self.our_pubkey = our_pubkey } diff --git a/damus/Models/Mentions.swift b/damus/Models/Mentions.swift @@ -32,13 +32,30 @@ struct IdBlock: Identifiable { let block: Block } -struct Invoice { - let description: String - let amount: Amount +typealias Invoice = LightningInvoice<Amount> +typealias ZapInvoice = LightningInvoice<Int64> + +enum InvoiceDescription { + case description(String) + case description_hash(Data) +} + +struct LightningInvoice<T> { + let description: InvoiceDescription + let amount: T let string: String let expiry: UInt64 let payment_hash: Data let created_at: UInt64 + + var description_string: String { + switch description { + case .description(let string): + return string + case .description_hash(let data): + return "" + } + } } enum Block { @@ -189,20 +206,50 @@ enum Amount: Equatable { case .any: return NSLocalizedString("Any", comment: "Any amount of sats") case .specific(let amt): - let numberFormatter = NumberFormatter() - numberFormatter.numberStyle = .decimal - numberFormatter.minimumFractionDigits = 0 - numberFormatter.maximumFractionDigits = 3 - numberFormatter.roundingMode = .down - - let sats = NSNumber(value: (Double(amt) / 1000.0)) - let formattedSats = numberFormatter.string(from: sats) ?? sats.stringValue - - return String(format: NSLocalizedString("sats_count", comment: "Amount of sats."), sats.decimalValue as NSDecimalNumber, formattedSats) + return format_msats(amt) } } } +func format_msats_abbrev(_ msats: Int64) -> String { + let formatter = NumberFormatter() + formatter.numberStyle = .decimal + formatter.positiveSuffix = "m" + formatter.positivePrefix = "" + formatter.minimumFractionDigits = 0 + formatter.maximumFractionDigits = 3 + formatter.roundingMode = .down + formatter.roundingIncrement = 0.1 + formatter.multiplier = 1 + + let sats = NSNumber(value: (Double(msats) / 1000.0)) + + if msats >= 1_000_000*1000 { + formatter.positiveSuffix = "m" + formatter.multiplier = 0.000001 + } else if msats >= 1000*1000 { + formatter.positiveSuffix = "k" + formatter.multiplier = 0.001 + } else { + return sats.stringValue + } + + return formatter.string(from: sats) ?? sats.stringValue +} + +func format_msats(_ msat: Int64) -> String { + let numberFormatter = NumberFormatter() + numberFormatter.numberStyle = .decimal + numberFormatter.minimumFractionDigits = 0 + numberFormatter.maximumFractionDigits = 3 + numberFormatter.roundingMode = .down + + let sats = NSNumber(value: (Double(msat) / 1000.0)) + let formattedSats = numberFormatter.string(from: sats) ?? sats.stringValue + + return String(format: NSLocalizedString("sats_count", comment: "Amount of sats."), sats.decimalValue as NSDecimalNumber, formattedSats) +} + func convert_invoice_block(_ b: invoice_block) -> Block? { guard let invstr = strblock_to_string(b.invstr) else { return nil @@ -212,9 +259,8 @@ func convert_invoice_block(_ b: invoice_block) -> Block? { return nil } - var description = "" - if b11.description != nil { - description = String(cString: b11.description) + guard let description = convert_invoice_description(b11: b11) else { + return nil } let amount: Amount = maybe_pointee(b11.msat).map { .specific(Int64($0.millisatoshis)) } ?? .any @@ -225,6 +271,18 @@ func convert_invoice_block(_ b: invoice_block) -> Block? { return .invoice(Invoice(description: description, amount: amount, string: invstr, expiry: b11.expiry, payment_hash: payment_hash, created_at: created_at)) } +func convert_invoice_description(b11: bolt11) -> InvoiceDescription? { + if let desc = b11.description { + return .description(String(cString: desc)) + } + + if var deschash = maybe_pointee(b11.description_hash) { + return .description_hash(Data(bytes: &deschash, count: 32)) + } + + return nil +} + func convert_mention_block(ind: Int32, tags: [[String]]) -> Block? { let ind = Int(ind) diff --git a/damus/Models/UserSettingsStore.swift b/damus/Models/UserSettingsStore.swift @@ -8,6 +8,20 @@ import Foundation import Vault +func should_show_wallet_selector(_ pubkey: String) -> Bool { + return UserDefaults.standard.object(forKey: "show_wallet_selector") as? Bool ?? true +} + +func get_default_wallet(_ pubkey: String) -> Wallet { + if let defaultWalletName = UserDefaults.standard.string(forKey: "default_wallet"), + let default_wallet = Wallet(rawValue: defaultWalletName) + { + return default_wallet + } else { + return .system_default_wallet + } +} + class UserSettingsStore: ObservableObject { @Published var default_wallet: Wallet { didSet { @@ -66,14 +80,10 @@ class UserSettingsStore: ObservableObject { } init() { - if let defaultWalletName = UserDefaults.standard.string(forKey: "default_wallet"), - let default_wallet = Wallet(rawValue: defaultWalletName) - { - self.default_wallet = default_wallet - } else { - default_wallet = .system_default_wallet - } - show_wallet_selector = UserDefaults.standard.object(forKey: "show_wallet_selector") as? Bool ?? true + // TODO: pubkey-scoped settings + let pubkey = "" + self.default_wallet = get_default_wallet("") + show_wallet_selector = should_show_wallet_selector("") left_handed = UserDefaults.standard.object(forKey: "left_handed") as? Bool ?? false diff --git a/damus/Nostr/NostrEvent.swift b/damus/Nostr/NostrEvent.swift @@ -11,6 +11,8 @@ import secp256k1 import secp256k1_implementation import CryptoKit + + enum ValidationResult: Decodable { case ok case bad_id @@ -79,7 +81,7 @@ class NostrEvent: Codable, Identifiable, CustomStringConvertible, Equatable, Has } var too_big: Bool { - return self.content.count > 32000 + return self.content.count > 16000 } var should_show_event: Bool { @@ -371,6 +373,10 @@ func encode_json<T: Encodable>(_ val: T) -> String? { return (try? encoder.encode(val)).map { String(decoding: $0, as: UTF8.self) } } +func decode_nostr_event_json(json: String) -> NostrEvent? { + return decode_json(json) +} + func decode_json<T: Decodable>(_ val: String) -> T? { return try? JSONDecoder().decode(T.self, from: Data(val.utf8)) } @@ -571,6 +577,26 @@ func make_like_event(pubkey: String, privkey: String, liked: NostrEvent) -> Nost return ev } +func zap_target_to_tags(_ target: ZapTarget) -> [[String]] { + switch target { + case .profile(let pk): + return [["p", pk]] + case .note(let note_target): + return [["e", note_target.note_id], ["p", note_target.author]] + } +} + +func make_zap_request_event(pubkey: String, privkey: String, content: String, relays: [RelayDescriptor], target: ZapTarget) -> NostrEvent { + var tags = zap_target_to_tags(target) + var relay_tag = ["relays"] + relay_tag.append(contentsOf: relays.map { $0.url.absoluteString }) + tags.append(relay_tag) + let ev = NostrEvent(content: content, pubkey: pubkey, kind: 9734, tags: tags) + ev.id = calculate_event_id(ev: ev) + ev.sig = sign_event(privkey: privkey, ev: ev) + return ev +} + func gather_reply_ids(our_pubkey: String, from: NostrEvent) -> [ReferencedId] { var ids = get_referenced_ids(tags: from.tags, key: "e").first.map { [$0] } ?? [] diff --git a/damus/Nostr/NostrKind.swift b/damus/Nostr/NostrKind.swift @@ -20,4 +20,5 @@ enum NostrKind: Int { case channel_meta = 41 case chat = 42 case list = 30000 + case zap = 9735 } diff --git a/damus/Nostr/Profiles.swift b/damus/Nostr/Profiles.swift @@ -12,11 +12,20 @@ import UIKit class Profiles { var profiles: [String: TimestampedProfile] = [:] var validated: [String: NIP05] = [:] + var zappers: [String: String] = [:] func is_validated(_ pk: String) -> NIP05? { return validated[pk] } + func lookup_zapper(pubkey: String) -> String? { + if let zapper = zappers[pubkey] { + return zapper + } + + return nil + } + func add(id: String, profile: TimestampedProfile) { profiles[id] = profile } diff --git a/damus/Nostr/Relay.swift b/damus/Nostr/Relay.swift @@ -7,16 +7,16 @@ import Foundation -struct RelayInfo: Codable { +public struct RelayInfo: Codable { let read: Bool let write: Bool static let rw = RelayInfo(read: true, write: true) } -struct RelayDescriptor: Codable { - let url: URL - let info: RelayInfo +public struct RelayDescriptor: Codable { + public let url: URL + public let info: RelayInfo } enum RelayFlags: Int { diff --git a/damus/Util/InsertSort.swift b/damus/Util/InsertSort.swift @@ -38,6 +38,26 @@ func insert_uniq_by_pubkey(events: inout [NostrEvent], new_ev: NostrEvent, cmp: return true } +func insert_uniq_sorted_zap(zaps: inout [Zap], new_zap: Zap) -> Bool { + var i: Int = 0 + + for zap in zaps { + // don't insert duplicate events + if new_zap.event.id == zap.event.id { + return false + } + + if new_zap.invoice.amount > zap.invoice.amount { + zaps.insert(new_zap, at: i) + return true + } + i += 1 + } + + zaps.append(new_zap) + return true +} + func insert_uniq_sorted_event(events: inout [NostrEvent], new_ev: NostrEvent, cmp: (NostrEvent, NostrEvent) -> Bool) -> Bool { var i: Int = 0 diff --git a/damus/Util/LNUrlPayRequest.swift b/damus/Util/LNUrlPayRequest.swift @@ -0,0 +1,24 @@ +// +// LNUrl.swift +// damus +// +// Created by William Casarin on 2023-01-16. +// + +import Foundation + +struct LNUrlPayRequest: Decodable { + let allowsNostr: Bool? + let nostrPubkey: String? + + let minSendable: Int64? + let maxSendable: Int64? + let status: String? + let callback: String? +} + + + +struct LNUrlPayResponse: Decodable { + let pr: String +} diff --git a/damus/Util/LNUrls.swift b/damus/Util/LNUrls.swift @@ -0,0 +1,20 @@ +// +// LNUrls.swift +// damus +// +// Created by William Casarin on 2023-01-17. +// + +import Foundation + +class LNUrls { + var endpoints: [String: LNUrlPayRequest] + + init() { + self.endpoints = [:] + } + + func lookup(_ id: String) -> LNUrlPayRequest? { + return self.endpoints[id] + } +} diff --git a/damus/Util/Zap.swift b/damus/Util/Zap.swift @@ -0,0 +1,322 @@ +// +// Zap.swift +// damus +// +// Created by William Casarin on 2023-01-15. +// + +import Foundation + +enum ZapSource { + case author(String) + // TODO: anonymous + //case anonymous +} + +public struct NoteZapTarget: Equatable { + public let note_id: String + public let author: String +} + +public enum ZapTarget: Equatable { + case profile(String) + case note(NoteZapTarget) + + public static func note(id: String, author: String) -> ZapTarget { + return .note(NoteZapTarget(note_id: id, author: author)) + } + + var pubkey: String { + switch self { + case .profile(let pk): + return pk + case .note(let note_target): + return note_target.author + } + } + + var id: String { + switch self { + case .note(let note_target): + return note_target.note_id + case .profile(let pk): + return pk + } + } +} + +struct ZapRequest { + let ev: NostrEvent +} + +struct Zap { + public let event: NostrEvent + public let invoice: ZapInvoice + public let zapper: String /// zap authorizer + public let target: ZapTarget + public let request: ZapRequest + + public static func from_zap_event(zap_ev: NostrEvent, zapper: String) -> Zap? { + /// Make sure that we only create a zap event if it is authorized by the profile or event + guard zapper == zap_ev.pubkey else { + return nil + } + guard let bolt11_str = event_tag(zap_ev, name: "bolt11") else { + return nil + } + guard let bolt11 = decode_bolt11(bolt11_str) else { + return nil + } + /// Any amount invoices are not allowed + guard let zap_invoice = invoice_to_zap_invoice(bolt11) else { + return nil + } + // Some endpoints don't have this, let's skip the check for now. We're mostly trusting the zapper anyways + /* + guard let preimage = event_tag(zap_ev, name: "preimage") else { + return nil + } + guard preimage_matches_invoice(preimage, inv: zap_invoice) else { + return nil + } + */ + guard let desc = get_zap_description(zap_ev, inv_desc: zap_invoice.description) else { + return nil + } + guard let zap_req = decode_nostr_event_json(desc) else { + return nil + } + guard let target = determine_zap_target(zap_req) else { + return nil + } + + return Zap(event: zap_ev, invoice: zap_invoice, zapper: zapper, target: target, request: ZapRequest(ev: zap_req)) + } +} + +/// Fetches the description from either the invoice, or tags, depending on the type of invoice +func get_zap_description(_ ev: NostrEvent, inv_desc: InvoiceDescription) -> String? { + switch inv_desc { + case .description(let string): + return string + case .description_hash(let deschash): + guard let desc = event_tag(ev, name: "description") else { + return nil + } + guard let data = desc.data(using: .utf8) else { + return nil + } + guard sha256(data) == deschash else { + return nil + } + + return desc + } +} + +func invoice_to_zap_invoice(_ invoice: Invoice) -> ZapInvoice? { + guard case .specific(let amt) = invoice.amount else { + return nil + } + + return ZapInvoice(description: invoice.description, amount: amt, string: invoice.string, expiry: invoice.expiry, payment_hash: invoice.payment_hash, created_at: invoice.created_at) +} + +func preimage_matches_invoice<T>(_ preimage: String, inv: LightningInvoice<T>) -> Bool { + guard let raw_preimage = hex_decode(preimage) else { + return false + } + + let hashed = sha256(Data(raw_preimage)) + + return inv.payment_hash == hashed +} + +func determine_zap_target(_ ev: NostrEvent) -> ZapTarget? { + guard let ptag = event_tag(ev, name: "p") else { + return nil + } + + if let etag = event_tag(ev, name: "e") { + return ZapTarget.note(id: etag, author: ptag) + } + + return .profile(ptag) +} + +func decode_bolt11(_ s: String) -> Invoice? { + var bs = blocks() + bs.num_blocks = 0 + blocks_init(&bs) + + let bytes = s.utf8CString + let _ = bytes.withUnsafeBufferPointer { p in + damus_parse_content(&bs, p.baseAddress) + } + + guard bs.num_blocks == 1 else { + blocks_free(&bs) + return nil + } + + let block = bs.blocks[0] + + guard let converted = convert_block(block, tags: []) else { + blocks_free(&bs) + return nil + } + + guard case .invoice(let invoice) = converted else { + blocks_free(&bs) + return nil + } + + blocks_free(&bs) + return invoice +} + +func event_tag(_ ev: NostrEvent, name: String) -> String? { + for tag in ev.tags { + if tag.count >= 2 && tag[0] == name { + return tag[1] + } + } + + return nil +} + +func decode_nostr_event_json(_ desc: String) -> NostrEvent? { + let decoder = JSONDecoder() + guard let dat = desc.data(using: .utf8) else { + return nil + } + guard let ev = try? decoder.decode(NostrEvent.self, from: dat) else { + return nil + } + + return ev +} + +func decode_zap_request(_ desc: String) -> ZapRequest? { + let decoder = JSONDecoder() + guard let jsonData = desc.data(using: .utf8) else { + return nil + } + guard let jsonArray = try? JSONSerialization.jsonObject(with: jsonData) as? [[Any]] else { + return nil + } + + for array in jsonArray { + guard array.count == 2 else { + continue + } + let mkey = array.first.flatMap { $0 as? String } + if let key = mkey, key == "application/nostr" { + guard let dat = try? JSONSerialization.data(withJSONObject: array[1], options: []) else { + return nil + } + + guard let zap_req = try? decoder.decode(NostrEvent.self, from: dat) else { + return nil + } + + guard zap_req.kind == 9734 else { + return nil + } + + /// Ensure the signature on the zap request is correct + guard case .ok = validate_event(ev: zap_req) else { + return nil + } + + return ZapRequest(ev: zap_req) + } + } + + return nil +} + + + +func fetch_zapper_from_lnurl(_ lnurl: String) async -> String? { + guard let endpoint = await fetch_static_payreq(lnurl) else { + return nil + } + + guard let allows = endpoint.allowsNostr, allows else { + return nil + } + + guard let key = endpoint.nostrPubkey, key.count == 64 else { + return nil + } + + return endpoint.nostrPubkey +} + +func decode_lnurl(_ lnurl: String) -> URL? { + guard let decoded = try? bech32_decode(lnurl) else { + return nil + } + guard decoded.hrp == "lnurl" else { + return nil + } + guard let url = URL(string: String(decoding: decoded.data, as: UTF8.self)) else { + return nil + } + return url +} + +func fetch_static_payreq(_ lnurl: String) async -> LNUrlPayRequest? { + guard let url = decode_lnurl(lnurl) else { + return nil + } + + guard let ret = try? await URLSession.shared.data(from: url) else { + return nil + } + + let json_str = String(decoding: ret.0, as: UTF8.self) + + guard let endpoint: LNUrlPayRequest = decode_json(json_str) else { + return nil + } + + return endpoint +} + +func fetch_zap_invoice(_ payreq: LNUrlPayRequest, zapreq: NostrEvent, amount: Int64) async -> String? { + guard var base_url = payreq.callback.flatMap({ URLComponents(string: $0) }) else { + return nil + } + + let zappable = payreq.allowsNostr ?? false + + var query = [URLQueryItem(name: "amount", value: "\(amount)")] + + if zappable { + if let json = encode_json(zapreq) { + query.append(URLQueryItem(name: "nostr", value: json)) + } + } + + base_url.queryItems = query + + guard let url = base_url.url else { + return nil + } + + print("url \(url)") + + guard let ret = try? await URLSession.shared.data(from: url) else { + return nil + } + + let json_str = String(decoding: ret.0, as: UTF8.self) + guard let result: LNUrlPayResponse = decode_json(json_str) else { + print("fetch_zap_invoice error: \(json_str)") + return nil + } + + return result.pr +} diff --git a/damus/Util/Zaps.swift b/damus/Util/Zaps.swift @@ -0,0 +1,65 @@ +// +// Zaps.swift +// damus +// +// Created by William Casarin on 2023-01-16. +// + +import Foundation + +class Zaps { + var zaps: [String: Zap] + let our_pubkey: String + var our_zaps: [String: [Zap]] + + var event_counts: [String: Int] + var event_totals: [String: Int64] + + init(our_pubkey: String) { + self.zaps = [:] + self.our_pubkey = our_pubkey + self.our_zaps = [:] + self.event_counts = [:] + self.event_totals = [:] + } + + func add_zap(zap: Zap) { + if zaps[zap.event.id] != nil { + return + } + self.zaps[zap.event.id] = zap + + // record our zaps for an event + if zap.request.ev.pubkey == our_pubkey { + switch zap.target { + case .note(let note_target): + if our_zaps[note_target.note_id] == nil { + our_zaps[note_target.note_id] = [zap] + } else { + let _ = insert_uniq_sorted_zap(zaps: &(our_zaps[note_target.note_id]!), new_zap: zap) + } + case .profile(_): + break + } + } + + // don't count tips to self. lame. + guard zap.request.ev.pubkey != zap.target.pubkey else { + return + } + + let id = zap.target.id + if event_counts[id] == nil { + event_counts[id] = 0 + } + + if event_totals[id] == nil { + event_totals[id] = 0 + } + + event_counts[id] = event_counts[id]! + 1 + event_totals[id] = event_totals[id]! + zap.invoice.amount + + return + } +} diff --git a/damus/Views/ActionBar/EventActionBar.swift b/damus/Views/ActionBar/EventActionBar.swift @@ -22,6 +22,7 @@ struct EventActionBar: View { let damus_state: DamusState let event: NostrEvent let generator = UIImpactFeedbackGenerator(style: .medium) + @State var sheet: ActionBarSheet? = nil @State var confirm_boost: Bool = false @State var show_share_sheet: Bool = false @@ -64,6 +65,12 @@ struct EventActionBar: View { .foregroundColor(bar.liked ? Color.accentColor : Color.gray) } + + if let lnurl = damus_state.profiles.lookup(id: event.pubkey)?.lnurl { + Spacer() + ZapButton(damus_state: damus_state, event: event, lnurl: lnurl, bar: bar) + } + Spacer() EventActionButton(img: "square.and.arrow.up", col: Color.gray) { show_share_sheet = true @@ -155,10 +162,11 @@ struct EventActionBar_Previews: PreviewProvider { let ds = test_damus_state() let ev = NostrEvent(content: "hi", pubkey: pk) - let bar = ActionBarModel(likes: 0, boosts: 0, tips: 0, our_like: nil, our_boost: nil, our_tip: nil) - let likedbar = ActionBarModel(likes: 10, boosts: 10, tips: 0, our_like: nil, our_boost: nil, our_tip: nil) - let likedbar_ours = ActionBarModel(likes: 100, boosts: 100, tips: 0, our_like: NostrEvent(id: "", content: "", pubkey: ""), our_boost: nil, our_tip: nil) - let maxed_bar = ActionBarModel(likes: 999, boosts: 999, tips: 0, our_like: NostrEvent(id: "", content: "", pubkey: ""), our_boost: NostrEvent(id: "", content: "", pubkey: ""), our_tip: nil) + let bar = ActionBarModel.empty() + let likedbar = ActionBarModel(likes: 10, boosts: 0, zaps: 0, zap_total: 0, our_like: nil, our_boost: nil, our_zap: nil) + let likedbar_ours = ActionBarModel(likes: 10, boosts: 0, zaps: 0, zap_total: 0, our_like: NostrEvent(id: "", content: "", pubkey: ""), our_boost: nil, our_zap: nil) + let maxed_bar = ActionBarModel(likes: 999, boosts: 999, zaps: 999, zap_total: 99999999, our_like: NostrEvent(id: "", content: "", pubkey: ""), our_boost: NostrEvent(id: "", content: "", pubkey: ""), our_zap: nil) + let zapbar = ActionBarModel(likes: 0, boosts: 0, zaps: 5, zap_total: 10000000, our_like: nil, our_boost: nil, our_zap: nil) VStack(spacing: 50) { EventActionBar(damus_state: ds, event: ev, bar: bar) @@ -168,6 +176,8 @@ struct EventActionBar_Previews: PreviewProvider { EventActionBar(damus_state: ds, event: ev, bar: likedbar_ours) EventActionBar(damus_state: ds, event: ev, bar: maxed_bar) + + EventActionBar(damus_state: ds, event: ev, bar: zapbar) } .padding(20) } diff --git a/damus/Views/ActionBar/EventDetailBar.swift b/damus/Views/ActionBar/EventDetailBar.swift @@ -28,8 +28,8 @@ struct EventDetailBar: View { .buttonStyle(PlainButtonStyle()) } - if bar.tips > 0 { - Text("\(Text("\(bar.tips)", comment: "Number of tip payments on a post.").font(.body.bold())) \(Text(String(format: NSLocalizedString("tips_count", comment: "Part of a larger sentence to describe how many tip payments there are on a post."), bar.boosts)).foregroundColor(.gray))", comment: "Sentence composed of 2 variables to describe how many tip payments there are on a post. In source English, the first variable is the number of tip payments, and the second variable is 'Tip' or 'Tips'.") + if bar.zaps > 0 { + Text("\(Text("\(bar.zaps)", comment: "Number of zap payments on a post.").font(.body.bold())) \(Text(String(format: NSLocalizedString("zaps_count", comment: "Part of a larger sentence to describe how many zap payments there are on a post."), bar.boosts)).foregroundColor(.gray))", comment: "Sentence composed of 2 variables to describe how many zap payments there are on a post. In source English, the first variable is the number of zap payments, and the second variable is 'Zap' or 'Zaps'.") } } } diff --git a/damus/Views/ChatView.swift b/damus/Views/ChatView.swift @@ -96,7 +96,7 @@ struct ChatView: View { if let ref_id = thread.replies.lookup(event.id) { if !is_reply_to_prev() { - ReplyQuoteView(privkey: damus_state.keypair.privkey, quoter: event, event_id: ref_id, profiles: damus_state.profiles, previews: damus_state.previews) + ReplyQuoteView(keypair: damus_state.keypair, quoter: event, event_id: ref_id, profiles: damus_state.profiles, previews: damus_state.previews) .frame(maxHeight: expand_reply ? nil : 100) .environmentObject(thread) .onTapGesture { @@ -106,7 +106,7 @@ struct ChatView: View { } } - NoteContentView(privkey: damus_state.keypair.privkey, event: event, profiles: damus_state.profiles, previews: damus_state.previews, show_images: should_show_images(contacts: damus_state.contacts, ev: event, our_pubkey: damus_state.pubkey), artifacts: .just_content(event.content), size: .normal) + NoteContentView(keypair: damus_state.keypair, event: event, profiles: damus_state.profiles, previews: damus_state.previews, show_images: should_show_images(contacts: damus_state.contacts, ev: event, our_pubkey: damus_state.pubkey), artifacts: .just_content(event.content), size: .normal) if is_active || next_ev == nil || next_ev!.pubkey != event.pubkey { let bar = make_actionbar_model(ev: event, damus: damus_state) diff --git a/damus/Views/DMView.swift b/damus/Views/DMView.swift @@ -23,7 +23,7 @@ struct DMView: View { let should_show_img = should_show_images(contacts: damus_state.contacts, ev: event, our_pubkey: damus_state.pubkey) - NoteContentView(privkey: damus_state.keypair.privkey, event: event, profiles: damus_state.profiles, previews: damus_state.previews, show_images: should_show_img, artifacts: .just_content(event.get_content(damus_state.keypair.privkey)), size: .normal) + NoteContentView(keypair: damus_state.keypair, event: event, profiles: damus_state.profiles, previews: damus_state.previews, show_images: should_show_img, artifacts: .just_content(event.get_content(damus_state.keypair.privkey)), size: .normal) .foregroundColor(is_ours ? Color.white : Color.primary) .padding(10) .background(is_ours ? Color.accentColor : Color.secondary.opacity(0.15)) diff --git a/damus/Views/EventView.swift b/damus/Views/EventView.swift @@ -72,10 +72,26 @@ struct EventView: View { TextEvent(inner_ev, pubkey: inner_ev.pubkey, booster_pubkey: event.pubkey) .padding([.top], 1) } + } else if event.known_kind == .zap { + if let zap = damus.zaps.zaps[event.id] { + VStack(alignment: .leading) { + Text("⚡️ \(format_msats(zap.invoice.amount))") + .font(.headline) + .padding([.top], 2) + + TextEvent(zap.request.ev, pubkey: zap.request.ev.pubkey, booster_pubkey: nil) + .padding([.top], 1) + } + } else { + EmptyView() + } } else { TextEvent(event, pubkey: pubkey) .padding([.top], 6) } + + Divider() + .padding([.top], 4) } } @@ -197,17 +213,19 @@ func format_date(_ created_at: Int64) -> String { func make_actionbar_model(ev: NostrEvent, damus: DamusState) -> ActionBarModel { let likes = damus.likes.counts[ev.id] let boosts = damus.boosts.counts[ev.id] - let tips = damus.tips.tips[ev.id] + let zaps = damus.zaps.event_counts[ev.id] + let zap_total = damus.zaps.event_totals[ev.id] let our_like = damus.likes.our_events[ev.id] let our_boost = damus.boosts.our_events[ev.id] - let our_tip = damus.tips.our_tips[ev.id] + let our_zap = damus.zaps.our_zaps[ev.id] return ActionBarModel(likes: likes ?? 0, boosts: boosts ?? 0, - tips: tips ?? 0, + zaps: zaps ?? 0, + zap_total: zap_total ?? 0, our_like: our_like, our_boost: our_boost, - our_tip: our_tip + our_zap: our_zap?.first ) } diff --git a/damus/Views/NoteContentView.swift b/damus/Views/NoteContentView.swift @@ -66,7 +66,7 @@ func is_image_url(_ url: URL) -> Bool { } struct NoteContentView: View { - let privkey: String? + let keypair: Keypair let event: NostrEvent let profiles: Profiles let previews: PreviewCache @@ -138,7 +138,7 @@ struct NoteContentView: View { .cornerRadius(10) } if artifacts.invoices.count > 0 { - InvoicesView(invoices: artifacts.invoices) + InvoicesView(our_pubkey: keypair.pubkey, invoices: artifacts.invoices) } if let preview = self.preview, show_images { @@ -157,16 +157,16 @@ struct NoteContentView: View { var body: some View { MainContent() .onAppear() { - self.artifacts = render_note_content(ev: event, profiles: profiles, privkey: privkey) + self.artifacts = render_note_content(ev: event, profiles: profiles, privkey: keypair.privkey) } .onReceive(handle_notify(.profile_updated)) { notif in let profile = notif.object as! ProfileUpdate - let blocks = event.blocks(privkey) + let blocks = event.blocks(keypair.privkey) for block in blocks { switch block { case .mention(let m): if m.type == .pubkey && m.ref.ref_id == profile.pubkey { - self.artifacts = render_note_content(ev: event, profiles: profiles, privkey: privkey) + self.artifacts = render_note_content(ev: event, profiles: profiles, privkey: keypair.privkey) } case .text: return case .hashtag: return diff --git a/damus/Views/ProfileView.swift b/damus/Views/ProfileView.swift @@ -161,7 +161,7 @@ struct ProfileView: View { } .cornerRadius(24) .sheet(isPresented: $showing_select_wallet, onDismiss: {showing_select_wallet = false}) { - SelectWalletView(showingSelectWallet: $showing_select_wallet, invoice: lnurl) + SelectWalletView(showingSelectWallet: $showing_select_wallet, our_pubkey: damus_state.pubkey, invoice: lnurl) .environmentObject(user_settings) } } @@ -409,7 +409,7 @@ struct ProfileView_Previews: PreviewProvider { func test_damus_state() -> DamusState { let pubkey = "3efdaebb1d8923ebd99c9e7ace3b4194ab45512e2be79c1b7d68d9243e0d2681" - let damus = DamusState(pool: RelayPool(), keypair: Keypair(pubkey: pubkey, privkey: "privkey"), likes: EventCounter(our_pubkey: pubkey), boosts: EventCounter(our_pubkey: pubkey), contacts: Contacts(our_pubkey: pubkey), tips: TipCounter(our_pubkey: pubkey), profiles: Profiles(), dms: DirectMessagesModel(our_pubkey: pubkey), previews: PreviewCache()) + let damus = DamusState(pool: RelayPool(), keypair: Keypair(pubkey: pubkey, privkey: "privkey"), likes: EventCounter(our_pubkey: pubkey), boosts: EventCounter(our_pubkey: pubkey), contacts: Contacts(our_pubkey: pubkey), tips: TipCounter(our_pubkey: pubkey), profiles: Profiles(), dms: DirectMessagesModel(our_pubkey: pubkey), previews: PreviewCache(), zaps: Zaps(our_pubkey: pubkey), lnurls: LNUrls()) let prof = Profile(name: "damus", display_name: "damus", about: "iOS app!", picture: "https://damus.io/img/logo.png", banner: "", website: "https://damus.io", lud06: nil, lud16: "jb55@sendsats.lol", nip05: "damus.io") let tsprof = TimestampedProfile(profile: prof, timestamp: 0) diff --git a/damus/Views/ReplyQuoteView.swift b/damus/Views/ReplyQuoteView.swift @@ -8,7 +8,7 @@ import SwiftUI struct ReplyQuoteView: View { - let privkey: String? + let keypair: Keypair let quoter: NostrEvent let event_id: String let profiles: Profiles @@ -32,7 +32,7 @@ struct ReplyQuoteView: View { .foregroundColor(.gray) } - NoteContentView(privkey: privkey, event: event, profiles: profiles, previews: previews, show_images: false, artifacts: .just_content(event.content), size: .normal) + NoteContentView(keypair: keypair, event: event, profiles: profiles, previews: previews, show_images: false, artifacts: .just_content(event.content), size: .normal) .font(.callout) .foregroundColor(.accentColor) @@ -59,7 +59,7 @@ struct ReplyQuoteView_Previews: PreviewProvider { static var previews: some View { let s = test_damus_state() let quoter = NostrEvent(content: "a\nb\nc", pubkey: "pubkey") - ReplyQuoteView(privkey: s.keypair.privkey, quoter: quoter, event_id: "pubkey2", profiles: s.profiles, previews: PreviewCache()) + ReplyQuoteView(keypair: s.keypair, quoter: quoter, event_id: "pubkey2", profiles: s.profiles, previews: PreviewCache()) .environmentObject(ThreadModel(event: quoter, damus_state: s)) } } diff --git a/damus/Views/SelectWalletView.swift b/damus/Views/SelectWalletView.swift @@ -9,10 +9,10 @@ import SwiftUI struct SelectWalletView: View { @Binding var showingSelectWallet: Bool + let our_pubkey: String let invoice: String @Environment(\.openURL) private var openURL @State var invoice_copied: Bool = false - @EnvironmentObject var user_settings: UserSettingsStore @State var allWalletModels: [Wallet.Model] = Wallet.allModels let generator = UIImpactFeedbackGenerator(style: .light) @@ -38,7 +38,7 @@ struct SelectWalletView: View { Section(NSLocalizedString("Select a Lightning wallet", comment: "Title of section for selecting a Lightning wallet to pay a Lightning invoice.")) { List{ Button() { - let wallet_model = user_settings.default_wallet.model + let wallet_model = get_default_wallet(our_pubkey).model open_with_wallet(wallet: wallet_model, invoice: invoice) } label: { HStack { @@ -73,6 +73,6 @@ struct SelectWalletView_Previews: PreviewProvider { @State static var invoice: String = "" static var previews: some View { - SelectWalletView(showingSelectWallet: $show, invoice: "") + SelectWalletView(showingSelectWallet: $show, our_pubkey: "", invoice: "") } } diff --git a/damusTests/FormatTests.swift b/damusTests/FormatTests.swift @@ -0,0 +1,37 @@ +// +// FormatTests.swift +// damusTests +// +// Created by William Casarin on 2023-01-17. +// + +import XCTest +@testable import damus + +final class FormatTests: XCTestCase { + + override func setUpWithError() throws { + // Put setup code here. This method is called before the invocation of each test method in the class. + } + + override func tearDownWithError() throws { + // Put teardown code here. This method is called after the invocation of each test method in the class. + } + + func testAbbrevSatsFormat() throws { + XCTAssertEqual(format_msats_abbrev(1_000_000 * 1000), "1m") + XCTAssertEqual(format_msats_abbrev(1_100_000 * 1000), "1.1m") + XCTAssertEqual(format_msats_abbrev(100_000_000 * 1000), "100m") + XCTAssertEqual(format_msats_abbrev(1000 * 1000), "1k") + XCTAssertEqual(format_msats_abbrev(1500 * 1000), "1.5k") + XCTAssertEqual(format_msats_abbrev(1595 * 1000), "1.5k") + XCTAssertEqual(format_msats_abbrev(100 * 1000), "100") + XCTAssertEqual(format_msats_abbrev(0), "0") + XCTAssertEqual(format_msats_abbrev(100_000_000 * 1000), "100m") + XCTAssertEqual(format_msats_abbrev(999 * 1000), "999") + XCTAssertEqual(format_msats_abbrev(999), "0.999") + XCTAssertEqual(format_msats_abbrev(1), "0.001") + XCTAssertEqual(format_msats_abbrev(1000), "1") + } + +} diff --git a/damusTests/ZapTests.swift b/damusTests/ZapTests.swift @@ -0,0 +1,45 @@ +// +// ZapTests.swift +// damusTests +// +// Created by William Casarin on 2023-01-16. +// + +import XCTest +@testable import damus + +final class ZapTests: XCTestCase { + + override func setUpWithError() throws { + // Put setup code here. This method is called before the invocation of each test method in the class. + } + + override func tearDownWithError() throws { + // Put teardown code here. This method is called after the invocation of each test method in the class. + } + + func testZap() throws { + let zapjson = "eyJpZCI6IjUzNmJlZTllODNjODE4ZTNiODJjMTAxOTM1MTI4YWUyN2EwZDQyOTAwMzlhYWYyNTNlZmU1ZjA5MjMyYzE5NjIiLCJwdWJrZXkiOiI5NjMwZjQ2NGNjYTZhNTE0N2FhOGEzNWYwYmNkZDNjZTQ4NTMyNGU3MzJmZDM5ZTA5MjMzYjFkODQ4MjM4ZjMxIiwiY3JlYXRlZF9hdCI6MTY3NDIwNDUzNSwia2luZCI6OTczNSwidGFncyI6W1sicCIsIjMyZTE4Mjc2MzU0NTBlYmIzYzVhN2QxMmMxZjhlN2IyYjUxNDQzOWFjMTBhNjdlZWYzZDlmZDljNWM2OGUyNDUiXSxbImJvbHQxMSIsImxuYmMxMHUxcDN1NTR0bnNwNTcyOXF2eG5renRqamtkNTg1eW4wbDg2MzBzMm01eDZsNTZ3eXk0ZWMybnU4eHV6NjI5eHFwcDV2MnE3aHVjNGpwamgwM2Z4OHVqZXQ1Nms3OWd4cXg3bWUycGV2ejZqMms4dDhtNGxnNXZxaHA1eWc1MDU3OGNtdWoyNG1mdDNxcnNybWd3ZjMwa2U3YXY3ZDc3Z2FtZmxkazlrNHNmMzltcXhxeWp3NXFjcXBqcnpqcTJoeWVoNXEzNmx3eDZ6dHd5cmw2dm1tcnZ6NnJ1ZndqZnI4N3lremZuYXR1a200dWRzNHl6YWszc3FxOW1jcXFxcXFxcWxncXFxcTg2cXF5ZzlxeHBxeXNncWFkeWVjdmR6ZjI3MHBkMzZyc2FmbDA3azQ1ZmNqMnN5OGU1djJ0ZW5kNTB2OTU3NnV4cDNkdmp6amV1aHJlODl5cGdjbTkwZDZsbTAwNGszMHlqNGF2NW1jc3M1bnl4NHU5bmVyOWdwcHY2eXF3Il0sWyJkZXNjcmlwdGlvbiIsIntcImlkXCI6XCJiMDkyMTYzNGIxYmI4ZWUzNTg0YmJiZjJlOGQ3OTBhZDk4NTk5ZDhlMDhmODFjNzAwZGRiZTQ4MjAxNTY4Yjk3XCIsXCJwdWJrZXlcIjpcIjdmYTU2ZjVkNjk2MmFiMWUzY2Q0MjRlNzU4YzMwMDJiODY2NWY3YjBkOGRjZWU5ZmU5ZTI4OGQ3NzUxYWMxOTRcIixcImNyZWF0ZWRfYXRcIjoxNjc0MjA0NTMxLFwia2luZFwiOjk3MzQsXCJ0YWdzXCI6W1tcInBcIixcIjMyZTE4Mjc2MzU0NTBlYmIzYzVhN2QxMmMxZjhlN2IyYjUxNDQzOWFjMTBhNjdlZWYzZDlmZDljNWM2OGUyNDVcIl0sW1wicmVsYXlzXCIsXCJ3c3M6Ly9yZWxheS5zbm9ydC5zb2NpYWxcIixcIndzczovL3JlbGF5LmRhbXVzLmlvXCIsXCJ3c3M6Ly9ub3N0ci1wdWIud2VsbG9yZGVyLm5ldFwiLFwid3NzOi8vbm9zdHIudjBsLmlvXCIsXCJ3c3M6Ly9wcml2YXRlLW5vc3RyLnYwbC5pb1wiLFwid3NzOi8vbm9zdHIuemViZWRlZS5jbG91ZFwiLFwid3NzOi8vcmVsYXkubm9zdHIuaW5mby9cIl1dLFwiY29udGVudFwiOlwiXCIsXCJzaWdcIjpcImQwODQwNGU2MjVmOWM1NjMzYWZhZGQxMWMxMTBiYTg4ZmNkYjRiOWUwOTJiOTg0MGU3NDgyYThkNTM3YjFmYzExODY5MmNmZDEzMWRkODMzNTM2NDc2OWE2NzE3NTRhZDdhYTk3MzEzNjgzYTRhZDdlZmI3NjQ3NmMwNGU1ZjE3XCJ9Il0sWyJwcmVpbWFnZSIsIjNlMDJhM2FmOGM4YmNmMmEzNzUzYzg3ZjMxMTJjNjU2YTIwMTE0ZWUwZTk4ZDgyMTliYzU2ZjVlOGE3MjM1YjMiXV0sImNvbnRlbnQiOiIiLCJzaWciOiIzYWI0NGQwZTIyMjhiYmQ0ZDIzNDFjM2ZhNzQwOTZjZmY2ZjU1Y2ZkYTk5YTVkYWRjY2Y0NWM2NjQ2MzdlMjExNTFiMmY5ZGQwMDQwZjFhMjRlOWY4Njg2NzM4YjE2YmY4MTM0YmRiZTQxYTIxOGM5MTFmN2JiMzFlNTk1NzhkMSJ9Cg==" + + guard let json_data = Data(base64Encoded: zapjson) else { + XCTAssert(false) + return + } + + let json_str = String(decoding: json_data, as: UTF8.self) + + guard let ev = decode_nostr_event_json(json: json_str) else { + XCTAssert(false) + return + } + + guard let zap = Zap.from_zap_event(zap_ev: ev, zapper: "9630f464cca6a5147aa8a35f0bcdd3ce485324e732fd39e09233b1d848238f31") else { + XCTAssert(false) + return + } + + XCTAssertEqual(zap.zapper, "9630f464cca6a5147aa8a35f0bcdd3ce485324e732fd39e09233b1d848238f31") + XCTAssertEqual(zap.target, ZapTarget.profile("32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245")) + } + +}