damus

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

commit 37b5309dd4c6ea07d5219b0b5ba201e8fd73e952
parent 13b01381d70f3c7f5ff19667fddd9509a1bcce9b
Author: William Casarin <jb55@jb55.com>
Date:   Mon, 11 Apr 2022 09:29:30 -0700

Profiles!

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

Diffstat:
Mdamus.xcodeproj/project.pbxproj | 4++++
Mdamus/ContentView.swift | 139+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------------
Adamus/Info.plist | 11+++++++++++
Mdamus/Nostr.swift | 6+++---
Mdamus/RelayConnection.swift | 49++++++++++++++++++++++++++++++++-----------------
5 files changed, 155 insertions(+), 54 deletions(-)

diff --git a/damus.xcodeproj/project.pbxproj b/damus.xcodeproj/project.pbxproj @@ -40,6 +40,7 @@ /* Begin PBXFileReference section */ 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>"; }; 4CE6DEE327F7A08100C66700 /* damus.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = damus.app; sourceTree = BUILT_PRODUCTS_DIR; }; 4CE6DEE627F7A08100C66700 /* damusApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = damusApp.swift; sourceTree = "<group>"; }; 4CE6DEE827F7A08100C66700 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; }; @@ -110,6 +111,7 @@ 4CE6DEE527F7A08100C66700 /* damus */ = { isa = PBXGroup; children = ( + 4C75EFA72804823E0006080F /* Info.plist */, 4C75EFA227FA576C0006080F /* Views */, 4CE6DEE627F7A08100C66700 /* damusApp.swift */, 4CE6DEE827F7A08100C66700 /* ContentView.swift */, @@ -450,6 +452,7 @@ DEVELOPMENT_TEAM = XK7H4JAB3D; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = damus/Info.plist; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; @@ -479,6 +482,7 @@ DEVELOPMENT_TEAM = XK7H4JAB3D; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = damus/Info.plist; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; diff --git a/damus/ContentView.swift b/damus/ContentView.swift @@ -8,22 +8,47 @@ import SwiftUI import Starscream +let PFP_SIZE: CGFloat? = 64 +let CORNER_RADIUS: CGFloat = 32 + +struct TimestampedProfile { + let profile: Profile + let timestamp: Int64 +} + struct EventView: View { let event: NostrEvent let profile: Profile? var body: some View { - VStack { - Text(String(profile?.name ?? String(event.pubkey.prefix(16)))) - .bold() - .onTapGesture { - UIPasteboard.general.string = event.pubkey + HStack { + if let pic = profile?.picture.flatMap { URL(string: $0) } { + AsyncImage(url: pic) { img in + img.resizable() + } placeholder: { + Color.purple.opacity(0.1) } - .frame(maxWidth: .infinity, alignment: .leading) - Text(event.content) - .textSelection(.enabled) - .frame(maxWidth: .infinity, alignment: .leading) - Divider() + .frame(width: PFP_SIZE, height: PFP_SIZE, alignment: .top) + .cornerRadius(CORNER_RADIUS) + } else { + Color.purple.opacity(0.1) + .frame(width: PFP_SIZE, height: PFP_SIZE, alignment: .top) + .cornerRadius(CORNER_RADIUS) + } + + VStack { + Text(String(profile?.name ?? String(event.pubkey.prefix(16)))) + .bold() + .onTapGesture { + UIPasteboard.general.string = event.pubkey + } + .frame(maxWidth: .infinity, alignment: .leading) + Text(event.content) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + Divider() + } + } } } @@ -39,20 +64,27 @@ enum Sheets: Identifiable { } } +enum NostrKind: Int { + case metadata = 0 + case text = 1 +} + struct ContentView: View { @State var status: String = "Not connected" @State var sub_id: String? = nil @State var active_sheet: Sheets? = nil @State var events: [NostrEvent] = [] - @State var profiles: [String: Profile] = [:] - @State var has_events: [String: Bool] = [:] + @State var profiles: [String: TimestampedProfile] = [:] + @State var has_events: [String: ()] = [:] + @State var profile_count: Int = 0 + @State var last_event_of_kind: [Int: NostrEvent] = [:] @State var loading: Bool = true @State var pool: RelayPool? = nil var MainContent: some View { ScrollView { - ForEach(events.reversed(), id: \.id) { - EventView(event: $0, profile: profiles[$0.pubkey]) + ForEach(events, id: \.id) { + EventView(event: $0, profile: profiles[$0.pubkey]?.profile) } } } @@ -66,7 +98,7 @@ struct ContentView: View { HStack { Spacer() - PostButton { + PostButton() { self.active_sheet = .post } } @@ -102,11 +134,44 @@ struct ContentView: View { } func handle_metadata_event(_ ev: NostrEvent) { + guard let profile: Profile = decode_data(Data(ev.content.utf8)) else { return } - self.profiles[ev.pubkey] = profile + if let mprof = self.profiles[ev.pubkey] { + if mprof.timestamp > ev.created_at { + // skip if we already have an newer profile + return + } + } + + self.profiles[ev.pubkey] = TimestampedProfile(profile: profile, timestamp: ev.created_at) + } + + 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 = last_event_of_kind[NostrKind.text.rawValue] + let since = get_since_time(last_event: last_text_event) + var since_filter = NostrFilter.filter_text + since_filter.since = since + + let last_metadata_event = last_event_of_kind[NostrKind.metadata.rawValue] + var profile_filter = NostrFilter.filter_profiles + if let prof_since = get_metadata_since_time(last_metadata_event) { + profile_filter.since = prof_since + } + + let filters = [since_filter, profile_filter] + print("connected to \(relay_id), refreshing from \(since)") + let sub_id = self.sub_id ?? UUID().description + if self.sub_id != sub_id { + self.sub_id = sub_id + } + print("subscribing to \(sub_id)") + self.pool?.send(filters: filters, sub_id: sub_id) } func handle_event(relay_id: String, conn_event: NostrConnectionEvent) { @@ -114,20 +179,14 @@ struct ContentView: View { case .ws_event(let ev): switch ev { case .connected: - // TODO: since times should be based on events from a specific relay - // perhaps we could mark this in the relay pool somehow - - let since = get_since_time(events: self.events) - let filter = NostrFilter.filter_since(since) - print("connected to \(relay_id), refreshing from \(since)") - let sub_id = self.sub_id ?? UUID().description - if self.sub_id != sub_id { - self.sub_id = sub_id - } - print("subscribing to \(sub_id)") - self.pool?.send(filter: filter, sub_id: sub_id) + send_filters(relay_id: relay_id) + case .disconnected: fallthrough case .cancelled: self.pool?.connect(to: [relay_id]) + case .reconnectSuggested(let t): + if t { + self.pool?.connect(to: [relay_id]) + } default: break } @@ -141,10 +200,15 @@ struct ContentView: View { } self.sub_id = sub_id - if !(has_events[ev.id] ?? false) { - has_events[ev.id] = true + if has_events[ev.id] == nil { + has_events[ev.id] = () + let last_k = last_event_of_kind[ev.kind] + if last_k == nil || ev.created_at > last_k!.created_at { + last_event_of_kind[ev.kind] = ev + } if ev.kind == 1 { self.events.append(ev) + self.events = self.events.sorted { $0.created_at > $1.created_at } } else if ev.kind == 0 { handle_metadata_event(ev) } else if ev.kind == 3 { @@ -182,13 +246,20 @@ func PostButton(action: @escaping () -> ()) -> some View { } +func get_metadata_since_time(_ metadata_event: NostrEvent?) -> Int64? { + if metadata_event == nil { + return nil + } + + return metadata_event!.created_at - 60 * 10 +} -func get_since_time(events: [NostrEvent]) -> Int64 { - if events.count == 0 { - return Int64(Date().timeIntervalSince1970) - (24 * 60 * 60) +func get_since_time(last_event: NostrEvent?) -> Int64 { + if last_event == nil { + return Int64(Date().timeIntervalSince1970) - (24 * 60 * 60 * 3) } - return events.last!.created_at - 60 + return last_event!.created_at - 60 * 10 } /* diff --git a/damus/Info.plist b/damus/Info.plist @@ -0,0 +1,11 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> +<plist version="1.0"> +<dict> + <key>NSAppTransportSecurity</key> + <dict> + <key>NSAllowsArbitraryLoads</key> + <true/> + </dict> +</dict> +</plist> diff --git a/damus/Nostr.swift b/damus/Nostr.swift @@ -9,9 +9,9 @@ import Foundation struct Profile: Decodable { - let name: String - let about: String - let picture: String + let name: String? + let about: String? + let picture: String? } diff --git a/damus/RelayConnection.swift b/damus/RelayConnection.swift @@ -34,13 +34,13 @@ struct NostrSubscription { } struct NostrFilter: Codable { - let ids: [String]? - let kinds: [String]? - let referenced_ids: [String]? - let pubkeys: [String]? - let since: Int64? - let until: Int64? - let authors: [String]? + var ids: [String]? + var kinds: [Int]? + var referenced_ids: [String]? + var pubkeys: [String]? + var since: Int64? + var until: Int64? + var authors: [String]? private enum CodingKeys : String, CodingKey { case ids @@ -52,6 +52,14 @@ struct NostrFilter: Codable { case authors } + public static var filter_text: NostrFilter { + NostrFilter(ids: nil, kinds: [1], referenced_ids: nil, pubkeys: nil, since: nil, until: nil, authors: nil) + } + + public static var filter_profiles: NostrFilter { + return NostrFilter(ids: nil, kinds: [0], referenced_ids: nil, pubkeys: nil, since: nil, until: nil, authors: nil) + } + public static func filter_since(_ val: Int64) -> NostrFilter { return NostrFilter(ids: nil, kinds: nil, referenced_ids: nil, pubkeys: nil, since: val, until: nil, authors: nil) } @@ -151,12 +159,12 @@ class RelayPool { } } - func send(filter: NostrFilter, sub_id: String, to: [String]? = nil) { + func send(filters: [NostrFilter], sub_id: String, to: [String]? = nil) { let relays = to.map{ get_relays($0) } ?? self.relays for relay in relays { if relay.connection.isConnected { - relay.connection.send(filter, sub_id: sub_id) + relay.connection.send(filters, sub_id: sub_id) } } } @@ -211,9 +219,9 @@ class RelayConnection: WebSocketDelegate { socket.disconnect() } - func send(_ filter: NostrFilter, sub_id: String) { - guard let req = make_nostr_req(filter, sub_id: sub_id) else { - print("failed to encode nostr req: \(filter)") + func send(_ filters: [NostrFilter], sub_id: String) { + guard let req = make_nostr_req(filters, sub_id: sub_id) else { + print("failed to encode nostr req: \(filters)") return } socket.write(string: req) @@ -262,12 +270,19 @@ func decode_data<T: Decodable>(_ data: Data) -> T? { return nil } -func make_nostr_req(_ filter: NostrFilter, sub_id: String) -> String? { +func make_nostr_req(_ filters: [NostrFilter], sub_id: String) -> String? { let encoder = JSONEncoder() - guard let filter_json = try? encoder.encode(filter) else { - return nil + var req = "[\"REQ\",\"\(sub_id)\"" + for filter in filters { + req += "," + guard let filter_json = try? encoder.encode(filter) else { + return nil + } + let filter_json_str = String(decoding: filter_json, as: UTF8.self) + req += filter_json_str } - let filter_json_str = String(decoding: filter_json, as: UTF8.self) - return "[\"REQ\",\"\(sub_id)\",\(filter_json_str)]" + req += "]" + print("req: \(req)") + return req }