damus

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

commit 7554a87d88d4aa3a576f8d9061c0f0bb6512f7f6
parent 8da251dc88f9846fbce302672435a1c59dfca593
Author: William Casarin <jb55@jb55.com>
Date:   Sun, 15 May 2022 11:08:36 -0700

following and unfollowing

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

Diffstat:
Mdamus.xcodeproj/project.pbxproj | 4++++
Mdamus/ContentView.swift | 55++++++++++++++++++++++++++++++++++++++++++++-----------
Adamus/Models/Contacts.swift | 122+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mdamus/Models/DamusState.swift | 1+
Mdamus/Models/ProfileModel.swift | 2++
Mdamus/Nostr/NostrEvent.swift | 5+++++
Mdamus/Nostr/Relay.swift | 2+-
Mdamus/Notifications.swift | 24++++++++++++++++++++++++
Mdamus/Views/EventView.swift | 6+++++-
Mdamus/Views/ProfileView.swift | 71+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
10 files changed, 277 insertions(+), 15 deletions(-)

diff --git a/damus.xcodeproj/project.pbxproj b/damus.xcodeproj/project.pbxproj @@ -31,6 +31,7 @@ 4C363AA228296A7E006E126D /* SearchView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C363AA128296A7E006E126D /* SearchView.swift */; }; 4C363AA428296DEE006E126D /* SearchModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C363AA328296DEE006E126D /* SearchModel.swift */; }; 4C363AA828297703006E126D /* InsertSort.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C363AA728297703006E126D /* InsertSort.swift */; }; + 4C3AC79B28306D7B00E1F516 /* Contacts.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C3AC79A28306D7B00E1F516 /* Contacts.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 */; }; @@ -115,6 +116,7 @@ 4C363AA128296A7E006E126D /* SearchView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchView.swift; sourceTree = "<group>"; }; 4C363AA328296DEE006E126D /* SearchModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchModel.swift; sourceTree = "<group>"; }; 4C363AA728297703006E126D /* InsertSort.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InsertSort.swift; sourceTree = "<group>"; }; + 4C3AC79A28306D7B00E1F516 /* Contacts.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Contacts.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>"; }; @@ -204,6 +206,7 @@ 4C363A9928283854006E126D /* Reply.swift */, 4C363A9B282838B9006E126D /* EventRef.swift */, 4C363AA328296DEE006E126D /* SearchModel.swift */, + 4C3AC79A28306D7B00E1F516 /* Contacts.swift */, ); path = Models; sourceTree = "<group>"; @@ -526,6 +529,7 @@ 4C363A922825FCF2006E126D /* ProfileUpdate.swift in Sources */, 4C0A3F95280F6C78000448DE /* ReplyQuoteView.swift in Sources */, 4C3BEFDA281DCA1400B3DE84 /* LikeCounter.swift in Sources */, + 4C3AC79B28306D7B00E1F516 /* Contacts.swift in Sources */, 4C363A8E28236FE4006E126D /* NoteContentView.swift in Sources */, 4CACA9D5280C31E100D9BBE8 /* ReplyView.swift in Sources */, 4C0A3F97280F8E02000448DE /* ThreadView.swift in Sources */, diff --git a/damus/ContentView.swift b/damus/ContentView.swift @@ -43,7 +43,6 @@ enum Timeline: String, CustomStringConvertible { struct ContentView: View { @State var status: String = "Not connected" @State var active_sheet: Sheets? = nil - @State var friends: [String: ()] = [:] @State var loading: Bool = true @State var damus: DamusState? = nil @State var selected_timeline: Timeline? = .home @@ -209,7 +208,8 @@ struct ContentView: View { Group { if let pk = self.active_profile { let profile_model = ProfileModel(pubkey: pk, damus: damus!) - ProfileView(damus: damus!, profile: profile_model) + let fs = damus!.contacts.follow_state(pk) + ProfileView(damus: damus!, follow_state: fs, profile: profile_model) } else { EmptyView() } @@ -290,6 +290,37 @@ struct ContentView: View { let ev = obj.object as! NostrEvent self.damus?.pool.send(.event(ev)) } + .onReceive(handle_notify(.unfollow)) { notif in + let pk = notif.object as! String + guard let damus = self.damus else { + return + } + + if unfollow_user(pool: damus.pool, + our_contacts: damus.contacts.event, + pubkey: damus.pubkey, + privkey: privkey, + unfollow: pk) { + notify(.unfollowed, pk) + damus.contacts.friends.remove(pk) + //friend_events = friend_events.filter { $0.pubkey != pk } + } + } + .onReceive(handle_notify(.follow)) { notif in + let pk = notif.object as! String + guard let damus = self.damus else { + return + } + + if follow_user(pool: damus.pool, + 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) + } + } .onReceive(handle_notify(.post)) { obj in let post_res = obj.object as! NostrPostResult switch post_res { @@ -308,12 +339,13 @@ struct ContentView: View { } } - func is_friend(pubkey: String) -> Bool { - return pubkey == self.pubkey || friends[pubkey] != nil - } - func is_friend_event(_ ev: NostrEvent) -> Bool { - if is_friend(pubkey: ev.pubkey) { + // we should be able to see our own messages in our homefeed + if ev.pubkey == self.pubkey { + return true + } + + if damus!.contacts.is_friend(ev.pubkey) { return true } @@ -323,7 +355,7 @@ struct ContentView: View { return true } for pk in ev.referenced_pubkeys { - if is_friend(pubkey: pk.ref_id) { + if damus!.contacts.is_friend(pk.ref_id) { return true } } @@ -366,13 +398,14 @@ struct ContentView: View { add_relay(pool, "wss://nostr.bitcoiner.social") add_relay(pool, "ws://monad.jb55.com:8080") add_relay(pool, "wss://nostr-relay.freeberty.net") - add_relay(pool, "wss://nostr-relay.untethr.me") + //add_relay(pool, "wss://nostr-relay.untethr.me") pool.register_handler(sub_id: sub_id, handler: handle_event) self.damus = DamusState(pool: pool, pubkey: pubkey, likes: EventCounter(our_pubkey: pubkey), boosts: EventCounter(our_pubkey: pubkey), + contacts: Contacts(), tips: TipCounter(our_pubkey: pubkey), image_cache: ImageCache(), profiles: Profiles() @@ -382,10 +415,11 @@ struct ContentView: View { func handle_contact_event(_ ev: NostrEvent) { if ev.pubkey == self.pubkey { + damus!.contacts.event = ev // our contacts for tag in ev.tags { if tag.count > 1 && tag[0] == "p" { - self.friends[tag[1]] = () + damus!.contacts.friends.insert(tag[1]) } } } @@ -728,7 +762,6 @@ func get_like_pow() -> [String] { func update_filters_with_since(last_of_kind: [Int: NostrEvent], filters: [NostrFilter]) -> [NostrFilter] { - let now = Int64(Date.now.timeIntervalSince1970) return filters.map { filter in let kinds = filter.kinds ?? [] diff --git a/damus/Models/Contacts.swift b/damus/Models/Contacts.swift @@ -0,0 +1,122 @@ +// +// Contacts.swift +// damus +// +// Created by William Casarin on 2022-05-14. +// + +import Foundation + + +class Contacts { + var friends: Set<String> = Set() + var event: NostrEvent? + + func is_friend(_ pubkey: String) -> Bool { + return friends.contains(pubkey) + } + + func follow_state(_ pubkey: String) -> FollowState { + return is_friend(pubkey) ? .follows : .unfollows + } +} + + +func create_contacts(relays: [RelayDescriptor], our_pubkey: String, follow: ReferencedId) -> NostrEvent { + let kind = NostrKind.contacts.rawValue + let content = create_contacts_content(relays) ?? "{}" + let tags = [refid_to_tag(follow)] + return NostrEvent(content: content, pubkey: our_pubkey, kind: kind, tags: tags) +} + +func create_contacts_content(_ relays: [RelayDescriptor]) -> String? { + // TODO: just create a new one of this is corrupted? + let crelays = make_contact_relays(relays) + guard let encoded = encode_json(crelays) else { + return nil + } + return encoded +} + + +func follow_user(pool: RelayPool, our_contacts: NostrEvent?, pubkey: String, privkey: String, follow: ReferencedId) -> Bool { + guard let ev = follow_user_event(our_contacts: our_contacts, our_pubkey: pubkey, follow: follow) else { + return false + } + + ev.calculate_id() + ev.sign(privkey: privkey) + + pool.send(.event(ev)) + + return true +} + +func unfollow_user(pool: RelayPool, our_contacts: NostrEvent?, pubkey: String, privkey: String, unfollow: String) -> Bool { + guard let cs = our_contacts else { + return false + } + + let ev = unfollow_user_event(our_contacts: cs, our_pubkey: pubkey, unfollow: unfollow) + ev.calculate_id() + ev.sign(privkey: privkey) + + pool.send(.event(ev)) + + return true +} + +func unfollow_user_event(our_contacts: NostrEvent, our_pubkey: String, unfollow: String) -> NostrEvent { + let tags = our_contacts.tags.filter { tag in + if tag.count >= 2 && tag[0] == "p" && tag[1] == unfollow { + return false + } + return true + } + + let kind = NostrKind.contacts.rawValue + return NostrEvent(content: our_contacts.content, pubkey: our_pubkey, kind: kind, tags: tags) +} + +func follow_user_event(our_contacts: NostrEvent?, our_pubkey: String, follow: ReferencedId) -> NostrEvent? { + guard let cs = our_contacts else { + // don't create contacts for now so we don't nuke our contact list due to connectivity issues + // we should only create contacts during profile creation + //return create_contacts(relays: relays, our_pubkey: our_pubkey, follow: follow) + return nil + } + + guard let ev = follow_with_existing_contacts(our_pubkey: our_pubkey, our_contacts: cs, follow: follow) else { + return nil + } + + return ev +} + +/* +func ensure_relay_info(relays: [RelayDescriptor], content: String) -> [String: RelayInfo] { + guard let relay_info = decode_json_relays(content) else { + return make_contact_relays(relays) + } + return relay_info +} + */ + +func follow_with_existing_contacts(our_pubkey: String, our_contacts: NostrEvent, follow: ReferencedId) -> NostrEvent? { + // don't update if we're already following + if our_contacts.references(id: follow.ref_id, key: "p") { + return nil + } + + let kind = NostrKind.contacts.rawValue + var tags = our_contacts.tags + tags.append(refid_to_tag(follow)) + return NostrEvent(content: our_contacts.content, pubkey: our_pubkey, kind: kind, tags: tags) +} + +func make_contact_relays(_ relays: [RelayDescriptor]) -> [String: RelayInfo] { + return relays.reduce(into: [:]) { acc, relay in + acc[relay.url.absoluteString] = relay.info + } +} + diff --git a/damus/Models/DamusState.swift b/damus/Models/DamusState.swift @@ -12,6 +12,7 @@ struct DamusState { let pubkey: String let likes: EventCounter let boosts: EventCounter + let contacts: Contacts let tips: TipCounter let image_cache: ImageCache let profiles: Profiles diff --git a/damus/Models/ProfileModel.swift b/damus/Models/ProfileModel.swift @@ -12,12 +12,14 @@ class ProfileModel: ObservableObject { let pubkey: String let damus: DamusState + @Published var following: Bool var seen_event: Set<String> = Set() var sub_id = UUID().description init(pubkey: String, damus: DamusState) { self.pubkey = pubkey self.damus = damus + self.following = damus.contacts.is_friend(pubkey) } func unsubscribe() { diff --git a/damus/Nostr/NostrEvent.swift b/damus/Nostr/NostrEvent.swift @@ -227,6 +227,11 @@ func decode_nostr_event(txt: String) -> NostrResponse? { return decode_data(Data(txt.utf8)) } +func encode_json<T: Encodable>(_ val: T) -> String? { + let encoder = JSONEncoder() + return (try? encoder.encode(val)).map { String(decoding: $0, as: UTF8.self) } +} + func decode_data<T: Decodable>(_ data: Data) -> T? { let decoder = JSONDecoder() do { diff --git a/damus/Nostr/Relay.swift b/damus/Nostr/Relay.swift @@ -7,7 +7,7 @@ import Foundation -struct RelayInfo { +struct RelayInfo: Codable { let read: Bool let write: Bool diff --git a/damus/Notifications.swift b/damus/Notifications.swift @@ -109,6 +109,30 @@ extension Notification.Name { } } +extension Notification.Name { + static var follow: Notification.Name { + return Notification.Name("follow") + } +} + +extension Notification.Name { + static var unfollow: Notification.Name { + return Notification.Name("unfollow") + } +} + +extension Notification.Name { + static var followed: Notification.Name { + return Notification.Name("followed") + } +} + +extension Notification.Name { + static var unfollowed: Notification.Name { + return Notification.Name("unfollowed") + } +} + func handle_notify(_ name: Notification.Name) -> NotificationCenter.Publisher { return NotificationCenter.default.publisher(for: name) } diff --git a/damus/Views/EventView.swift b/damus/Views/EventView.swift @@ -68,7 +68,9 @@ struct EventView: View { return HStack { let profile = damus.profiles.lookup(id: event.pubkey) VStack { - let pv = ProfileView(damus: damus, profile: ProfileModel(pubkey: event.pubkey, damus: damus)) + let pmodel = ProfileModel(pubkey: event.pubkey, damus: damus) + let fs = damus.contacts.follow_state(event.pubkey) + let pv = ProfileView(damus: damus, follow_state: fs, profile: pmodel) NavigationLink(destination: pv) { ProfilePicView(pubkey: event.pubkey, size: PFP_SIZE!, highlight: highlight, image_cache: damus.image_cache, profiles: damus.profiles) @@ -196,3 +198,5 @@ func make_actionbar_model(ev: NostrEvent, damus: DamusState) -> ActionBarModel { our_tip: our_tip ) } + + diff --git a/damus/Views/ProfileView.swift b/damus/Views/ProfileView.swift @@ -12,9 +12,58 @@ enum ProfileTab: Hashable { case following } +enum FollowState { + case follows + case following + case unfollowing + case unfollows +} + +func follow_btn_txt(_ fs: FollowState) -> String { + switch fs { + case .follows: + return "Unfollow" + case .following: + return "Following..." + case .unfollowing: + return "Unfollowing..." + case .unfollows: + return "Follow" + } +} + +func follow_btn_enabled_state(_ fs: FollowState) -> Bool { + switch fs { + case .follows: + return true + case .following: + return false + case .unfollowing: + return false + case .unfollows: + return true + } +} + +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: DamusState + @State var follow_state: FollowState = .follows @State private var selected_tab: ProfileTab = .posts @StateObject var profile: ProfileModel @@ -28,8 +77,25 @@ struct ProfileView: View { Spacer() - Button("Follow") { - print("follow \(profile.pubkey)") + Button("\(follow_btn_txt(follow_state))") { + follow_state = perform_follow_btn_action(follow_state, target: profile.pubkey) + } + .buttonStyle(.bordered) + .onReceive(handle_notify(.followed)) { notif in + let pk = notif.object as! String + if pk != profile.pubkey { + return + } + + self.follow_state = .follows + } + .onReceive(handle_notify(.unfollowed)) { notif in + let pk = notif.object as! String + if pk != profile.pubkey { + return + } + + self.follow_state = .unfollows } } @@ -62,6 +128,7 @@ struct ProfileView: View { .navigationBarTitle("Profile") .onAppear() { + follow_state = damus.contacts.follow_state(profile.pubkey) profile.subscribe() } .onDisappear {