damus

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

commit f42bc2e91e034cec21fc531b7e4f68600c229757
parent 403fa74f8d5ada2bb658874610ab3285a3aa7065
Author: William Casarin <jb55@jb55.com>
Date:   Wed,  4 May 2022 16:26:10 -0700

likes, mention parsing, lots of stuff

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

Diffstat:
Mdamus.xcodeproj/project.pbxproj | 32++++++++++++++++++++++++++++++++
Mdamus/ContentView.swift | 153+++++++++++++++++++++++++++++++++++++++++++++++++++++--------------------------
Mdamus/Models/ActionBarModel.swift | 15+++++++++++----
Adamus/Models/DamusState.swift | 16++++++++++++++++
Adamus/Models/LikeCounter.swift | 53+++++++++++++++++++++++++++++++++++++++++++++++++++++
Adamus/Models/Liked.swift | 19+++++++++++++++++++
Adamus/Models/Mentions.swift | 132+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Adamus/Models/ParsedRefs.swift | 15+++++++++++++++
Mdamus/Models/ProfileModel.swift | 19++++++++++---------
Mdamus/Models/ThreadModel.swift | 8++++++--
Mdamus/Nostr/NostrEvent.swift | 107++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------
Mdamus/Nostr/NostrFilter.swift | 4++++
Mdamus/Notifications.swift | 6++++++
Adamus/Util/Parser.swift | 42++++++++++++++++++++++++++++++++++++++++++
Mdamus/Views/ChatView.swift | 7+++++--
Mdamus/Views/ChatroomView.swift | 6+++++-
Mdamus/Views/EventActionBar.swift | 33++++++++++++++++++++++++---------
Mdamus/Views/EventDetailView.swift | 4++--
Mdamus/Views/EventView.swift | 14+++++++++++---
Mdamus/Views/PostView.swift | 8--------
Mdamus/Views/ProfileView.swift | 5+++--
Mdamus/Views/ReplyView.swift | 6+++---
Mdamus/Views/ThreadView.swift | 6+++---
Mdamus/Views/TimelineView.swift | 6+++---
MdamusTests/damusTests.swift | 10++++++++++
25 files changed, 608 insertions(+), 118 deletions(-)

