damus

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

commit 097cc54bba2fc852ea98894864d8fa726cf63c50
parent b230d430ee7776e54285bf8dfe5dab0e8810718b
Author: William Casarin <jb55@jb55.com>
Date:   Tue, 24 May 2022 12:57:40 -0700

extract HomeModel from ContentView

huge refactor

Signed-off-by: William Casarin <jb55@jb55.com>

Diffstat:
Mdamus.xcodeproj/project.pbxproj | 24++++++++++++++++++++++++
Mdamus/ContentView.swift | 324+++++++++++++++----------------------------------------------------------------
Mdamus/Models/Contacts.swift | 46++++++++++++++++++++++++++++++++++++++++++----
Mdamus/Models/CreateAccountModel.swift | 12++++++++++--
Mdamus/Models/DamusState.swift | 10+++++++++-
Adamus/Models/FollowTarget.swift | 25+++++++++++++++++++++++++
Adamus/Models/HomeModel.swift | 322+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mdamus/Models/ProfileModel.swift | 9++++++++-
Adamus/Models/SignalModel.swift | 32++++++++++++++++++++++++++++++++
Mdamus/Nostr/NostrEvent.swift | 23+++++++++++++++++------
Mdamus/Nostr/ProofOfWork.swift | 3+++
Adamus/Util/Bech32.swift | 207+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mdamus/Util/InsertSort.swift | 10++++++++++
Mdamus/Util/Keys.swift | 76+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----
Mdamus/Views/ChatView.swift | 2+-
Mdamus/Views/CreateAccountView.swift | 76++++++++++++++++++++++++++++++++++++++++++++++------------------------------
Mdamus/Views/EventActionBar.swift | 12+++++++-----
Mdamus/Views/EventView.swift | 2+-
Mdamus/Views/FollowButtonView.swift | 47++++++++++++++++++++++++++++++++++++++++-------
Mdamus/Views/FollowingView.swift | 14+++++++-------
Adamus/Views/LoginView.swift | 195+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mdamus/Views/MainTabView.swift | 2--
Mdamus/Views/ProfileView.swift | 17+----------------
Mdamus/Views/SaveKeysView.swift | 40++++++++++++++++++++++++----------------
Mdamus/Views/SetupView.swift | 5++++-
Mdamus/damusApp.swift | 2+-
AdamusTests/Bech32Tests.swift | 51+++++++++++++++++++++++++++++++++++++++++++++++++++
27 files changed, 1219 insertions(+), 369 deletions(-)

