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:
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: ¬ifications, 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"))
+ }
+
+}