diff --git a/damus.xcodeproj/project.pbxproj b/damus.xcodeproj/project.pbxproj @@ -13,9 +13,14 @@ 4C0A3F93280F66F5000448DE /* ReplyMap.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C0A3F92280F66F5000448DE /* ReplyMap.swift */; }; 4C0A3F95280F6C78000448DE /* ReplyQuoteView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C0A3F94280F6C78000448DE /* ReplyQuoteView.swift */; }; 4C0A3F97280F8E02000448DE /* ThreadView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C0A3F96280F8E02000448DE /* ThreadView.swift */; }; + 4C363A8428233689006E126D /* Parser.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C363A8328233689006E126D /* Parser.swift */; }; 4C3BEFD22819DB9B00B3DE84 /* ProfileModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C3BEFD12819DB9B00B3DE84 /* ProfileModel.swift */; }; 4C3BEFD42819DE8F00B3DE84 /* NostrKind.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C3BEFD32819DE8F00B3DE84 /* NostrKind.swift */; }; 4C3BEFD6281D995700B3DE84 /* ActionBarModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C3BEFD5281D995700B3DE84 /* ActionBarModel.swift */; }; + 4C3BEFDA281DCA1400B3DE84 /* LikeCounter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C3BEFD9281DCA1400B3DE84 /* LikeCounter.swift */; }; + 4C3BEFDC281DCE6100B3DE84 /* Liked.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C3BEFDB281DCE6100B3DE84 /* Liked.swift */; }; + 4C3BEFDE281DD59C00B3DE84 /* ParsedRefs.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C3BEFDD281DD59C00B3DE84 /* ParsedRefs.swift */; }; + 4C3BEFE0281DE1ED00B3DE84 /* DamusState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C3BEFDF281DE1ED00B3DE84 /* DamusState.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 */; }; @@ -26,6 +31,7 @@ 4C75EFB728049D990006080F /* RelayPool.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C75EFB628049D990006080F /* RelayPool.swift */; }; 4C75EFB92804A2740006080F /* EventView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C75EFB82804A2740006080F /* EventView.swift */; }; 4C75EFBB2804A34C0006080F /* ProofOfWork.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C75EFBA2804A34C0006080F /* ProofOfWork.swift */; }; + 4C7FF7D52823313F009601DB /* Mentions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C7FF7D42823313F009601DB /* Mentions.swift */; }; 4C8682872814DE470026224F /* ProfileView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C8682862814DE470026224F /* ProfileView.swift */; }; 4CA2EFA0280E37AC0044ACD8 /* TimelineView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CA2EF9F280E37AC0044ACD8 /* TimelineView.swift */; }; 4CACA9D5280C31E100D9BBE8 /* ReplyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CACA9D4280C31E100D9BBE8 /* ReplyView.swift */; }; @@ -74,9 +80,14 @@ 4C0A3F92280F66F5000448DE /* ReplyMap.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReplyMap.swift; sourceTree = "<group>"; }; 4C0A3F94280F6C78000448DE /* ReplyQuoteView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReplyQuoteView.swift; sourceTree = "<group>"; }; 4C0A3F96280F8E02000448DE /* ThreadView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThreadView.swift; sourceTree = "<group>"; }; + 4C363A8328233689006E126D /* Parser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Parser.swift; sourceTree = "<group>"; }; 4C3BEFD12819DB9B00B3DE84 /* ProfileModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileModel.swift; sourceTree = "<group>"; }; 4C3BEFD32819DE8F00B3DE84 /* NostrKind.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NostrKind.swift; sourceTree = "<group>"; }; 4C3BEFD5281D995700B3DE84 /* ActionBarModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActionBarModel.swift; sourceTree = "<group>"; }; + 4C3BEFD9281DCA1400B3DE84 /* LikeCounter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LikeCounter.swift; sourceTree = "<group>"; }; + 4C3BEFDB281DCE6100B3DE84 /* Liked.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Liked.swift; sourceTree = "<group>"; }; + 4C3BEFDD281DD59C00B3DE84 /* ParsedRefs.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParsedRefs.swift; sourceTree = "<group>"; }; + 4C3BEFDF281DE1ED00B3DE84 /* DamusState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DamusState.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>"; }; @@ -88,6 +99,7 @@ 4C75EFB628049D990006080F /* RelayPool.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RelayPool.swift; sourceTree = "<group>"; }; 4C75EFB82804A2740006080F /* EventView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EventView.swift; sourceTree = "<group>"; }; 4C75EFBA2804A34C0006080F /* ProofOfWork.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProofOfWork.swift; sourceTree = "<group>"; }; + 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>"; }; 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>"; }; @@ -148,6 +160,11 @@ 4C0A3F92280F66F5000448DE /* ReplyMap.swift */, 4C3BEFD12819DB9B00B3DE84 /* ProfileModel.swift */, 4C3BEFD5281D995700B3DE84 /* ActionBarModel.swift */, + 4C3BEFD9281DCA1400B3DE84 /* LikeCounter.swift */, + 4C3BEFDB281DCE6100B3DE84 /* Liked.swift */, + 4C3BEFDD281DD59C00B3DE84 /* ParsedRefs.swift */, + 4C3BEFDF281DE1ED00B3DE84 /* DamusState.swift */, + 4C7FF7D42823313F009601DB /* Mentions.swift */, ); path = Models; sourceTree = "<group>"; @@ -192,6 +209,14 @@ path = Nostr; sourceTree = "<group>"; }; + 4C7FF7D628233637009601DB /* Util */ = { + isa = PBXGroup; + children = ( + 4C363A8328233689006E126D /* Parser.swift */, + ); + path = Util; + sourceTree = "<group>"; + }; 4CE6DEDA27F7A08100C66700 = { isa = PBXGroup; children = ( @@ -216,6 +241,7 @@ 4CE6DEE527F7A08100C66700 /* damus */ = { isa = PBXGroup; children = ( + 4C7FF7D628233637009601DB /* Util */, 4C0A3F8D280F63FF000448DE /* Models */, 4C75EFAB28049CC80006080F /* Nostr */, 4C75EFA72804823E0006080F /* Info.plist */, @@ -407,14 +433,19 @@ 4CEE2AF5280B29E600AB5EEF /* TimeAgo.swift in Sources */, 4C75EFAD28049CFB0006080F /* PostButton.swift in Sources */, 4C75EFB92804A2740006080F /* EventView.swift in Sources */, + 4C7FF7D52823313F009601DB /* Mentions.swift in Sources */, 4C0A3F8C280F5FCA000448DE /* ChatroomView.swift in Sources */, 4C0A3F91280F6528000448DE /* ChatView.swift in Sources */, 4C75EFA627FF87A20006080F /* Nostr.swift in Sources */, 4C75EFB328049D640006080F /* NostrEvent.swift in Sources */, 4CA2EFA0280E37AC0044ACD8 /* TimelineView.swift in Sources */, + 4C363A8428233689006E126D /* Parser.swift in Sources */, + 4C3BEFDC281DCE6100B3DE84 /* Liked.swift in Sources */, 4C75EFB128049D510006080F /* NostrResponse.swift in Sources */, 4CEE2AF7280B2DEA00AB5EEF /* ProfileName.swift in Sources */, + 4C3BEFDE281DD59C00B3DE84 /* ParsedRefs.swift in Sources */, 4CEE2B02280B39E800AB5EEF /* EventActionBar.swift in Sources */, + 4C3BEFE0281DE1ED00B3DE84 /* DamusState.swift in Sources */, 4C0A3F8F280F640A000448DE /* ThreadModel.swift in Sources */, 4C3BEFD22819DB9B00B3DE84 /* ProfileModel.swift in Sources */, 4C0A3F93280F66F5000448DE /* ReplyMap.swift in Sources */, @@ -429,6 +460,7 @@ 4CE6DEE727F7A08100C66700 /* damusApp.swift in Sources */, 4CEE2AED2805B22500AB5EEF /* NostrRequest.swift in Sources */, 4C0A3F95280F6C78000448DE /* ReplyQuoteView.swift in Sources */, + 4C3BEFDA281DCA1400B3DE84 /* LikeCounter.swift in Sources */, 4CACA9D5280C31E100D9BBE8 /* ReplyView.swift in Sources */, 4C0A3F97280F8E02000448DE /* ThreadView.swift in Sources */, 4C75EFA427FA577B0006080F /* PostView.swift in Sources */, diff --git a/damus/ContentView.swift b/damus/ContentView.swift @@ -46,7 +46,7 @@ struct ContentView: View { @State var profiles: Profiles = Profiles() @State var friends: [String: ()] = [:] @State var loading: Bool = true - @State var pool: RelayPool? = nil + @State var damus: DamusState? = nil @State var selected_timeline: Timeline? = .home @State var is_thread_open: Bool = false @State var is_profile_open: Bool = false @@ -135,15 +135,15 @@ struct ContentView: View { var PostingTimelineView: some View { ZStack { - if let pool = self.pool { - TimelineView(events: $friend_events, pool: pool) + if let damus = self.damus { + TimelineView(events: $friend_events, damus: damus) .environmentObject(profiles) } PostButtonContainer } } - func MainContent(pool: RelayPool) -> some View { + func MainContent(damus: DamusState) -> some View { NavigationView { VStack { switch selected_timeline { @@ -154,13 +154,13 @@ struct ContentView: View { } case .notifications: - TimelineView(events: $notifications, pool: pool) + TimelineView(events: $notifications, damus: damus) .environmentObject(profiles) .navigationTitle("Notifications") case .global: - TimelineView(events: $events, pool: pool) + TimelineView(events: $events, damus: damus) .environmentObject(profiles) .navigationTitle("Global") case .none: @@ -174,9 +174,9 @@ struct ContentView: View { var body: some View { VStack { - if let pool = self.pool { + if let damus = self.damus { ZStack { - MainContent(pool: pool) + MainContent(damus: damus) .padding([.bottom], -8.0) LoadingContainer @@ -193,14 +193,14 @@ struct ContentView: View { case .post: PostView(references: []) case .reply(let event): - ReplyView(replying_to: event, pool: pool!) + ReplyView(replying_to: event, damus: damus!) .environmentObject(profiles) } } .onReceive(handle_notify(.boost)) { notif in let ev = notif.object as! NostrEvent let boost = make_boost_event(ev, privkey: privkey, pubkey: pubkey) - self.pool?.send(.event(boost)) + self.damus?.pool.send(.event(boost)) } .onReceive(handle_notify(.open_thread)) { obj in //let ev = obj.object as! NostrEvent @@ -211,9 +211,18 @@ struct ContentView: View { let ev = notif.object as! NostrEvent self.active_sheet = .reply(ev) } + .onReceive(handle_notify(.like)) { like in + let ev = like.object as! NostrEvent + guard let like_ev = make_like_event(pubkey: pubkey, liked: ev) else { + return + } + like_ev.calculate_id() + like_ev.sign(privkey: privkey) + self.damus?.pool.send(.event(like_ev)) + } .onReceive(handle_notify(.broadcast_event)) { obj in let ev = obj.object as! NostrEvent - self.pool?.send(.event(ev)) + self.damus?.pool.send(.event(ev)) } .onReceive(handle_notify(.post)) { obj in let post_res = obj.object as! NostrPostResult @@ -221,15 +230,15 @@ struct ContentView: View { case .post(let post): print("post \(post.content)") let new_ev = post.to_event(privkey: privkey, pubkey: pubkey) - self.pool?.send(.event(new_ev)) + self.damus?.pool.send(.event(new_ev)) case .cancel: active_sheet = nil print("post cancelled") } } .onReceive(timer) { n in - self.pool?.connect_to_disconnected() - self.loading = (self.pool?.num_connecting ?? 0) != 0 + self.damus?.pool.connect_to_disconnected() + self.loading = (self.damus?.pool.num_connecting ?? 0) != 0 } } @@ -292,7 +301,9 @@ struct ContentView: View { pool.register_handler(sub_id: sub_id, handler: handle_event) - self.pool = pool + self.damus = DamusState(pool: pool, pubkey: pubkey, + likes: EventCounter(our_pubkey: pubkey), + boosts: EventCounter(our_pubkey: pubkey)) pool.connect() } @@ -306,7 +317,28 @@ struct ContentView: View { } } } - + + func handle_boost_event(_ ev: NostrEvent) { + damus!.boosts.add_event(ev) + } + + 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!.likes.add_event(ev) { + case .user_already_liked: + break + case .success(let n): + let liked = Liked(like: 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 @@ -335,11 +367,15 @@ struct ContentView: View { 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 last_text_event = get_last_event_of_kind(relay_id: relay_id, kind: NostrKind.text.rawValue) let since = get_since_time(last_event: last_text_event) - var since_filter = NostrFilter.filter_text + var since_filter = NostrFilter.filter_kinds([1,5,6]) since_filter.since = since + + let last_like_event = get_last_event_of_kind(relay_id: relay_id, kind: 7) + var like_filter = NostrFilter.filter_kinds([7]) + like_filter.since = get_since_time(last_event: last_like_event) + //like_filter.ids = get_like_pow() let last_metadata_event = get_last_event_of_kind(relay_id: relay_id, kind: NostrKind.metadata.rawValue) var profile_filter = NostrFilter.filter_profiles @@ -355,9 +391,9 @@ struct ContentView: View { var contacts_filter = NostrFilter.filter_contacts contacts_filter.authors = [self.pubkey] - let filters = [since_filter, profile_filter, contacts_filter] + let filters = [since_filter, profile_filter, contacts_filter, like_filter] print("connected to \(relay_id), refreshing from \(since)") - self.pool?.send(.subscribe(.init(filters: filters, sub_id: sub_id)), to: [relay_id]) + self.damus?.pool.send(.subscribe(.init(filters: filters, sub_id: sub_id)), to: [relay_id]) //self.pool?.send(.subscribe(.init(filters: [notification_filter], sub_id: "notifications"))) } @@ -382,32 +418,42 @@ struct ContentView: View { self.friend_events = self.friend_events.sorted { $0.created_at > $1.created_at } } + func handle_text_event(_ ev: NostrEvent) { + if should_hide_event(ev) { + return + } + + self.events.append(ev) + self.events = self.events.sorted { $0.created_at > $1.created_at } + + 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 { - 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 { - if !should_hide_event(ev) { - self.events.append(ev) - self.events = self.events.sorted { $0.created_at > $1.created_at } - - handle_friend_event(ev) - - if is_notification(ev: ev, pubkey: pubkey) { - handle_notification(ev: ev) - } - } - } else if ev.kind == 0 { - handle_metadata_event(ev) - } else if ev.kind == 3 { - handle_contact_event(ev) - - if ev.pubkey == pubkey { - process_friend_events() - } + 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 == 7 { + handle_like_event(ev) + } else if ev.kind == 3 { + handle_contact_event(ev) + + if ev.pubkey == pubkey { + process_friend_events() } } } @@ -436,27 +482,29 @@ struct ContentView: View { case .error(let merr): let desc = merr.debugDescription if desc.contains("Software caused connection abort") { - self.pool?.reconnect(to: [relay_id]) + self.damus?.pool.reconnect(to: [relay_id]) } case .disconnected: fallthrough case .cancelled: - self.pool?.reconnect(to: [relay_id]) + self.damus?.pool.reconnect(to: [relay_id]) case .reconnectSuggested(let t): if t { - self.pool?.reconnect(to: [relay_id]) + self.damus?.pool.reconnect(to: [relay_id]) } default: break } - self.loading = (self.pool?.num_connecting ?? 0) != 0 + self.loading = (self.damus?.pool.num_connecting ?? 0) != 0 print("ws_event \(ev)") case .nostr_event(let ev): switch ev { case .event(let sub_id, let ev): - if sub_id != self.sub_id { + // 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 } @@ -593,3 +641,8 @@ func make_boost_event(_ ev: NostrEvent, privkey: String, pubkey: String) -> Nost boost.sign(privkey: privkey) return boost } + + +func get_like_pow() -> [String] { + return ["00000"] // 20 bits +} diff --git a/damus/Models/ActionBarModel.swift b/damus/Models/ActionBarModel.swift @@ -9,14 +9,21 @@ import Foundation class ActionBarModel: ObservableObject { - @Published var our_like_event: NostrEvent? = nil - @Published var our_boost_event: NostrEvent? = nil + @Published var our_like: NostrEvent? + @Published var our_boost: NostrEvent? + @Published var likes: Int + + init(likes: Int, our_like: NostrEvent?, our_boost: NostrEvent?) { + self.likes = likes + self.our_like = our_like + self.our_boost = our_boost + } var liked: Bool { - return our_like_event != nil + return our_like != nil } var boosted: Bool { - return our_boost_event != nil + return our_boost != nil } } diff --git a/damus/Models/DamusState.swift b/damus/Models/DamusState.swift @@ -0,0 +1,16 @@ +// +// DamusState.swift +// damus +// +// Created by William Casarin on 2022-04-30. +// + +import Foundation + +struct DamusState { + let pool: RelayPool + let pubkey: String + let likes: EventCounter + let boosts: EventCounter + +} diff --git a/damus/Models/LikeCounter.swift b/damus/Models/LikeCounter.swift @@ -0,0 +1,53 @@ +// +// LikeCounter.swift +// damus +// +// Created by William Casarin on 2022-04-30. +// + +import Foundation + + +class EventCounter { + var counts: [String: Int] = [:] + var user_events: [String: Set<String>] = [:] + var our_events: [String: NostrEvent] = [:] + var our_pubkey: String + + enum LikeResult { + case user_already_liked + case success(Int) + } + + init (our_pubkey: String) { + self.our_pubkey = our_pubkey + } + + func add_event(_ ev: NostrEvent) -> LikeResult { + let pubkey = ev.pubkey + + if self.user_events[pubkey] == nil { + self.user_events[pubkey] = Set() + } + + if user_events[pubkey]!.contains(ev.id) { + // don't double count + return .user_already_liked + } + + user_events[pubkey]!.insert(ev.id) + + if ev.pubkey == self.our_pubkey { + our_events[ev.id] = ev + } + + if counts[ev.id] == nil { + counts[ev.id] = 1 + return .success(1) + } + + counts[ev.id]! += 1 + + return .success(counts[ev.id]!) + } +} diff --git a/damus/Models/Liked.swift b/damus/Models/Liked.swift @@ -0,0 +1,19 @@ +// +// Liked.swift +// damus +// +// Created by William Casarin on 2022-04-30. +// + +import Foundation + +struct Liked { + let like: NostrEvent + let id: String + let total: Int +} + +struct LikeRefs { + let thread_id: String? + let like_id: String +} diff --git a/damus/Models/Mentions.swift b/damus/Models/Mentions.swift @@ -0,0 +1,132 @@ +// +// Mentions.swift +// damus +// +// Created by William Casarin on 2022-05-04. +// + +import Foundation + +enum MentionType { + case pubkey + case event +} + +struct Mention { + let index: Int + let kind: MentionType +} + +enum Block { + case text(String) + case mention(Mention) + + var is_text: Bool { + if case .text = self { + return true + } + return false + } + + var is_mention: Bool { + if case .mention = self { + return true + } + return false + } +} + +struct ParsedMentions { + let blocks: [Block] +} + +class Parser { + var pos: Int + var str: String + + init(pos: Int, str: String) { + self.pos = pos + self.str = str + } +} + +func consume_until(_ p: Parser, match: Character) -> Bool { + var i: Int = 0 + let sub = substring(p.str, start: p.pos, end: p.str.count) + for c in sub { + if c == match { + p.pos += i + return true + } + i += 1 + } + + return false +} + +func substring(_ s: String, start: Int, end: Int) -> Substring { + let ind = s.index(s.startIndex, offsetBy: start) + let end = s.index(s.startIndex, offsetBy: end) + return s[ind..<end] +} + +func parse_textblock(str: String, from: Int, to: Int) -> Block { + return .text(String(substring(str, start: from, end: to))) +} + +func parse_mentions(content: String, tags: [[String]]) -> [Block] { + let p = Parser(pos: 0, str: content) + var blocks: [Block] = [] + var starting_from: Int = 0 + + while p.pos < content.count { + if (!consume_until(p, match: "#")) { + blocks.append(parse_textblock(str: p.str, from: starting_from, to: p.str.count)) + return blocks + } + + let pre_mention = p.pos + if let mention = parse_mention(p, tags: tags) { + blocks.append(parse_textblock(str: p.str, from: starting_from, to: pre_mention)) + blocks.append(.mention(mention)) + starting_from = p.pos + } + } + + return blocks +} + +func parse_mention(_ p: Parser, tags: [[String]]) -> Mention? { + let start = p.pos + + if !parse_str(p, "#[") { + return nil + } + + guard let digit = parse_digit(p) else { + p.pos = start + return nil + } + + if !parse_char(p, "]") { + return nil + } + + var kind: MentionType = .pubkey + if digit > tags.count - 1 { + return nil + } + + if tags[digit].count == 0 { + return nil + } + + switch tags[digit][0] { + case "e": kind = .event + case "p": kind = .pubkey + default: return nil + } + + return Mention(index: digit, kind: kind) +} + diff --git a/damus/Models/ParsedRefs.swift b/damus/Models/ParsedRefs.swift @@ -0,0 +1,15 @@ +// +// ParsedRefs.swift +// damus +// +// Created by William Casarin on 2022-04-30. +// + +import Foundation + + +struct ReplyRefs { + let thread_id: String + let direct_reply: String +} + diff --git a/damus/Models/ProfileModel.swift b/damus/Models/ProfileModel.swift @@ -10,34 +10,35 @@ import Foundation class ProfileModel: ObservableObject { @Published var events: [NostrEvent] = [] let pubkey: String - let pool: RelayPool + let damus: DamusState var seen_event: Set<String> = Set() var sub_id = UUID().description - - init(pubkey: String, pool: RelayPool) { + init(pubkey: String, damus: DamusState) { self.pubkey = pubkey - self.pool = pool + self.damus = damus } func unsubscribe() { print("unsubscribing from profile \(pubkey) with sub_id \(sub_id)") - pool.unsubscribe(sub_id: sub_id) + damus.pool.unsubscribe(sub_id: sub_id) } func subscribe() { let kinds: [Int] = [ NostrKind.text.rawValue, NostrKind.delete.rawValue, + NostrKind.contacts.rawValue, + NostrKind.metadata.rawValue, NostrKind.boost.rawValue ] - var filter = NostrFilter.filter_kinds(kinds) - filter.authors = [pubkey] - + var filter = NostrFilter.filter_authors([pubkey]) + filter.kinds = kinds + print("subscribing to profile \(pubkey) with sub_id \(sub_id)") - pool.subscribe(sub_id: sub_id, filters: [filter], handler: handle_event) + damus.pool.subscribe(sub_id: sub_id, filters: [filter], handler: handle_event) } func add_event(_ ev: NostrEvent) { diff --git a/damus/Models/ThreadModel.swift b/damus/Models/ThreadModel.swift @@ -62,14 +62,16 @@ class ThreadModel: ObservableObject { } func subscribe() { - let kinds: [Int] = [1, 5, 6] + let kinds: [Int] = [1, 5, 6, 7] var ref_events = NostrFilter.filter_kinds(kinds) var events_filter = NostrFilter.filter_kinds(kinds) + //var likes_filter = NostrFilter.filter_kinds(7]) // TODO: add referenced relays ref_events.referenced_ids = event.referenced_ids.map { $0.ref_id } ref_events.referenced_ids!.append(event.id) + //likes_filter.ids = ref_events.referenced_ids! events_filter.ids = ref_events.referenced_ids! print("subscribing to thread \(event.id) with sub_id \(sub_id)") @@ -110,7 +112,9 @@ class ThreadModel: ObservableObject { switch res { case .event(let sub_id, let ev): if sub_id == self.sub_id { - add_event(ev) + if ev.known_kind == .text { + add_event(ev) + } } case .notice(let note): diff --git a/damus/Nostr/NostrEvent.swift b/damus/Nostr/NostrEvent.swift @@ -63,15 +63,7 @@ class NostrEvent: Codable, Identifiable, CustomStringConvertible { } private func get_referenced_ids(key: String) -> [ReferencedId] { - return tags.reduce(into: []) { (acc, tag) in - if tag.count >= 2 && tag[0] == key { - var relay_id: String? = nil - if tag.count >= 3 { - relay_id = tag[2] - } - acc.append(ReferencedId(ref_id: tag[1], relay_id: relay_id, key: key)) - } - } + return damus.get_referenced_ids(tags: self.tags, key: key) } public func is_root_event() -> Bool { @@ -107,6 +99,23 @@ class NostrEvent: Codable, Identifiable, CustomStringConvertible { return first } + public func last_refid() -> ReferencedId? { + var mlast: Int? = nil + var i: Int = 0 + for tag in tags { + if tag.count >= 2 && tag[0] == "e" { + mlast = i + } + i += 1 + } + + guard let last = mlast else { + return nil + } + + return tag_to_refid(tags[last]) + } + public func directly_references(_ id: String) -> Bool { // conditions: if it only has 1 e ref // OR it has more than 1 e ref, ignoring the first @@ -152,14 +161,6 @@ class NostrEvent: Codable, Identifiable, CustomStringConvertible { return false } - public func reply_ids() -> [ReferencedId] { - var ids = self.referenced_ids.first.map { [$0] } ?? [] - ids.append(ReferencedId(ref_id: self.id, relay_id: nil, key: "e")) - ids.append(contentsOf: self.referenced_pubkeys) - ids.append(ReferencedId(ref_id: self.pubkey, relay_id: nil, key: "p")) - return ids - } - public var referenced_ids: [ReferencedId] { return get_referenced_ids(key: "e") } @@ -353,3 +354,75 @@ func random_bytes(count: Int) -> Data { } return data } + +func tag_to_refid(_ tag: [String]) -> ReferencedId? { + if tag.count == 0 { + return nil + } + if tag.count == 1 { + return nil + } + + var relay_id: String? = nil + if tag.count > 2 { + relay_id = tag[2] + } + + return ReferencedId(ref_id: tag[1], relay_id: relay_id, key: tag[0]) +} + +func parse_reply_refs(tags: [[String]]) -> ReplyRefs? { + let ids = get_referenced_ids(tags: tags, key: "e") + + if ids.count == 0 { + return nil + } + + let first = ids.first! + let last = ids.last! + + return ReplyRefs(thread_id: first.ref_id, direct_reply: last.ref_id) +} + +func get_referenced_ids(tags: [[String]], key: String) -> [ReferencedId] { + return tags.reduce(into: []) { (acc, tag) in + if tag.count >= 2 && tag[0] == key { + var relay_id: String? = nil + if tag.count >= 3 { + relay_id = tag[2] + } + acc.append(ReferencedId(ref_id: tag[1], relay_id: relay_id, key: key)) + } + } +} + + +func make_like_event(pubkey: String, liked: NostrEvent) -> NostrEvent? { + var tags: [[String]] + + if let refs = parse_reply_refs(tags: liked.tags) { + if refs.thread_id == refs.direct_reply { + tags = [["e", refs.thread_id], ["e", liked.id]] + } else { + tags = [["e", refs.thread_id], ["e", refs.direct_reply], ["e", liked.id]] + } + } else { + // root event + tags = [["e", liked.id]] + } + + + return NostrEvent(content: "", pubkey: pubkey, kind: 7, tags: tags) +} + +func gather_reply_ids(our_pubkey: String, from: NostrEvent) -> [ReferencedId] { + var ids = get_referenced_ids(tags: from.tags, key: "e").first.map { [$0] } ?? [] + + ids.append(ReferencedId(ref_id: from.id, relay_id: nil, key: "e")) + ids.append(contentsOf: from.referenced_pubkeys.filter { $0.ref_id != our_pubkey }) + if from.pubkey != our_pubkey { + ids.append(ReferencedId(ref_id: from.pubkey, relay_id: nil, key: "p")) + } + return ids +} + diff --git a/damus/Nostr/NostrFilter.swift b/damus/Nostr/NostrFilter.swift @@ -37,6 +37,10 @@ struct NostrFilter: Codable { public static var filter_contacts: NostrFilter { return filter_kinds([3]) } + + public static func filter_authors(_ authors: [String]) -> NostrFilter { + return NostrFilter(ids: nil, kinds: nil, referenced_ids: nil, pubkeys: nil, since: nil, until: nil, authors: authors) + } public static func filter_kinds(_ kinds: [Int]) -> NostrFilter { return NostrFilter(ids: nil, kinds: kinds, referenced_ids: nil, pubkeys: nil, since: nil, until: nil, authors: nil) diff --git a/damus/Notifications.swift b/damus/Notifications.swift @@ -38,6 +38,12 @@ extension Notification.Name { } extension Notification.Name { + static var liked: Notification.Name { + return Notification.Name("liked") + } +} + +extension Notification.Name { static var click_profile_pic: Notification.Name { return Notification.Name("click_profile_pic") } diff --git a/damus/Util/Parser.swift b/damus/Util/Parser.swift @@ -0,0 +1,42 @@ +// +// Parser.swift +// damus +// +// Created by William Casarin on 2022-05-04. +// + +import Foundation + +func parse_str(_ p: Parser, _ s: String) -> Bool { + let sub = substring(p.str, start: p.pos, end: p.pos + s.count) + if sub == s { + p.pos += s.count + return true + } + return false +} + +func parse_char(_ p: Parser, _ c: Character) -> Bool{ + let ind = p.str.index(p.str.startIndex, offsetBy: p.pos) + + if p.str[ind] == c { + p.pos += 1 + return true + } + + return false +} + +func parse_digit(_ p: Parser) -> Int? { + let ind = p.str.index(p.str.startIndex, offsetBy: p.pos) + + if let c = p.str[ind].unicodeScalars.first { + let d = Int(c.value) - 48 + if d >= 0 && d < 10 { + p.pos += 1 + return Int(d) + } + } + + return 0 +} diff --git a/damus/Views/ChatView.swift b/damus/Views/ChatView.swift @@ -11,7 +11,10 @@ struct ChatView: View { let event: NostrEvent let prev_ev: NostrEvent? let next_ev: NostrEvent? - + + let likes: EventCounter + let our_pubkey: String + @EnvironmentObject var profiles: Profiles @EnvironmentObject var thread: ThreadModel @@ -130,7 +133,7 @@ struct ChatView: View { .textSelection(.enabled) if is_active || next_ev == nil || next_ev!.pubkey != event.pubkey { - EventActionBar(event: event) + EventActionBar(event: event, our_pubkey: our_pubkey, bar: make_actionbar_model(ev: event, counter: likes)) .environmentObject(profiles) } diff --git a/damus/Views/ChatroomView.swift b/damus/Views/ChatroomView.swift @@ -10,6 +10,8 @@ import SwiftUI struct ChatroomView: View { @EnvironmentObject var thread: ThreadModel @Environment(\.dismiss) var dismiss + let likes: EventCounter + let our_pubkey: String var body: some View { ScrollViewReader { scroller in @@ -19,7 +21,9 @@ struct ChatroomView: View { ForEach(Array(zip(thread.events, thread.events.indices)), id: \.0.id) { (ev, ind) in ChatView(event: thread.events[ind], prev_ev: ind > 0 ? thread.events[ind-1] : nil, - next_ev: ind == count-1 ? nil : thread.events[ind+1] + next_ev: ind == count-1 ? nil : thread.events[ind+1], + likes: likes, + our_pubkey: our_pubkey ) .onTapGesture { if thread.event.id == ev.id { diff --git a/damus/Views/EventActionBar.swift b/damus/Views/EventActionBar.swift @@ -19,9 +19,10 @@ enum ActionBarSheet: Identifiable { struct EventActionBar: View { let event: NostrEvent + let our_pubkey: String @State var sheet: ActionBarSheet? = nil @EnvironmentObject var profiles: Profiles - @StateObject var bar: ActionBarModel = ActionBarModel() + @StateObject var bar: ActionBarModel var body: some View { HStack { @@ -38,25 +39,40 @@ struct EventActionBar: View { } .padding([.trailing], 40) - EventActionButton(img: bar.liked ? "heart.fill" : "heart", col: bar.liked ? Color.red : nil) { - if bar.liked { - notify(.delete, bar.our_like_event) - } else { - notify(.like, event) + HStack(alignment: .bottom) { + Text("\(bar.likes > 0 ? "\(bar.likes)" : "")") + .font(.footnote) + .foregroundColor(Color.gray) + + EventActionButton(img: bar.liked ? "heart.fill" : "heart", col: bar.liked ? Color.red : nil) { + if bar.liked { + notify(.delete, bar.our_like) + } else { + notify(.like, event) + } } } .padding([.trailing], 40) EventActionButton(img: "arrow.2.squarepath", col: bar.boosted ? Color.green : nil) { if bar.boosted { - notify(.delete, bar.our_boost_event) + notify(.delete, bar.our_boost) } else { notify(.boost, event) } } } - .contentShape(Rectangle()) + .onReceive(handle_notify(.liked)) { n in + let liked = n.object as! Liked + if liked.id != event.id { + return + } + self.bar.likes = liked.total + if liked.like.pubkey == our_pubkey { + self.bar.our_like = liked.like + } + } } } @@ -67,6 +83,5 @@ func EventActionButton(img: String, col: Color?, action: @escaping () -> ()) -> .font(.footnote) .foregroundColor(col == nil ? Color.gray : col!) } - .contentShape(Rectangle()) } diff --git a/damus/Views/EventDetailView.swift b/damus/Views/EventDetailView.swift @@ -32,7 +32,7 @@ enum CollapsedEvent: Identifiable { struct EventDetailView: View { let sub_id = UUID().description - let pool: RelayPool + let damus: DamusState @StateObject var thread: ThreadModel @State var collapsed: Bool = true @@ -70,7 +70,7 @@ struct EventDetailView: View { toggle_thread_view() } case .event(let ev, let highlight): - EventView(event: ev, highlight: highlight, has_action_bar: true, pool: pool) + EventView(event: ev, highlight: highlight, has_action_bar: true, damus: damus) .onTapGesture { if thread.event.id == ev.id { toggle_thread_view() diff --git a/damus/Views/EventView.swift b/damus/Views/EventView.swift @@ -40,7 +40,7 @@ struct EventView: View { let event: NostrEvent let highlight: Highlight let has_action_bar: Bool - let pool: RelayPool + let damus: DamusState @EnvironmentObject var profiles: Profiles @EnvironmentObject var action_bar: ActionBarModel @@ -49,7 +49,7 @@ struct EventView: View { let profile = profiles.lookup(id: event.pubkey) HStack { VStack { - let pv = ProfileView(pool: pool, profile: ProfileModel(pubkey: event.pubkey, pool: pool)) + let pv = ProfileView(damus: damus, profile: ProfileModel(pubkey: event.pubkey, damus: damus)) .environmentObject(profiles) NavigationLink(destination: pv) { @@ -81,7 +81,7 @@ struct EventView: View { Spacer() if has_action_bar { - EventActionBar(event: event) + EventActionBar(event: event, our_pubkey: damus.pubkey, bar: make_actionbar_model(ev: event, counter: damus.likes)) .environmentObject(profiles) } @@ -152,3 +152,11 @@ func reply_others_desc(n: Int, n_pubkeys: Int) -> String { } + +func make_actionbar_model(ev: NostrEvent, counter: EventCounter) -> ActionBarModel { + let likes = counter.counts[ev.id] + let our_like = counter.our_events[ev.id] + let our_boost: NostrEvent? = nil + + return ActionBarModel(likes: likes ?? 0, our_like: our_like, our_boost: our_boost) +} diff --git a/damus/Views/PostView.swift b/damus/Views/PostView.swift @@ -24,14 +24,6 @@ struct NostrPost { tag.append(relay_id) } new_ev.tags.append(tag) - // filter our pubkeys - new_ev.tags = new_ev.tags.filter { - if $0[0] == "p" { - return $0[1] != pubkey - } else { - return true - } - } } new_ev.calculate_id() new_ev.sign(privkey: privkey) diff --git a/damus/Views/ProfileView.swift b/damus/Views/ProfileView.swift @@ -13,7 +13,8 @@ enum ProfileTab: Hashable { } struct ProfileView: View { - let pool: RelayPool + let damus: DamusState + @State private var selected_tab: ProfileTab = .posts @StateObject var profile: ProfileModel @@ -54,7 +55,7 @@ struct ProfileView: View { Group { switch(selected_tab) { case .posts: - TimelineView(events: $profile.events, pool: pool) + TimelineView(events: $profile.events, damus: damus) .environmentObject(profiles) case .following: Text("Following") diff --git a/damus/Views/ReplyView.swift b/damus/Views/ReplyView.swift @@ -16,7 +16,7 @@ func all_referenced_pubkeys(_ ev: NostrEvent) -> [ReferencedId] { struct ReplyView: View { let replying_to: NostrEvent - let pool: RelayPool + let damus: DamusState @EnvironmentObject var profiles: Profiles @@ -35,8 +35,8 @@ struct ReplyView: View { .foregroundColor(.gray) .font(.footnote) } - EventView(event: replying_to, highlight: .none, has_action_bar: false, pool: pool) - PostView(references: replying_to.reply_ids()) + EventView(event: replying_to, highlight: .none, has_action_bar: false, damus: damus) + PostView(references: gather_reply_ids(our_pubkey: damus.pubkey, from: replying_to)) Spacer() } diff --git a/damus/Views/ThreadView.swift b/damus/Views/ThreadView.swift @@ -11,7 +11,7 @@ import SwiftUI struct ThreadView: View { @State var is_chatroom: Bool = false @StateObject var thread: ThreadModel - let pool: RelayPool + let damus: DamusState @EnvironmentObject var profiles: Profiles @Environment(\.dismiss) var dismiss @@ -19,12 +19,12 @@ struct ThreadView: View { var body: some View { Group { if is_chatroom { - ChatroomView() + ChatroomView(likes: damus.likes, our_pubkey: damus.pubkey) .navigationBarTitle("Chat") .environmentObject(profiles) .environmentObject(thread) } else { - EventDetailView(pool: pool, thread: thread) + EventDetailView(damus: damus, thread: thread) .navigationBarTitle("Thread") .environmentObject(profiles) .environmentObject(thread) diff --git a/damus/Views/TimelineView.swift b/damus/Views/TimelineView.swift @@ -17,7 +17,7 @@ struct TimelineView: View { @EnvironmentObject var profiles: Profiles - let pool: RelayPool + let damus: DamusState var body: some View { MainContent @@ -37,11 +37,11 @@ struct TimelineView: View { .environmentObject(profiles) */ - let tv = ThreadView(thread: ThreadModel(ev: ev, pool: pool), pool: pool) + let tv = ThreadView(thread: ThreadModel(ev: ev, pool: damus.pool), damus: damus) .environmentObject(profiles) NavigationLink(destination: tv) { - EventView(event: ev, highlight: .none, has_action_bar: true, pool: pool) + EventView(event: ev, highlight: .none, has_action_bar: true, damus: damus) } .isDetailLink(true) .buttonStyle(PlainButtonStyle()) diff --git a/damusTests/damusTests.swift b/damusTests/damusTests.swift @@ -32,5 +32,15 @@ class damusTests: XCTestCase { // Put the code you want to measure the time of here. } } + + func testParseMention() throws { + let parsed = parse_mentions(content: "this is #[0] a mention", tags: [["e", "event_id"]]) + + XCTAssertNotNil(parsed) + XCTAssertEqual(parsed.count, 3) + XCTAssertTrue(parsed[0].is_text) + XCTAssertTrue(parsed[1].is_mention) + XCTAssertTrue(parsed[2].is_text) + } }