diff --git a/damus.xcodeproj/project.pbxproj b/damus.xcodeproj/project.pbxproj @@ -50,6 +50,9 @@ 4C3BEFDC281DCE6100B3DE84 /* Liked.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C3BEFDB281DCE6100B3DE84 /* Liked.swift */; }; 4C3BEFE0281DE1ED00B3DE84 /* DamusState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C3BEFDF281DE1ED00B3DE84 /* DamusState.swift */; }; 4C477C9E282C3A4800033AA3 /* TipCounter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C477C9D282C3A4800033AA3 /* TipCounter.swift */; }; + 4C5F9114283D694D0052CD1C /* FollowTarget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C5F9113283D694D0052CD1C /* FollowTarget.swift */; }; + 4C633350283D40E500B1C9C3 /* HomeModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C63334F283D40E500B1C9C3 /* HomeModel.swift */; }; + 4C633352283D419F00B1C9C3 /* SignalModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C633351283D419F00B1C9C3 /* SignalModel.swift */; }; 4C75EFA427FA577B0006080F /* PostView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C75EFA327FA577B0006080F /* PostView.swift */; }; 4C75EFA627FF87A20006080F /* Nostr.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C75EFA527FF87A20006080F /* Nostr.swift */; }; 4C75EFAD28049CFB0006080F /* PostButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C75EFAC28049CFB0006080F /* PostButton.swift */; }; @@ -63,6 +66,9 @@ 4C7FF7D52823313F009601DB /* Mentions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C7FF7D42823313F009601DB /* Mentions.swift */; }; 4C8682872814DE470026224F /* ProfileView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C8682862814DE470026224F /* ProfileView.swift */; }; 4C90BD162839DB54008EE7EF /* NostrMetadata.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C90BD152839DB54008EE7EF /* NostrMetadata.swift */; }; + 4C90BD18283A9EE5008EE7EF /* LoginView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C90BD17283A9EE5008EE7EF /* LoginView.swift */; }; + 4C90BD1A283AA67F008EE7EF /* Bech32.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C90BD19283AA67F008EE7EF /* Bech32.swift */; }; + 4C90BD1C283AC38E008EE7EF /* Bech32Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C90BD1B283AC38E008EE7EF /* Bech32Tests.swift */; }; 4CA2EFA0280E37AC0044ACD8 /* TimelineView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CA2EF9F280E37AC0044ACD8 /* TimelineView.swift */; }; 4CACA9D5280C31E100D9BBE8 /* ReplyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CACA9D4280C31E100D9BBE8 /* ReplyView.swift */; }; 4CACA9DC280C38C000D9BBE8 /* Profiles.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CACA9DB280C38C000D9BBE8 /* Profiles.swift */; }; @@ -147,6 +153,9 @@ 4C3BEFDB281DCE6100B3DE84 /* Liked.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Liked.swift; sourceTree = "<group>"; }; 4C3BEFDF281DE1ED00B3DE84 /* DamusState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DamusState.swift; sourceTree = "<group>"; }; 4C477C9D282C3A4800033AA3 /* TipCounter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TipCounter.swift; sourceTree = "<group>"; }; + 4C5F9113283D694D0052CD1C /* FollowTarget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FollowTarget.swift; sourceTree = "<group>"; }; + 4C63334F283D40E500B1C9C3 /* HomeModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeModel.swift; sourceTree = "<group>"; }; + 4C633351283D419F00B1C9C3 /* SignalModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SignalModel.swift; sourceTree = "<group>"; }; 4C75EFA327FA577B0006080F /* PostView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostView.swift; sourceTree = "<group>"; }; 4C75EFA527FF87A20006080F /* Nostr.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Nostr.swift; sourceTree = "<group>"; }; 4C75EFA72804823E0006080F /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; }; @@ -161,6 +170,9 @@ 4C7FF7D42823313F009601DB /* Mentions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Mentions.swift; sourceTree = "<group>"; }; 4C8682862814DE470026224F /* ProfileView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileView.swift; sourceTree = "<group>"; }; 4C90BD152839DB54008EE7EF /* NostrMetadata.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NostrMetadata.swift; sourceTree = "<group>"; }; + 4C90BD17283A9EE5008EE7EF /* LoginView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginView.swift; sourceTree = "<group>"; }; + 4C90BD19283AA67F008EE7EF /* Bech32.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Bech32.swift; sourceTree = "<group>"; }; + 4C90BD1B283AC38E008EE7EF /* Bech32Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Bech32Tests.swift; sourceTree = "<group>"; }; 4CA2EF9F280E37AC0044ACD8 /* TimelineView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineView.swift; sourceTree = "<group>"; }; 4CACA9D4280C31E100D9BBE8 /* ReplyView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReplyView.swift; sourceTree = "<group>"; }; 4CACA9DB280C38C000D9BBE8 /* Profiles.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Profiles.swift; sourceTree = "<group>"; }; @@ -232,6 +244,9 @@ 4C363AA328296DEE006E126D /* SearchModel.swift */, 4C3AC79A28306D7B00E1F516 /* Contacts.swift */, 4C285C85283892E7008A31F1 /* CreateAccountModel.swift */, + 4C63334F283D40E500B1C9C3 /* HomeModel.swift */, + 4C633351283D419F00B1C9C3 /* SignalModel.swift */, + 4C5F9113283D694D0052CD1C /* FollowTarget.swift */, ); path = Models; sourceTree = "<group>"; @@ -268,6 +283,7 @@ 4C285C8328385690008A31F1 /* CreateAccountView.swift */, 4C285C892838B985008A31F1 /* ProfilePictureSelector.swift */, 4C285C8D28399BFD008A31F1 /* SaveKeysView.swift */, + 4C90BD17283A9EE5008EE7EF /* LoginView.swift */, ); path = Views; sourceTree = "<group>"; @@ -302,6 +318,7 @@ 4C363AA728297703006E126D /* InsertSort.swift */, 4C477C9D282C3A4800033AA3 /* TipCounter.swift */, 4C285C8B28398BC6008A31F1 /* Keys.swift */, + 4C90BD19283AA67F008EE7EF /* Bech32.swift */, ); path = Util; sourceTree = "<group>"; @@ -355,6 +372,7 @@ 4CE6DEF627F7A08200C66700 /* damusTests */ = { isa = PBXGroup; children = ( + 4C90BD1B283AC38E008EE7EF /* Bech32Tests.swift */, 4C363A9F2828A8DD006E126D /* LikeTests.swift */, 4C363A9D2828A822006E126D /* ReplyTests.swift */, 4CE6DEF727F7A08200C66700 /* damusTests.swift */, @@ -533,6 +551,7 @@ 4C285C8A2838B985008A31F1 /* ProfilePictureSelector.swift in Sources */, 4C75EFB92804A2740006080F /* EventView.swift in Sources */, 4C7FF7D52823313F009601DB /* Mentions.swift in Sources */, + 4C633350283D40E500B1C9C3 /* HomeModel.swift in Sources */, 4C363A9828283441006E126D /* TestingPrivate.swift in Sources */, 4C363A9028247A1D006E126D /* NostrLink.swift in Sources */, 4C0A3F8C280F5FCA000448DE /* ChatroomView.swift in Sources */, @@ -544,6 +563,7 @@ 4CA2EFA0280E37AC0044ACD8 /* TimelineView.swift in Sources */, 4C363A8428233689006E126D /* Parser.swift in Sources */, 4C363A9A28283854006E126D /* Reply.swift in Sources */, + 4C90BD18283A9EE5008EE7EF /* LoginView.swift in Sources */, 4C3BEFDC281DCE6100B3DE84 /* Liked.swift in Sources */, 4C75EFB128049D510006080F /* NostrResponse.swift in Sources */, 4CEE2AF7280B2DEA00AB5EEF /* ProfileName.swift in Sources */, @@ -558,11 +578,13 @@ 4C3AC7A12835A81400E1F516 /* SetupView.swift in Sources */, 4C285C8C28398BC7008A31F1 /* Keys.swift in Sources */, 4CACA9DC280C38C000D9BBE8 /* Profiles.swift in Sources */, + 4C633352283D419F00B1C9C3 /* SignalModel.swift in Sources */, 4C363A94282704FA006E126D /* Post.swift in Sources */, 4C363A8828236948006E126D /* BlocksView.swift in Sources */, 4C75EFAF28049D350006080F /* NostrFilter.swift in Sources */, 4C363A9C282838B9006E126D /* EventRef.swift in Sources */, 4C8682872814DE470026224F /* ProfileView.swift in Sources */, + 4C5F9114283D694D0052CD1C /* FollowTarget.swift in Sources */, 4CE6DF1627F8DEBF00C66700 /* RelayConnection.swift in Sources */, 4C3BEFD6281D995700B3DE84 /* ActionBarModel.swift in Sources */, 4C363AA428296DEE006E126D /* SearchModel.swift in Sources */, @@ -578,6 +600,7 @@ 4C3BEFDA281DCA1400B3DE84 /* LikeCounter.swift in Sources */, 4C3AC79B28306D7B00E1F516 /* Contacts.swift in Sources */, 4C363A8E28236FE4006E126D /* NoteContentView.swift in Sources */, + 4C90BD1A283AA67F008EE7EF /* Bech32.swift in Sources */, 4CACA9D5280C31E100D9BBE8 /* ReplyView.swift in Sources */, 4C0A3F97280F8E02000448DE /* ThreadView.swift in Sources */, 4C75EFA427FA577B0006080F /* PostView.swift in Sources */, @@ -594,6 +617,7 @@ files = ( 4C363A9E2828A822006E126D /* ReplyTests.swift in Sources */, 4C363AA02828A8DD006E126D /* LikeTests.swift in Sources */, + 4C90BD1C283AC38E008EE7EF /* Bech32Tests.swift in Sources */, 4CE6DEF827F7A08200C66700 /* damusTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/damus/ContentView.swift b/damus/ContentView.swift @@ -31,28 +31,30 @@ enum ThreadState { } struct ContentView: View { - let pubkey: String - let privkey: String + let keypair: Keypair + + var pubkey: String { + return keypair.pubkey + } + + var privkey: String? { + return keypair.privkey + } + @State var status: String = "Not connected" @State var active_sheet: Sheets? = nil - @State var loading: Bool = true @State var damus_state: DamusState? = nil @State var selected_timeline: Timeline? = .home @State var is_thread_open: Bool = false @State var is_profile_open: Bool = false - @State var last_event_of_kind: [String: [Int: NostrEvent]] = [:] - @State var has_events: [String: ()] = [:] - @State var new_notifications: Bool = false @State var event: NostrEvent? = nil - @State var events: [NostrEvent] = [] - @State var friend_events: [NostrEvent] = [] - @State var notifications: [NostrEvent] = [] @State var active_profile: String? = nil @State var active_search: NostrFilter? = nil @State var active_event_id: String? = nil @State var profile_open: Bool = false @State var thread_open: Bool = false @State var search_open: Bool = false + @StateObject var home: HomeModel = HomeModel() // connect retry timer let timer = Timer.publish(every: 60, on: .main, in: .common).autoconnect() @@ -64,9 +66,10 @@ struct ContentView: View { HStack { Spacer() - if self.loading { - ProgressView() - .progressViewStyle(.circular) + if home.signal.signal != home.signal.max_signal { + Text("\(home.signal.signal)/\(home.signal.max_signal)") + .font(.callout) + .foregroundColor(.gray) } } @@ -77,10 +80,12 @@ struct ContentView: View { var PostingTimelineView: some View { ZStack { if let damus = self.damus_state { - TimelineView(events: $friend_events, damus: damus) + TimelineView(events: $home.events, damus: damus) } - PostButtonContainer { - self.active_sheet = .post + if privkey != nil { + PostButtonContainer { + self.active_sheet = .post + } } } } @@ -105,13 +110,9 @@ struct ContentView: View { PostingTimelineView case .notifications: - TimelineView(events: $notifications, damus: damus) + TimelineView(events: $home.notifications, damus: damus) .navigationTitle("Notifications") - case .global: - - TimelineView(events: $events, damus: damus) - .navigationTitle("Global") case .none: EmptyView() } @@ -165,7 +166,7 @@ struct ContentView: View { } } - TabBar(new_notifications: $new_notifications, selected: $selected_timeline, action: switch_timeline) + TabBar(new_notifications: $home.new_notifications, selected: $selected_timeline, action: switch_timeline) } .onAppear() { self.connect() @@ -201,6 +202,10 @@ struct ContentView: View { } .onReceive(handle_notify(.boost)) { notif in + guard let privkey = self.privkey else { + return + } + let ev = notif.object as! NostrEvent let boost = make_boost_event(pubkey: pubkey, privkey: privkey, boosted: ev) self.damus_state?.pool.send(.event(boost)) @@ -215,6 +220,9 @@ struct ContentView: View { self.active_sheet = .reply(ev) } .onReceive(handle_notify(.like)) { like in + guard let privkey = self.privkey else { + return + } let ev = like.object as! NostrEvent let like_ev = make_like_event(pubkey: pubkey, privkey: privkey, liked: ev) self.damus_state?.pool.send(.event(like_ev)) @@ -224,6 +232,10 @@ struct ContentView: View { self.damus_state?.pool.send(.event(ev)) } .onReceive(handle_notify(.unfollow)) { notif in + guard let privkey = self.privkey else { + return + } + let pk = notif.object as! String guard let damus = self.damus_state else { return @@ -235,12 +247,16 @@ struct ContentView: View { privkey: privkey, unfollow: pk) { notify(.unfollowed, pk) - damus.contacts.friends.remove(pk) + damus.contacts.remove_friend(pk) //friend_events = friend_events.filter { $0.pubkey != pk } } } .onReceive(handle_notify(.follow)) { notif in - let pk = notif.object as! String + guard let privkey = self.privkey else { + return + } + + let fnotify = notif.object as! FollowTarget guard let damus = self.damus_state else { return } @@ -249,12 +265,22 @@ struct ContentView: View { our_contacts: damus.contacts.event, pubkey: damus.pubkey, privkey: privkey, - follow: ReferencedId(ref_id: pk, relay_id: nil, key: "p")) { - notify(.followed, pk) - damus.contacts.friends.insert(pk) + follow: ReferencedId(ref_id: fnotify.pubkey, relay_id: nil, key: "p")) { + notify(.followed, fnotify.pubkey) + + switch fnotify { + case .pubkey(let pk): + damus.contacts.add_friend_pubkey(pk) + case .contact(let ev): + damus.contacts.add_friend_contact(ev) + } } } .onReceive(handle_notify(.post)) { obj in + guard let privkey = self.privkey else { + return + } + let post_res = obj.object as! NostrPostResult switch post_res { case .post(let post): @@ -268,12 +294,11 @@ struct ContentView: View { } .onReceive(timer) { n in self.damus_state?.pool.connect_to_disconnected() - self.loading = (self.damus_state?.pool.num_connecting ?? 0) != 0 } } func is_friend_event(_ ev: NostrEvent) -> Bool { - return damus.is_friend_event(ev, our_pubkey: self.pubkey, friends: self.damus_state!.contacts.friends) + return damus.is_friend_event(ev, our_pubkey: self.pubkey, contacts: self.damus_state!.contacts) } func switch_timeline(_ timeline: Timeline) { @@ -283,7 +308,7 @@ struct ContentView: View { } if (timeline != .notifications && self.selected_timeline == .notifications) || timeline == .notifications { - new_notifications = false + home.new_notifications = false } self.selected_timeline = timeline NotificationCenter.default.post(name: .switched_timeline, object: timeline) @@ -312,9 +337,9 @@ struct ContentView: View { add_relay(pool, "wss://nostr-relay.freeberty.net") add_relay(pool, "wss://nostr-relay.untethr.me") - pool.register_handler(sub_id: sub_id, handler: handle_event) + pool.register_handler(sub_id: sub_id, handler: home.handle_event) - self.damus_state = DamusState(pool: pool, pubkey: pubkey, + self.damus_state = DamusState(pool: pool, keypair: keypair, likes: EventCounter(our_pubkey: pubkey), boosts: EventCounter(our_pubkey: pubkey), contacts: Contacts(), @@ -322,239 +347,12 @@ struct ContentView: View { image_cache: ImageCache(), profiles: Profiles() ) - pool.connect() - } - - func handle_contact_event(_ ev: NostrEvent) { - if ev.pubkey == self.pubkey { - damus_state!.contacts.event = ev - // our contacts - for tag in ev.tags { - if tag.count > 1 && tag[0] == "p" { - damus_state!.contacts.friends.insert(tag[1]) - } - } - } - } - - func handle_boost_event(_ ev: NostrEvent) { - var boost_ev_id = ev.last_refid()?.ref_id - - // CHECK SIGS ON THESE - if let inner_ev = ev.inner_event { - boost_ev_id = inner_ev.id - - if inner_ev.kind == 1 { - handle_text_event(ev) - } - } - - guard let e = boost_ev_id else { - return - } - - switch damus_state!.boosts.add_event(ev, target: e) { - case .already_counted: - break - case .success(let n): - let boosted = Counted(event: ev, id: e, total: n) - notify(.boosted, boosted) - } - } - - func handle_like_event(_ ev: NostrEvent) { - guard let e = ev.last_refid() else { - // no id ref? invalid like event - return - } + home.damus_state = self.damus_state! - // CHECK SIGS ON THESE - - switch damus_state!.likes.add_event(ev, target: e.ref_id) { - case .already_counted: - break - case .success(let n): - let liked = Counted(event: ev, id: e.ref_id, total: n) - notify(.liked, liked) - } - } - - func handle_metadata_event(_ ev: NostrEvent) { - guard let profile: Profile = decode_data(Data(ev.content.utf8)) else { - return - } - - if let mprof = damus_state!.profiles.lookup_with_timestamp(id: ev.pubkey) { - if mprof.timestamp > ev.created_at { - // skip if we already have an newer profile - return - } - } - - let tprof = TimestampedProfile(profile: profile, timestamp: ev.created_at) - damus_state!.profiles.add(id: ev.pubkey, profile: tprof) - - notify(.profile_updated, ProfileUpdate(pubkey: ev.pubkey, profile: profile)) - } - - func get_last_event_of_kind(relay_id: String, kind: Int) -> NostrEvent? { - guard let m = last_event_of_kind[relay_id] else { - last_event_of_kind[relay_id] = [:] - return nil - } - - return m[kind] + pool.connect() } - - func send_filters(relay_id: String) { - // TODO: since times should be based on events from a specific relay - // perhaps we could mark this in the relay pool somehow - let text_filter = NostrFilter.filter_kinds([1,5,6,7]) - let profile_filter = NostrFilter.filter_profiles - var contacts_filter = NostrFilter.filter_contacts - - contacts_filter.authors = [self.pubkey] - var filters = [text_filter, profile_filter, contacts_filter] - - filters = update_filters_with_since(last_of_kind: last_event_of_kind[relay_id] ?? [:], filters: filters) - - print("connected to \(relay_id) with filters:") - for filter in filters { - print(filter) - } - print("-----") - - self.damus_state?.pool.send(.subscribe(.init(filters: filters, sub_id: sub_id)), to: [relay_id]) - //self.pool?.send(.subscribe(.init(filters: [notification_filter], sub_id: "notifications"))) - } - - func handle_notification(ev: NostrEvent) { - notifications.append(ev) - notifications = notifications.sorted { $0.created_at > $1.created_at } - - let last_notified = get_last_notified() - - if last_notified == nil || last_notified!.created_at < ev.created_at { - save_last_notified(ev) - new_notifications = true - } - } - - func handle_friend_event(_ ev: NostrEvent) { - if !is_friend_event(ev) { - return - } - if !insert_uniq_sorted_event(events: &self.friend_events, new_ev: ev, cmp: { $0.created_at > $1.created_at } ) { - return - } - } - - func handle_text_event(_ ev: NostrEvent) { - if should_hide_event(ev) { - return - } - - if !insert_uniq_sorted_event(events: &self.events, new_ev: ev, cmp: { $0.created_at > $1.created_at }) { - return - } - - handle_friend_event(ev) - - if is_notification(ev: ev, pubkey: pubkey) { - handle_notification(ev: ev) - } - } - func process_event(relay_id: String, ev: NostrEvent) { - if has_events[ev.id] != nil { - return - } - - has_events[ev.id] = () - let last_k = get_last_event_of_kind(relay_id: relay_id, kind: ev.kind) - if last_k == nil || ev.created_at > last_k!.created_at { - last_event_of_kind[relay_id]?[ev.kind] = ev - } - if ev.kind == 1 { - handle_text_event(ev) - } else if ev.kind == 0 { - handle_metadata_event(ev) - } else if ev.kind == 6 { - handle_boost_event(ev) - } else if ev.kind == 7 { - handle_like_event(ev) - } else if ev.kind == 3 { - handle_contact_event(ev) - - if ev.pubkey == pubkey { - process_friend_events() - } - } - } - - func process_friend_events() { - for event in events { - handle_friend_event(event) - } - } - - func handle_event(relay_id: String, conn_event: NostrConnectionEvent) { - switch conn_event { - case .ws_event(let ev): - - /* - if let wsev = ws_nostr_event(relay: relay_id, ev: ev) { - wsev.flags |= 1 - self.events.insert(wsev, at: 0) - } - */ - - - switch ev { - case .connected: - send_filters(relay_id: relay_id) - case .error(let merr): - let desc = merr.debugDescription - if desc.contains("Software caused connection abort") { - self.damus_state?.pool.reconnect(to: [relay_id]) - } - case .disconnected: fallthrough - case .cancelled: - self.damus_state?.pool.reconnect(to: [relay_id]) - case .reconnectSuggested(let t): - if t { - self.damus_state?.pool.reconnect(to: [relay_id]) - } - default: - break - } - - self.loading = (self.damus_state?.pool.num_connecting ?? 0) != 0 - - print("ws_event \(ev)") - - case .nostr_event(let ev): - switch ev { - case .event(let sub_id, let ev): - // globally handle likes - let always_process = ev.known_kind == .like || ev.known_kind == .contacts || ev.known_kind == .metadata - if !always_process && sub_id != self.sub_id { - // TODO: other views like threads might have their own sub ids, so ignore those events... or should we? - return - } - - self.process_event(relay_id: relay_id, ev: ev) - case .notice(let msg): - self.events.insert(NostrEvent(content: "NOTICE from \(relay_id): \(msg)", pubkey: "system"), at: 0) - print(msg) - } - } - } - - func should_hide_event(_ ev: NostrEvent) -> Bool { - return false - } } /* @@ -574,9 +372,9 @@ func get_metadata_since_time(_ metadata_event: NostrEvent?) -> Int64? { return metadata_event!.created_at - 60 * 10 } -func get_since_time(last_event: NostrEvent?) -> Int64 { +func get_since_time(last_event: NostrEvent?) -> Int64? { if last_event == nil { - return Int64(Date().timeIntervalSince1970) - (24 * 60 * 60 * 3) + return nil } return last_event!.created_at - 60 * 10 @@ -696,7 +494,7 @@ func update_filters_with_since(last_of_kind: [Int: NostrEvent], filters: [NostrF return since } - return since! < earliest! ? since! : earliest! + return earliest.flatMap { earliest in since.map { since in since < earliest ? since : earliest } } } if let earliest = earliest { diff --git a/damus/Models/Contacts.swift b/damus/Models/Contacts.swift @@ -9,9 +9,47 @@ import Foundation class Contacts { - var friends: Set<String> = Set() + private var friends: Set<String> = Set() + private var friend_of_friends: Set<String> = Set() var event: NostrEvent? + func get_friendosphere() -> [String] { + var fs = get_friend_list() + fs.append(contentsOf: get_friend_of_friend_list()) + return fs + } + + func remove_friend(_ pubkey: String) { + friends.remove(pubkey) + } + + func get_friend_list() -> [String] { + return Array(friends) + } + + func get_friend_of_friend_list() -> [String] { + return Array(friend_of_friends) + } + + func add_friend_pubkey(_ pubkey: String) { + friends.insert(pubkey) + } + + func add_friend_contact(_ contact: NostrEvent) { + friends.insert(contact.pubkey) + for friend in contact.referenced_pubkeys { + friend_of_friends.insert(friend.ref_id) + } + } + + func is_friend_of_friend(_ pubkey: String) -> Bool { + return friend_of_friends.contains(pubkey) + } + + func is_in_friendosphere(_ pubkey: String) -> Bool { + return friends.contains(pubkey) || friend_of_friends.contains(pubkey) + } + func is_friend(_ pubkey: String) -> Bool { return friends.contains(pubkey) } @@ -121,9 +159,9 @@ func make_contact_relays(_ relays: [RelayDescriptor]) -> [String: RelayInfo] { } // TODO: tests for this -func is_friend_event(_ ev: NostrEvent, our_pubkey: String, friends: Set<String>) -> Bool +func is_friend_event(_ ev: NostrEvent, our_pubkey: String, contacts: Contacts) -> Bool { - if !friends.contains(ev.pubkey) { + if !contacts.is_friend(ev.pubkey) { return false } @@ -133,7 +171,7 @@ func is_friend_event(_ ev: NostrEvent, our_pubkey: String, friends: Set<String>) // show our replies? for pk in ev.referenced_pubkeys { - if friends.contains(pk.ref_id) { + if contacts.is_friend(pk.ref_id) { return true } } diff --git a/damus/Models/CreateAccountModel.swift b/damus/Models/CreateAccountModel.swift @@ -15,6 +15,14 @@ class CreateAccountModel: ObservableObject { @Published var pubkey: String = "" @Published var privkey: String = "" + var pubkey_bech32: String { + return bech32_pubkey(self.pubkey) ?? "" + } + + var privkey_bech32: String { + return bech32_privkey(self.privkey) ?? "" + } + var rendered_name: String { if real_name.isEmpty { return nick_name @@ -29,13 +37,13 @@ class CreateAccountModel: ObservableObject { init() { let keypair = generate_new_keypair() self.pubkey = keypair.pubkey - self.privkey = keypair.privkey + self.privkey = keypair.privkey! } init(real: String, nick: String, about: String) { let keypair = generate_new_keypair() self.pubkey = keypair.pubkey - self.privkey = keypair.privkey + self.privkey = keypair.privkey! self.real_name = real self.nick_name = nick diff --git a/damus/Models/DamusState.swift b/damus/Models/DamusState.swift @@ -9,11 +9,19 @@ import Foundation struct DamusState { let pool: RelayPool - let pubkey: String + let keypair: Keypair let likes: EventCounter let boosts: EventCounter let contacts: Contacts let tips: TipCounter let image_cache: ImageCache let profiles: Profiles + + var pubkey: String { + return keypair.pubkey + } + + static var empty: DamusState { + return DamusState.init(pool: RelayPool(), keypair: Keypair(pubkey: "", privkey: ""), likes: EventCounter(our_pubkey: ""), boosts: EventCounter(our_pubkey: ""), contacts: Contacts(), tips: TipCounter(our_pubkey: ""), image_cache: ImageCache(), profiles: Profiles()) + } } diff --git a/damus/Models/FollowTarget.swift b/damus/Models/FollowTarget.swift @@ -0,0 +1,25 @@ +// +// FollowNotify.swift +// damus +// +// Created by William Casarin on 2022-05-24. +// + +import Foundation + + +enum FollowTarget { + case pubkey(String) + case contact(NostrEvent) + + var pubkey: String { + switch self { + case .pubkey(let pk): + return pk + case .contact(let ev): + return ev.pubkey + } + } +} + + diff --git a/damus/Models/HomeModel.swift b/damus/Models/HomeModel.swift @@ -0,0 +1,322 @@ +// +// HomeModel.swift +// damus +// +// Created by William Casarin on 2022-05-24. +// + +import Foundation + + +class HomeModel: ObservableObject { + var damus_state: DamusState + + var has_events: Set<String> = Set() + var last_event_of_kind: [String: [Int: NostrEvent]] = [:] + var done_init: Bool = false + + let damus_home_subid = UUID().description + let damus_contacts_subid = UUID().description + let damus_init_subid = UUID().description + + @Published var new_notifications: Bool = false + @Published var notifications: [NostrEvent] = [] + @Published var events: [NostrEvent] = [] + @Published var signal: SignalModel = SignalModel() + + init() { + self.damus_state = DamusState.empty + } + + init(damus_state: DamusState) { + self.damus_state = damus_state + } + + var pool: RelayPool { + return damus_state.pool + } + + func process_event(sub_id: String, relay_id: String, ev: NostrEvent) { + if has_events.contains(ev.id) { + return + } + + has_events.insert(ev.id) + let last_k = get_last_event_of_kind(relay_id: relay_id, kind: ev.kind) + if last_k == nil || ev.created_at > last_k!.created_at { + last_event_of_kind[relay_id]?[ev.kind] = ev + } + if ev.kind == 1 { + handle_text_event(ev) + } else if ev.kind == 0 { + handle_metadata_event(ev) + } else if ev.kind == 6 { + handle_boost_event(ev) + } else if ev.kind == 7 { + handle_like_event(ev) + } else if ev.kind == 3 { + handle_contact_event(sub_id: sub_id, relay_id: relay_id, ev: ev) + } + } + + func handle_contact_event(sub_id: String, relay_id: String, ev: NostrEvent) { + load_our_contacts(contacts: self.damus_state.contacts, our_pubkey: self.damus_state.pubkey, ev: ev) + + if sub_id == damus_init_subid { + pool.send(.unsubscribe(damus_init_subid), to: [relay_id]) + if !done_init { + done_init = true + send_home_filters(relay_id: nil) + } + } + } + + func handle_boost_event(_ ev: NostrEvent) { + var boost_ev_id = ev.last_refid()?.ref_id + + // CHECK SIGS ON THESE + if let inner_ev = ev.inner_event { + boost_ev_id = inner_ev.id + + if inner_ev.kind == 1 { + handle_text_event(ev) + } + } + + guard let e = boost_ev_id else { + return + } + + switch self.damus_state.boosts.add_event(ev, target: e) { + case .already_counted: + break + case .success(let n): + let boosted = Counted(event: ev, id: e, total: n) + notify(.boosted, boosted) + } + } + + func handle_like_event(_ ev: NostrEvent) { + guard let e = ev.last_refid() else { + // no id ref? invalid like event + return + } + + // CHECK SIGS ON THESE + + switch damus_state.likes.add_event(ev, target: e.ref_id) { + case .already_counted: + break + case .success(let n): + let liked = Counted(event: ev, id: e.ref_id, total: n) + notify(.liked, liked) + } + } + + + func handle_event(relay_id: String, conn_event: NostrConnectionEvent) { + switch conn_event { + case .ws_event(let ev): + + /* + if let wsev = ws_nostr_event(relay: relay_id, ev: ev) { + wsev.flags |= 1 + self.events.insert(wsev, at: 0) + } + */ + + + switch ev { + case .connected: + if !done_init { + send_initial_filters(relay_id: relay_id) + } else { + send_home_filters(relay_id: relay_id) + } + case .error(let merr): + let desc = merr.debugDescription + if desc.contains("Software caused connection abort") { + pool.reconnect(to: [relay_id]) + } + case .disconnected: fallthrough + case .cancelled: + pool.reconnect(to: [relay_id]) + case .reconnectSuggested(let t): + if t { + pool.reconnect(to: [relay_id]) + } + default: + break + } + + update_signal_from_pool(signal: self.signal, pool: self.pool) + + print("ws_event \(ev)") + + case .nostr_event(let ev): + switch ev { + case .event(let sub_id, let ev): + // globally handle likes + let always_process = sub_id == damus_contacts_subid || sub_id == damus_home_subid || sub_id == damus_init_subid || ev.known_kind == .like || ev.known_kind == .contacts || ev.known_kind == .metadata + if !always_process { + // TODO: other views like threads might have their own sub ids, so ignore those events... or should we? + return + } + + self.process_event(sub_id: sub_id, relay_id: relay_id, ev: ev) + case .notice(let msg): + //self.events.insert(NostrEvent(content: "NOTICE from \(relay_id): \(msg)", pubkey: "system"), at: 0) + print(msg) + } + } + } + + + /// Send the initial filters, just our contact list mostly + func send_initial_filters(relay_id: String) { + var filter = NostrFilter.filter_contacts + filter.authors = [self.damus_state.pubkey] + filter.limit = 1 + + pool.send(.subscribe(.init(filters: [filter], sub_id: damus_init_subid)), to: [relay_id]) + } + + func send_home_filters(relay_id: String?) { + // TODO: since times should be based on events from a specific relay + // perhaps we could mark this in the relay pool somehow + + var friends = damus_state.contacts.get_friend_list() + friends.append(damus_state.pubkey) + + var contacts_filter = NostrFilter.filter_kinds([0,3]) + contacts_filter.authors = damus_state.contacts.get_friendosphere() + + // TODO: separate likes? + var home_filter = NostrFilter.filter_kinds([ + NostrKind.text.rawValue, + NostrKind.like.rawValue, + NostrKind.boost.rawValue, + ]) + // include our pubkey as well even if we're not technically a friend + home_filter.authors = friends + home_filter.limit = 1000 + + var home_filters = [home_filter] + var contacts_filters = [contacts_filter] + let last_of_k = relay_id.flatMap { last_event_of_kind[$0] } ?? [:] + home_filters = update_filters_with_since(last_of_kind: last_of_k, filters: home_filters) + contacts_filters = update_filters_with_since(last_of_kind: last_of_k, filters: contacts_filters) + + print_filters(relay_id: relay_id, filters: [home_filters, contacts_filters]) + + if let relay_id = relay_id { + pool.send(.subscribe(.init(filters: home_filters, sub_id: damus_home_subid)), to: [relay_id]) + pool.send(.subscribe(.init(filters: contacts_filters, sub_id: damus_contacts_subid)), to: [relay_id]) + } else { + pool.send(.subscribe(.init(filters: home_filters, sub_id: damus_home_subid))) + pool.send(.subscribe(.init(filters: contacts_filters, sub_id: damus_contacts_subid))) + } + } + + func handle_metadata_event(_ ev: NostrEvent) { + guard let profile: Profile = decode_data(Data(ev.content.utf8)) else { + return + } + + if let mprof = damus_state.profiles.lookup_with_timestamp(id: ev.pubkey) { + if mprof.timestamp > ev.created_at { + // skip if we already have an newer profile + return + } + } + + let tprof = TimestampedProfile(profile: profile, timestamp: ev.created_at) + damus_state.profiles.add(id: ev.pubkey, profile: tprof) + + notify(.profile_updated, ProfileUpdate(pubkey: ev.pubkey, profile: profile)) + } + + func get_last_event_of_kind(relay_id: String, kind: Int) -> NostrEvent? { + guard let m = last_event_of_kind[relay_id] else { + last_event_of_kind[relay_id] = [:] + return nil + } + + return m[kind] + } + + func handle_notification(ev: NostrEvent) { + if !insert_uniq_sorted_event(events: &notifications, new_ev: ev, cmp: { $0.created_at > $1.created_at }) { + return + } + + let last_notified = get_last_notified() + + if last_notified == nil || last_notified!.created_at < ev.created_at { + save_last_notified(ev) + new_notifications = true + } + } + + func insert_home_event(_ ev: NostrEvent) -> Bool { + let ok = insert_uniq_sorted_event(events: &self.events, new_ev: ev, cmp: { $0.created_at > $1.created_at }) + return ok + } + + func should_hide_event(_ ev: NostrEvent) -> Bool { + return false + } + + func handle_text_event(_ ev: NostrEvent) { + if should_hide_event(ev) { + return + } + + let _ = insert_home_event(ev) + + if is_notification(ev: ev, pubkey: self.damus_state.pubkey) { + handle_notification(ev: ev) + } + } +} + + +func update_signal_from_pool(signal: SignalModel, pool: RelayPool) { + if signal.max_signal != pool.relays.count { + signal.max_signal = pool.relays.count + } + + if signal.signal != pool.num_connecting { + signal.signal = signal.max_signal - pool.num_connecting + } +} + + +func load_our_contacts(contacts: Contacts, our_pubkey: String, ev: NostrEvent) { + if ev.pubkey != our_pubkey { + return + } + + contacts.event = ev + + // our contacts + for tag in ev.tags { + if tag.count > 1 && tag[0] == "p" { + // TODO: validate pubkey? + contacts.add_friend_pubkey(tag[1]) + } + } +} + + + +func print_filters(relay_id: String?, filters groups: [[NostrFilter]]) { + let relays = relay_id ?? "relays" + print("connected to \(relays) with filters:") + for group in groups { + for filter in group { + print(filter) + } + } + print("-----") +} diff --git a/damus/Models/ProfileModel.swift b/damus/Models/ProfileModel.swift @@ -18,6 +18,13 @@ class ProfileModel: ObservableObject { var seen_event: Set<String> = Set() var sub_id = UUID().description + func get_follow_target() -> FollowTarget { + if let contacts = contacts { + return .contact(contacts) + } + return .pubkey(pubkey) + } + init(pubkey: String, damus: DamusState) { self.pubkey = pubkey self.damus = damus @@ -39,7 +46,7 @@ class ProfileModel: ObservableObject { var filter = NostrFilter.filter_authors([pubkey]) filter.kinds = kinds - filter.limit = 500 + filter.limit = 1000 print("subscribing to profile \(pubkey) with sub_id \(sub_id)") damus.pool.subscribe(sub_id: sub_id, filters: [filter], handler: handle_event) diff --git a/damus/Models/SignalModel.swift b/damus/Models/SignalModel.swift @@ -0,0 +1,32 @@ +// +// SignalModel.swift +// damus +// +// Created by William Casarin on 2022-05-24. +// + +import Foundation + + +class SignalModel: ObservableObject { + @Published var signal: Int + @Published var max_signal: Int + + var percentage: Double { + if max_signal == 0 { + return 0 + } + + return Double(signal) / Double(max_signal) + } + + init() { + self.signal = 0 + self.max_signal = 0 + } + + init(signal: Int, max_signal: Int) { + self.signal = signal + self.max_signal = max_signal + } +} diff --git a/damus/Nostr/NostrEvent.swift b/damus/Nostr/NostrEvent.swift @@ -349,22 +349,33 @@ func get_referenced_ids(tags: [[String]], key: String) -> [ReferencedId] { } } -func make_first_contact_event(keypair: Keypair) -> NostrEvent { +func make_first_contact_event(keypair: Keypair) -> NostrEvent? { + guard let privkey = keypair.privkey else { + return nil + } + let rw_relay_info = RelayInfo(read: true, write: true) - let damus_relay = "wss://relay.damus.io" let relays: [String: RelayInfo] = ["wss://relay.damus.io": rw_relay_info] let relay_json = encode_json(relays)! let damus_pubkey = "3efdaebb1d8923ebd99c9e7ace3b4194ab45512e2be79c1b7d68d9243e0d2681" + let tags = [ + ["p", damus_pubkey], + ["p", keypair.pubkey] // you're a friend of yourself! + ] let ev = NostrEvent(content: relay_json, pubkey: keypair.pubkey, kind: NostrKind.contacts.rawValue, - tags: [["p", damus_pubkey, damus_relay]]) + tags: tags) ev.calculate_id() - ev.sign(privkey: keypair.privkey) + ev.sign(privkey: privkey) return ev } -func make_metadata_event(keypair: Keypair, metadata: NostrMetadata) -> NostrEvent { +func make_metadata_event(keypair: Keypair, metadata: NostrMetadata) -> NostrEvent? { + guard let privkey = keypair.privkey else { + return nil + } + let metadata_json = encode_json(metadata)! let ev = NostrEvent(content: metadata_json, pubkey: keypair.pubkey, @@ -372,7 +383,7 @@ func make_metadata_event(keypair: Keypair, metadata: NostrMetadata) -> NostrEven tags: []) ev.calculate_id() - ev.sign(privkey: keypair.privkey) + ev.sign(privkey: privkey) return ev } diff --git a/damus/Nostr/ProofOfWork.swift b/damus/Nostr/ProofOfWork.swift @@ -75,6 +75,9 @@ func char_to_hex(_ c: UInt8) -> UInt8? func hex_decode(_ str: String) -> [UInt8]? { + if str.count == 0 { + return nil + } var ret: [UInt8] = [] let chars = Array(str.utf8) var i: Int = 0 diff --git a/damus/Util/Bech32.swift b/damus/Util/Bech32.swift @@ -0,0 +1,207 @@ +// +// Bech32.swift +// +// Modified by William Casarin in 2022 +// Created by Evolution Group Ltd on 12.02.2018. +// Copyright © 2018 Evolution Group Ltd. All rights reserved. +// +// Base32 address format for native v0-16 witness outputs implementation +// https://github.com/bitcoin/bips/blob/master/bip-0173.mediawiki +// Inspired by Pieter Wuille C++ implementation +import Foundation + +/// Bech32 checksum implementation +fileprivate let gen: [UInt32] = [0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3] +/// Bech32 checksum delimiter +fileprivate let checksumMarker: String = "1" +/// Bech32 character set for encoding +fileprivate let encCharset: Data = "qpzry9x8gf2tvdw0s3jn54khce6mua7l".data(using: .utf8)! +/// Bech32 character set for decoding +fileprivate let decCharset: [Int8] = [ + -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, + -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, + -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, + 15, -1, 10, 17, 21, 20, 26, 30, 7, 5, -1, -1, -1, -1, -1, -1, + -1, 29, -1, 24, 13, 25, 9, 8, 23, -1, 18, 22, 31, 27, 19, -1, + 1, 0, 3, 16, 11, 28, 12, 14, 6, 4, 2, -1, -1, -1, -1, -1, + -1, 29, -1, 24, 13, 25, 9, 8, 23, -1, 18, 22, 31, 27, 19, -1, + 1, 0, 3, 16, 11, 28, 12, 14, 6, 4, 2, -1, -1, -1, -1, -1 +] + + /// Find the polynomial with value coefficients mod the generator as 30-bit. +public func bech32_polymod(_ values: Data) -> UInt32 { + var chk: UInt32 = 1 + for v in values { + let top = (chk >> 25) + chk = (chk & 0x1ffffff) << 5 ^ UInt32(v) + for i: UInt8 in 0..<5 { + chk ^= ((top >> i) & 1) == 0 ? 0 : gen[Int(i)] + } + } + return chk + } + + +/// Expand a HRP for use in checksum computation. +func bech32_expand_hrp(_ s: String) -> Data { + var left: [UInt8] = [] + var right: [UInt8] = [] + for x in Array(s) { + let scalars = String(x).unicodeScalars + left.append(UInt8(scalars[scalars.startIndex].value) >> 5) + right.append(UInt8(scalars[scalars.startIndex].value) & 31) + } + return Data(left + [0] + right) +} + +/// Verify checksum +public func bech32_verify(hrp: String, checksum: Data) -> Bool { + var data = bech32_expand_hrp(hrp) + data.append(checksum) + return bech32_polymod(data) == 1 +} + +/// Create checksum +public func bech32_create_checksum(hrp: String, values: Data) -> Data { + var enc = bech32_expand_hrp(hrp) + enc.append(values) + enc.append(Data(repeating: 0x00, count: 6)) + let mod: UInt32 = bech32_polymod(enc) ^ 1 + var ret: Data = Data(repeating: 0x00, count: 6) + for i in 0..<6 { + ret[i] = UInt8((mod >> (5 * (5 - i))) & 31) + } + return ret +} + +public func bech32_encode(hrp: String, _ input: [UInt8]) -> String { + let table: [Character] = Array("qpzry9x8gf2tvdw0s3jn54khce6mua7l") + let bits = eightToFiveBits(input) + let check_sum = bech32_checksum(hrp: hrp, data: bits) + let separator = "1" + return "\(hrp)" + separator + String((bits + check_sum).map { table[Int($0)] }) +} + +func bech32_checksum(hrp: String, data: [UInt8]) -> [UInt8] { + let values = bech32_expand_hrp(hrp) + data + let polymod = bech32_polymod(values + [0,0,0,0,0,0]) ^ 1 + var result: [UInt32] = [] + for i in (0..<6) { + result.append((polymod >> (5 * (5 - UInt32(i)))) & 31) + } + return result.map { UInt8($0) } +} + +func eightToFiveBits(_ input: [UInt8]) -> [UInt8] { + guard !input.isEmpty else { return [] } + + var outputSize = (input.count * 8) / 5 + if ((input.count * 8) % 5) != 0 { + outputSize += 1 + } + var outputArray: [UInt8] = [] + for i in (0..<outputSize) { + let devision = (i * 5) / 8 + let reminder = (i * 5) % 8 + var element = input[devision] << reminder + element >>= 3 + + if (reminder > 3) && (i + 1 < outputSize) { + element = element | (input[devision + 1] >> (8 - reminder + 3)) + } + + outputArray.append(element) + } + + return outputArray +} + + /// Decode Bech32 string +public func bech32_decode(_ str: String) throws -> (hrp: String, data: Data) { + guard let strBytes = str.data(using: .utf8) else { + throw Bech32Error.nonUTF8String + } + guard strBytes.count <= 90 else { + throw Bech32Error.stringLengthExceeded + } + var lower: Bool = false + var upper: Bool = false + for c in strBytes { + // printable range + if c < 33 || c > 126 { + throw Bech32Error.nonPrintableCharacter + } + // 'a' to 'z' + if c >= 97 && c <= 122 { + lower = true + } + // 'A' to 'Z' + if c >= 65 && c <= 90 { + upper = true + } + } + if lower && upper { + throw Bech32Error.invalidCase + } + guard let pos = str.range(of: checksumMarker, options: .backwards)?.lowerBound else { + throw Bech32Error.noChecksumMarker + } + let intPos: Int = str.distance(from: str.startIndex, to: pos) + guard intPos >= 1 else { + throw Bech32Error.incorrectHrpSize + } + guard intPos + 7 <= str.count else { + throw Bech32Error.incorrectChecksumSize + } + let vSize: Int = str.count - 1 - intPos + var values: Data = Data(repeating: 0x00, count: vSize) + for i in 0..<vSize { + let c = strBytes[i + intPos + 1] + let decInt = decCharset[Int(c)] + if decInt == -1 { + throw Bech32Error.invalidCharacter + } + values[i] = UInt8(decInt) + } + let hrp = String(str[..<pos]).lowercased() + guard bech32_verify(hrp: hrp, checksum: values) else { + throw Bech32Error.checksumMismatch + } + return (hrp, Data(values[..<(vSize-6)])) +} + +public enum Bech32Error: LocalizedError { + case nonUTF8String + case nonPrintableCharacter + case invalidCase + case noChecksumMarker + case incorrectHrpSize + case incorrectChecksumSize + case stringLengthExceeded + + case invalidCharacter + case checksumMismatch + + public var errorDescription: String? { + switch self { + case .checksumMismatch: + return "Checksum doesn't match" + case .incorrectChecksumSize: + return "Checksum size too low" + case .incorrectHrpSize: + return "Human-readable-part is too small or empty" + case .invalidCase: + return "String contains mixed case characters" + case .invalidCharacter: + return "Invalid character met on decoding" + case .noChecksumMarker: + return "Checksum delimiter not found" + case .nonPrintableCharacter: + return "Non printable character in input string" + case .nonUTF8String: + return "String cannot be decoded by utf8 decoder" + case .stringLengthExceeded: + return "Input string is too long" + } + } +} diff --git a/damus/Util/InsertSort.swift b/damus/Util/InsertSort.swift @@ -7,6 +7,16 @@ import Foundation +func insert_uniq<T: Equatable>(xs: inout [T], new_x: T) -> Bool { + for x in xs { + if x == new_x { + return false + } + } + + xs.append(new_x) + 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/Keys.swift b/damus/Util/Keys.swift @@ -8,9 +8,54 @@ import Foundation import secp256k1 +let PUBKEY_HRP = "npub" +let PRIVKEY_HRP = "nsec" + struct Keypair { let pubkey: String - let privkey: String + let privkey: String? + + var pubkey_bech32: String { + return bech32_pubkey(pubkey)! + } + + var privkey_bech32: String? { + return privkey.flatMap { bech32_privkey($0) } + } +} + +enum Bech32Key { + case pub(String) + case sec(String) +} + +func decode_bech32_key(_ key: String) -> Bech32Key? { + guard let decoded = try? bech32_decode(key) else { + return nil + } + + let hexed = hex_encode(decoded.data) + if decoded.hrp == "npub" { + return .pub(hexed) + } else if decoded.hrp == "nsec" { + return .sec(hexed) + } + + return nil +} + +func bech32_privkey(_ privkey: String) -> String? { + guard let bytes = hex_decode(privkey) else { + return nil + } + return bech32_encode(hrp: "nsec", bytes) +} + +func bech32_pubkey(_ pubkey: String) -> String? { + guard let bytes = hex_decode(pubkey) else { + return nil + } + return bech32_encode(hrp: "npub", bytes) } func generate_new_keypair() -> Keypair { @@ -21,16 +66,37 @@ func generate_new_keypair() -> Keypair { return Keypair(pubkey: pubkey, privkey: privkey) } -func save_keypair(pubkey: String, privkey: String) { +func privkey_to_pubkey(privkey: String) -> String? { + guard let sec = hex_decode(privkey) else { + return nil + } + guard let key = try? secp256k1.Signing.PrivateKey(rawRepresentation: sec) else { + return nil + } + return hex_encode(Data(key.publicKey.xonlyKeyBytes)) +} + +func save_pubkey(pubkey: String) { UserDefaults.standard.set(pubkey, forKey: "pubkey") +} + +func save_privkey(privkey: String) { UserDefaults.standard.set(privkey, forKey: "privkey") } +func clear_privkey() { + UserDefaults.standard.removeObject(forKey: "privkey") +} + +func save_keypair(pubkey: String, privkey: String) { + save_pubkey(pubkey: pubkey) + save_privkey(privkey: privkey) +} + func get_saved_keypair() -> Keypair? { get_saved_pubkey().flatMap { pubkey in - get_saved_privkey().map { privkey in - return Keypair(pubkey: pubkey, privkey: privkey) - } + let privkey = get_saved_privkey() + return Keypair(pubkey: pubkey, privkey: privkey) } } diff --git a/damus/Views/ChatView.swift b/damus/Views/ChatView.swift @@ -115,7 +115,7 @@ struct ChatView: View { if is_active || next_ev == nil || next_ev!.pubkey != event.pubkey { let bar = make_actionbar_model(ev: event, damus: damus) EventActionBar(event: event, - our_pubkey: damus.pubkey, + keypair: damus.keypair, profiles: damus.profiles, bar: bar ) diff --git a/damus/Views/CreateAccountView.swift b/damus/Views/CreateAccountView.swift @@ -12,32 +12,6 @@ struct CreateAccountView: View { @State var is_light: Bool = false @State var is_done: Bool = false - func FormTextInput(_ title: String, text: Binding<String>) -> some View { - return TextField("", text: text) - .placeholder(when: text.wrappedValue.isEmpty) { - Text(title).foregroundColor(.white.opacity(0.4)) - } - .padding() - .background { - RoundedRectangle(cornerRadius: 4.0).opacity(0.2) - } - .foregroundColor(.white) - .font(.body.bold()) - } - - func FormLabel(_ title: String, optional: Bool = false) -> some View { - return HStack { - Text(title) - .bold() - .foregroundColor(.white) - if optional { - Text("optional") - .font(.callout) - .foregroundColor(.white.opacity(0.5)) - } - } - } - func SignupForm<FormContent: View>(@ViewBuilder content: () -> FormContent) -> some View { return VStack(alignment: .leading, spacing: 10.0, content: content) } @@ -45,7 +19,7 @@ struct CreateAccountView: View { func regen_key() { let keypair = generate_new_keypair() self.account.pubkey = keypair.pubkey - self.account.privkey = keypair.privkey + self.account.privkey = keypair.privkey! } var body: some View { @@ -89,7 +63,7 @@ struct CreateAccountView: View { regen_key() } - KeyInput($account.pubkey) + KeyText($account.pubkey) .onTapGesture { regen_key() } @@ -110,6 +84,20 @@ struct CreateAccountView: View { } .navigationBarTitleDisplayMode(.inline) + .navigationBarBackButtonHidden(true) + .navigationBarItems(leading: BackNav()) + } +} + +struct BackNav: View { + @Environment(\.dismiss) var dismiss + + var body: some View { + Image(systemName: "chevron.backward") + .foregroundColor(.white) + .onTapGesture { + self.dismiss() + } } } @@ -133,10 +121,38 @@ struct CreateAccountView_Previews: PreviewProvider { } } -func KeyInput(_ text: Binding<String>) -> some View { - return Text("\(text.wrappedValue)") +func KeyText(_ text: Binding<String>) -> some View { + let decoded = hex_decode(text.wrappedValue)! + let bechkey = bech32_encode(hrp: PUBKEY_HRP, decoded) + return Text(bechkey) .textSelection(.enabled) .font(.callout.monospaced()) .foregroundColor(.white) } +func FormTextInput(_ title: String, text: Binding<String>) -> some View { + return TextField("", text: text) + .placeholder(when: text.wrappedValue.isEmpty) { + Text(title).foregroundColor(.white.opacity(0.4)) + } + .padding() + .background { + RoundedRectangle(cornerRadius: 4.0).opacity(0.2) + } + .foregroundColor(.white) + .font(.body.bold()) +} + +func FormLabel(_ title: String, optional: Bool = false) -> some View { + return HStack { + Text(title) + .bold() + .foregroundColor(.white) + if optional { + Text("optional") + .font(.callout) + .foregroundColor(.white.opacity(0.5)) + } + } +} + diff --git a/damus/Views/EventActionBar.swift b/damus/Views/EventActionBar.swift @@ -19,7 +19,7 @@ enum ActionBarSheet: Identifiable { struct EventActionBar: View { let event: NostrEvent - let our_pubkey: String + let keypair: Keypair @State var sheet: ActionBarSheet? = nil let profiles: Profiles @StateObject var bar: ActionBarModel @@ -34,10 +34,12 @@ struct EventActionBar: View { Spacer() */ - EventActionButton(img: "bubble.left", col: nil) { - notify(.reply, event) + if keypair.privkey != nil { + EventActionButton(img: "bubble.left", col: nil) { + notify(.reply, event) + } + .padding([.trailing], 20) } - .padding([.trailing], 20) HStack(alignment: .bottom) { Text("\(bar.likes > 0 ? "\(bar.likes)" : "")") @@ -90,7 +92,7 @@ struct EventActionBar: View { return } self.bar.likes = liked.total - if liked.event.pubkey == our_pubkey { + if liked.event.pubkey == keypair.pubkey { self.bar.our_like = liked.event } } diff --git a/damus/Views/EventView.swift b/damus/Views/EventView.swift @@ -101,7 +101,7 @@ struct EventView: View { if has_action_bar { let bar = make_actionbar_model(ev: event, damus: damus) - EventActionBar(event: event, our_pubkey: damus.pubkey, profiles: damus.profiles, bar: bar) + EventActionBar(event: event, keypair: damus.keypair, profiles: damus.profiles, bar: bar) } Divider() diff --git a/damus/Views/FollowButtonView.swift b/damus/Views/FollowButtonView.swift @@ -8,17 +8,17 @@ import SwiftUI struct FollowButtonView: View { - let pubkey: String + let target: FollowTarget @State var follow_state: FollowState var body: some View { Button("\(follow_btn_txt(follow_state))") { - follow_state = perform_follow_btn_action(follow_state, target: pubkey) + follow_state = perform_follow_btn_action(follow_state, target: target) } .buttonStyle(.bordered) .onReceive(handle_notify(.followed)) { notif in let pk = notif.object as! String - if pk != pubkey { + if pk != target.pubkey { return } @@ -26,7 +26,7 @@ struct FollowButtonView: View { } .onReceive(handle_notify(.unfollowed)) { notif in let pk = notif.object as! String - if pk != pubkey { + if pk != target.pubkey { return } @@ -35,10 +35,43 @@ struct FollowButtonView: View { } } - /* +struct FollowButtonPreviews: View { + let target: FollowTarget = .pubkey("") + var body: some View { + VStack { + Text("Unfollows") + FollowButtonView(target: target, follow_state: .unfollows) + + Text("Following") + FollowButtonView(target: target, follow_state: .following) + + Text("Follows") + FollowButtonView(target: target, follow_state: .follows) + + Text("Unfollowing") + FollowButtonView(target: target, follow_state: .unfollowing) + } + } +} + struct FollowButtonView_Previews: PreviewProvider { static var previews: some View { - FollowButtonView() + FollowButtonPreviews() + } +} + +func perform_follow_btn_action(_ fs: FollowState, target: FollowTarget) -> FollowState { + switch fs { + case .follows: + notify(.unfollow, target) + return .following + case .following: + return .following + case .unfollowing: + return .following + case .unfollows: + notify(.follow, target) + return .unfollowing } } - */ + diff --git a/damus/Views/FollowingView.swift b/damus/Views/FollowingView.swift @@ -8,21 +8,21 @@ import SwiftUI struct FollowUserView: View { - let pubkey: String + let target: FollowTarget let damus_state: DamusState var body: some View { HStack(alignment: .top) { - let pmodel = ProfileModel(pubkey: pubkey, damus: damus_state) + let pmodel = ProfileModel(pubkey: target.pubkey, damus: damus_state) let pv = ProfileView(damus_state: damus_state, profile: pmodel) NavigationLink(destination: pv) { - ProfilePicView(pubkey: pubkey, size: PFP_SIZE, highlight: .none, image_cache: damus_state.image_cache, profiles: damus_state.profiles) + ProfilePicView(pubkey: target.pubkey, size: PFP_SIZE, highlight: .none, image_cache: damus_state.image_cache, profiles: damus_state.profiles) } VStack(alignment: .leading) { - let profile = damus_state.profiles.lookup(id: pubkey) - ProfileName(pubkey: pubkey, profile: profile) + let profile = damus_state.profiles.lookup(id: target.pubkey) + ProfileName(pubkey: target.pubkey, profile: profile) if let about = profile.flatMap { $0.about } { Text(about) } @@ -30,7 +30,7 @@ struct FollowUserView: View { Spacer() - FollowButtonView(pubkey: pubkey, follow_state: damus_state.contacts.follow_state(pubkey)) + FollowButtonView(target: target, follow_state: damus_state.contacts.follow_state(target.pubkey)) } } } @@ -43,7 +43,7 @@ struct FollowingView: View { ScrollView { LazyVStack(alignment: .leading) { ForEach(contact.referenced_pubkeys) { pk in - FollowUserView(pubkey: pk.ref_id, damus_state: damus_state) + FollowUserView(target: .pubkey(pk.ref_id), damus_state: damus_state) } } } diff --git a/damus/Views/LoginView.swift b/damus/Views/LoginView.swift @@ -0,0 +1,195 @@ +// +// LoginView.swift +// damus +// +// Created by William Casarin on 2022-05-22. +// + +import SwiftUI + +enum ParsedKey { + case pub(String) + case priv(String) + case hex(String) + + var is_pub: Bool { + if case .pub = self { + return true + } + return false + } + + var is_hex: Bool { + if case .hex = self { + return true + } + return false + } +} + +struct LoginView: View { + @State var key: String = "" + @State var is_pubkey: Bool = false + @State var error: String? = nil + + func get_error(parsed_key: ParsedKey?) -> String? { + if self.error != nil { + return self.error + } + + if !key.isEmpty && parsed_key == nil { + return "Invalid key" + } + + return nil + } + + var body: some View { + ZStack(alignment: .top) { + DamusGradient() + VStack { + Text("Login") + .foregroundColor(.white) + .font(.title) + .padding() + + Text("Enter your account key to login:") + .foregroundColor(.white) + .padding() + + KeyInput("nsec1...", key: $key) + + let parsed = parse_key(key) + + if parsed?.is_hex ?? false { + Text("This is an old-style nostr key. We're not sure if it's a pubkey or private key. Please toggle the button below if this a public key.") + .font(.subheadline.bold()) + .foregroundColor(.white) + PubkeySwitch(isOn: $is_pubkey) + .padding() + } + + if let error = get_error(parsed_key: parsed) { + Text(error) + .foregroundColor(.red) + .padding() + } + + if parsed?.is_pub ?? false { + Text("This is a public key, you will not be able to make posts or interact in any way. This is used for viewing accounts from their perspective.") + .foregroundColor(.white) + .padding() + } + + if let p = parsed { + DamusWhiteButton("Login") { + if !process_login(p, is_pubkey: self.is_pubkey) { + self.error = "Invalid key" + } + } + } + } + .padding() + } + .navigationBarBackButtonHidden(true) + .navigationBarItems(leading: BackNav()) + } +} + +struct PubkeySwitch: View { + @Binding var isOn: Bool + var body: some View { + HStack { + Toggle(isOn: $isOn) { + Text("Public Key?") + .foregroundColor(.white) + } + } + } +} + +func parse_key(_ thekey: String) -> ParsedKey? { + var key = thekey + if key.count > 0 && key.first! == "@" { + key = String(key.dropFirst()) + } + if hex_decode(key) != nil { + return .hex(key) + } + + if let bech_key = decode_bech32_key(key) { + switch bech_key { + case .pub(let pk): + return .pub(pk) + case .sec(let sec): + return .priv(sec) + } + } + + return nil +} + +func process_login(_ key: ParsedKey, is_pubkey: Bool) -> Bool { + switch key { + case .priv(let priv): + save_privkey(privkey: priv) + guard let pk = privkey_to_pubkey(privkey: priv) else { + return false + } + save_pubkey(pubkey: pk) + + case .pub(let pub): + clear_privkey() + save_pubkey(pubkey: pub) + + case .hex(let hexstr): + if is_pubkey { + clear_privkey() + save_pubkey(pubkey: hexstr) + } else { + save_privkey(privkey: hexstr) + guard let pk = privkey_to_pubkey(privkey: hexstr) else { + return false + } + save_pubkey(pubkey: pk) + } + } + + notify(.login, ()) + return true +} + +struct KeyInput: View { + let title: String + let key: Binding<String> + + init(_ title: String, key: Binding<String>) { + self.title = title + self.key = key + } + + var body: some View { + TextField("", text: key) + .placeholder(when: key.wrappedValue.isEmpty) { + Text(title).foregroundColor(.white.opacity(0.6)) + } + .padding() + .background { + RoundedRectangle(cornerRadius: 4.0).opacity(0.2) + } + .autocapitalization(.none) + .foregroundColor(.white) + .font(.body.monospaced()) + } +} + +struct LoginView_Previews: PreviewProvider { + static var previews: some View { + let pubkey = "3efdaebb1d8923ebd99c9e7ace3b4194ab45512e2be79c1b7d68d9243e0d2681" + let bech32_pubkey = "KeyInput" + Group { + LoginView(key: pubkey) + LoginView(key: bech32_pubkey) + } + } +} diff --git a/damus/Views/MainTabView.swift b/damus/Views/MainTabView.swift @@ -10,7 +10,6 @@ import SwiftUI enum Timeline: String, CustomStringConvertible { case home case notifications - case global case search var description: String { @@ -86,7 +85,6 @@ struct TabBar: View { TabButton(timeline: .home, img: "house", selected: $selected, action: action) TabButton(timeline: .search, img: "magnifyingglass.circle", selected: $selected, action: action) NotificationsTab(new_notifications: $new_notifications, selected: $selected, action: action) - TabButton(timeline: .global, img: "globe.americas", selected: $selected, action: action) } } } diff --git a/damus/Views/ProfileView.swift b/damus/Views/ProfileView.swift @@ -45,21 +45,6 @@ func follow_btn_enabled_state(_ fs: FollowState) -> Bool { } } -func perform_follow_btn_action(_ fs: FollowState, target: String) -> FollowState { - switch fs { - case .follows: - notify(.unfollow, target) - return .following - case .following: - return .following - case .unfollowing: - return .following - case .unfollows: - notify(.follow, target) - return .unfollowing - } -} - struct ProfileView: View { let damus_state: DamusState @@ -76,7 +61,7 @@ struct ProfileView: View { Spacer() - FollowButtonView(pubkey: profile.pubkey, follow_state: damus_state.contacts.follow_state(profile.pubkey)) + FollowButtonView(target: profile.get_follow_target(), follow_state: damus_state.contacts.follow_state(profile.pubkey)) } if let pubkey = profile.pubkey { diff --git a/damus/Views/SaveKeysView.swift b/damus/Views/SaveKeysView.swift @@ -39,20 +39,22 @@ struct SaveKeysView: View { .foregroundColor(.white) .padding(.bottom, 10) - SaveKeyView(text: account.pubkey, is_copied: $pub_copied) + SaveKeyView(text: account.pubkey_bech32, is_copied: $pub_copied) .padding(.bottom, 10) - Text("Private Key") - .font(.title2.bold()) - .foregroundColor(.white) - .padding(.bottom, 10) - - Text("This is your secret account key. You need this to access your account. Don't share this with anyone! Save it in a password manager and keep it safe!") - .foregroundColor(.white) - .padding(.bottom, 10) - - SaveKeyView(text: account.privkey, is_copied: $priv_copied) - .padding(.bottom, 10) + if pub_copied { + Text("Private Key") + .font(.title2.bold()) + .foregroundColor(.white) + .padding(.bottom, 10) + + Text("This is your secret account key. You need this to access your account. Don't share this with anyone! Save it in a password manager and keep it safe!") + .foregroundColor(.white) + .padding(.bottom, 10) + + SaveKeyView(text: account.privkey_bech32, is_copied: $priv_copied) + .padding(.bottom, 10) + } if pub_copied && priv_copied { if loading { @@ -73,6 +75,8 @@ struct SaveKeysView: View { } .padding(20) } + .navigationBarBackButtonHidden(true) + .navigationBarItems(leading: BackNav()) } func complete_account_creation(_ account: CreateAccountModel) { @@ -90,11 +94,15 @@ struct SaveKeysView: View { switch wsev { case .connected: let metadata = create_account_to_metadata(account) - let metadata_ev = make_metadata_event(keypair: account.keypair, metadata: metadata) - let contacts_ev = make_first_contact_event(keypair: account.keypair) + let m_metadata_ev = make_metadata_event(keypair: account.keypair, metadata: metadata) + let m_contacts_ev = make_first_contact_event(keypair: account.keypair) - self.pool.send(.event(metadata_ev)) - self.pool.send(.event(contacts_ev)) + if let metadata_ev = m_metadata_ev { + self.pool.send(.event(metadata_ev)) + } + if let contacts_ev = m_contacts_ev { + self.pool.send(.event(contacts_ev)) + } save_keypair(pubkey: account.pubkey, privkey: account.privkey) notify(.login, account.keypair) diff --git a/damus/Views/SetupView.swift b/damus/Views/SetupView.swift @@ -45,6 +45,9 @@ struct SetupView: View { NavigationLink(destination: CreateAccountView(), tag: .create_account, selection: $state ) { EmptyView() } + NavigationLink(destination: LoginView(), tag: .login, selection: $state ) { + EmptyView() + } Image("logo-nobg") .resizable() @@ -64,7 +67,7 @@ struct SetupView: View { } Button("Login") { - notify(.login, ()) + self.state = .login } .padding([.top, .bottom], 20) .foregroundColor(.white) diff --git a/damus/damusApp.swift b/damus/damusApp.swift @@ -26,7 +26,7 @@ struct MainView: View { var body: some View { Group { if let kp = keypair, !needs_setup { - ContentView(pubkey: kp.pubkey, privkey: kp.privkey) + ContentView(keypair: kp) } else { SetupView() .onReceive(handle_notify(.login)) { notif in diff --git a/damusTests/Bech32Tests.swift b/damusTests/Bech32Tests.swift @@ -0,0 +1,51 @@ +// +// Bech32Tests.swift +// damusTests +// +// Created by William Casarin on 2022-05-22. +// + +import XCTest +@testable import damus + +class Bech32Tests: 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 test_bech32_encode_decode() throws { + // This is an example of a functional test case. + // Use XCTAssert and related functions to verify your tests produce the correct results. + // Any test you write for XCTest can be annotated as throws and async. + // Mark your test throws to produce an unexpected failure when your test encounters an uncaught error. + // Mark your test async to allow awaiting for asynchronous code to complete. Check the results with assertions afterwards. + + let pubkey = "32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245" + guard let b32_pubkey = bech32_pubkey(pubkey) else { + XCTAssert(false) + return + } + + guard let decoded = try? bech32_decode(b32_pubkey) else { + XCTAssert(false) + return + } + + let encoded = hex_encode(decoded.data) + + XCTAssertEqual(encoded, pubkey) + } + + func testPerformanceExample() throws { + // This is an example of a performance test case. + self.measure { + // Put the code you want to measure the time of here. + } + } + +}