damus

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

commit 13b01381d70f3c7f5ff19667fddd9509a1bcce9b
parent a32243ab15810b715d215f6e4609487aaf3c9e69
Author: William Casarin <jb55@jb55.com>
Date:   Sat,  9 Apr 2022 08:03:50 -0700

relay pool!

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

Diffstat:
Mdamus.xcodeproj/project.pbxproj | 16++++++++--------
Mdamus/ContentView.swift | 90++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------
Adamus/Nostr.swift | 17+++++++++++++++++
Ddamus/NostrConnection.swift | 181-------------------------------------------------------------------------------
Adamus/RelayConnection.swift | 273+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
5 files changed, 373 insertions(+), 204 deletions(-)

diff --git a/damus.xcodeproj/project.pbxproj b/damus.xcodeproj/project.pbxproj @@ -8,6 +8,7 @@ /* Begin PBXBuildFile section */ 4C75EFA427FA577B0006080F /* PostView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C75EFA327FA577B0006080F /* PostView.swift */; }; + 4C75EFA627FF87A20006080F /* Nostr.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C75EFA527FF87A20006080F /* Nostr.swift */; }; 4CE6DEE727F7A08100C66700 /* damusApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CE6DEE627F7A08100C66700 /* damusApp.swift */; }; 4CE6DEE927F7A08100C66700 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CE6DEE827F7A08100C66700 /* ContentView.swift */; }; 4CE6DEEB27F7A08200C66700 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 4CE6DEEA27F7A08200C66700 /* Assets.xcassets */; }; @@ -16,8 +17,7 @@ 4CE6DF0227F7A08200C66700 /* damusUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CE6DF0127F7A08200C66700 /* damusUITests.swift */; }; 4CE6DF0427F7A08200C66700 /* damusUITestsLaunchTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CE6DF0327F7A08200C66700 /* damusUITestsLaunchTests.swift */; }; 4CE6DF1227F7A2B300C66700 /* Starscream in Frameworks */ = {isa = PBXBuildFile; productRef = 4CE6DF1127F7A2B300C66700 /* Starscream */; }; - 4CE6DF1427F7A45200C66700 /* WSConnection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CE6DF1327F7A45200C66700 /* WSConnection.swift */; }; - 4CE6DF1627F8DEBF00C66700 /* NostrConnection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CE6DF1527F8DEBF00C66700 /* NostrConnection.swift */; }; + 4CE6DF1627F8DEBF00C66700 /* RelayConnection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CE6DF1527F8DEBF00C66700 /* RelayConnection.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -39,6 +39,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>"; }; 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>"; }; @@ -49,8 +50,7 @@ 4CE6DEFD27F7A08200C66700 /* damusUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = damusUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 4CE6DF0127F7A08200C66700 /* damusUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = damusUITests.swift; sourceTree = "<group>"; }; 4CE6DF0327F7A08200C66700 /* damusUITestsLaunchTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = damusUITestsLaunchTests.swift; sourceTree = "<group>"; }; - 4CE6DF1327F7A45200C66700 /* WSConnection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WSConnection.swift; sourceTree = "<group>"; }; - 4CE6DF1527F8DEBF00C66700 /* NostrConnection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NostrConnection.swift; sourceTree = "<group>"; }; + 4CE6DF1527F8DEBF00C66700 /* RelayConnection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RelayConnection.swift; sourceTree = "<group>"; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -115,8 +115,8 @@ 4CE6DEE827F7A08100C66700 /* ContentView.swift */, 4CE6DEEA27F7A08200C66700 /* Assets.xcassets */, 4CE6DEEC27F7A08200C66700 /* Preview Content */, - 4CE6DF1327F7A45200C66700 /* WSConnection.swift */, - 4CE6DF1527F8DEBF00C66700 /* NostrConnection.swift */, + 4CE6DF1527F8DEBF00C66700 /* RelayConnection.swift */, + 4C75EFA527FF87A20006080F /* Nostr.swift */, ); path = damus; sourceTree = "<group>"; @@ -283,8 +283,8 @@ buildActionMask = 2147483647; files = ( 4CE6DEE927F7A08100C66700 /* ContentView.swift in Sources */, - 4CE6DF1427F7A45200C66700 /* WSConnection.swift in Sources */, - 4CE6DF1627F8DEBF00C66700 /* NostrConnection.swift in Sources */, + 4C75EFA627FF87A20006080F /* Nostr.swift in Sources */, + 4CE6DF1627F8DEBF00C66700 /* RelayConnection.swift in Sources */, 4CE6DEE727F7A08100C66700 /* damusApp.swift in Sources */, 4C75EFA427FA577B0006080F /* PostView.swift in Sources */, ); diff --git a/damus/ContentView.swift b/damus/ContentView.swift @@ -10,10 +10,11 @@ import Starscream struct EventView: View { let event: NostrEvent + let profile: Profile? var body: some View { VStack { - Text(String(event.pubkey.prefix(16))) + Text(String(profile?.name ?? String(event.pubkey.prefix(16)))) .bold() .onTapGesture { UIPasteboard.general.string = event.pubkey @@ -43,14 +44,15 @@ struct ContentView: View { @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 loading: Bool = true - @State var connection: NostrConnection? = nil + @State var pool: RelayPool? = nil var MainContent: some View { ScrollView { ForEach(events.reversed(), id: \.id) { - EventView(event: $0) + EventView(event: $0, profile: profiles[$0.pubkey]) } } } @@ -86,28 +88,46 @@ struct ContentView: View { } func connect() { - let url = URL(string: "wss://nostr.bitcoiner.social")! - let conn = NostrConnection(url: url, handleEvent: handle_event) - conn.connect() - self.connection = conn + let pool = RelayPool(handle_event: handle_event) + + add_rw_relay(pool, "wss://nostr-pub.wellorder.net") + add_rw_relay(pool, "wss://nostr-relay.wlvs.space") + add_rw_relay(pool, "wss://nostr.bitcoiner.social") + + self.pool = pool + pool.connect() + } + + func handle_contact_event(_ ev: NostrEvent) { + } + + func handle_metadata_event(_ ev: NostrEvent) { + guard let profile: Profile = decode_data(Data(ev.content.utf8)) else { + return + } + + self.profiles[ev.pubkey] = profile } - func handle_event(conn_event: NostrConnectionEvent) { + func handle_event(relay_id: String, conn_event: NostrConnectionEvent) { switch conn_event { case .ws_event(let ev): switch ev { case .connected: - let now = Int64(Date().timeIntervalSince1970) - let yesterday = now - 24 * 60 * 60 - let filter = NostrFilter.filter_since(yesterday) + // 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.connection?.send(filter, sub_id: sub_id) + self.pool?.send(filter: filter, sub_id: sub_id) case .cancelled: - self.connection?.connect() + self.pool?.connect(to: [relay_id]) default: break } @@ -120,9 +140,16 @@ struct ContentView: View { self.loading = false } self.sub_id = sub_id - if ev.kind == 1 && !(has_events[ev.id] ?? false) { + + if !(has_events[ev.id] ?? false) { has_events[ev.id] = true - self.events.append(ev) + if ev.kind == 1 { + self.events.append(ev) + } else if ev.kind == 0 { + handle_metadata_event(ev) + } else if ev.kind == 3 { + handle_contact_event(ev) + } } case .notice(let msg): print(msg) @@ -154,3 +181,36 @@ func PostButton(action: @escaping () -> ()) -> some View { y: 3) } + + +func get_since_time(events: [NostrEvent]) -> Int64 { + if events.count == 0 { + return Int64(Date().timeIntervalSince1970) - (24 * 60 * 60) + } + + return events.last!.created_at - 60 +} + +/* +func fetch_profiles(relay: URL, pubkeys: [String]) { + return NostrFilter(ids: nil, kinds: 3, event_ids: nil, pubkeys: pubkeys, since: nil, until: nil, authors: pubkeys) +} + + +func nostr_req(relays: [URL], filter: NostrFilter) { + if relays.count == 0 { + return + } + let conn = NostrConnection(url: relay) { + } +} + + +func get_profiles() + +*/ + +func add_rw_relay(_ pool: RelayPool, _ url: String) { + let url_ = URL(string: url)! + try! pool.add_relay(url_, info: RelayInfo.rw) +} diff --git a/damus/Nostr.swift b/damus/Nostr.swift @@ -0,0 +1,17 @@ +// +// Nostr.swift +// damus +// +// Created by William Casarin on 2022-04-07. +// + +import Foundation + + +struct Profile: Decodable { + let name: String + let about: String + let picture: String +} + + diff --git a/damus/NostrConnection.swift b/damus/NostrConnection.swift @@ -1,181 +0,0 @@ -// -// NostrConnection.swift -// damus -// -// Created by William Casarin on 2022-04-02. -// - -import Foundation -import Starscream - -struct OtherEvent { - let event_id: String - let relay_url: String -} - -struct KeyEvent { - let key: String - let relay_url: String -} - -enum NostrConnectionEvent { - case ws_event(WebSocketEvent) - case nostr_event(NostrResponse) -} - -enum NostrTag { - case other_event(OtherEvent) - case key_event(KeyEvent) -} - -struct NostrSubscription { - let sub_id: String - let filter: NostrFilter -} - -struct NostrFilter: Codable { - let ids: [String]? - let kinds: [String]? - let event_ids: [String]? - let pubkeys: [String]? - let since: Int64? - let until: Int64? - let authors: [String]? - - private enum CodingKeys : String, CodingKey { - case ids - case kinds - case event_ids = "#e" - case pubkeys = "#p" - case since - case until - case authors - } - - public static func filter_since(_ val: Int64) -> NostrFilter { - return NostrFilter(ids: nil, kinds: nil, event_ids: nil, pubkeys: nil, since: val, until: nil, authors: nil) - } -} - -enum NostrResponse: Decodable { - case event(String, NostrEvent) - case notice(String) - - init(from decoder: Decoder) throws { - var container = try decoder.unkeyedContainer() - - // Only use first item - let typ = try container.decode(String.self) - if typ == "EVENT" { - let sub_id = try container.decode(String.self) - var ev: NostrEvent - do { - ev = try container.decode(NostrEvent.self) - } catch { - print(error) - throw error - } - self = .event(sub_id, ev) - return - } else if typ == "NOTICE" { - let msg = try container.decode(String.self) - self = .notice(msg) - return - } - - throw DecodingError.dataCorrupted(.init(codingPath: [], debugDescription: "expected EVENT or NOTICE, got \(typ)")) - } -} - -struct NostrEvent: Decodable, Identifiable { - let id: String - let pubkey: String - let created_at: Int64 - let kind: Int - let tags: [[String]] - let content: String - let sig: String -} - -class NostrConnection: WebSocketDelegate { - var isConnected: Bool = false - var socket: WebSocket - var handleEvent: (NostrConnectionEvent) -> () - - init(url: URL, handleEvent: @escaping (NostrConnectionEvent) -> ()) { - var req = URLRequest(url: url) - req.timeoutInterval = 5 - self.socket = WebSocket(request: req) - self.handleEvent = handleEvent - - socket.delegate = self - } - - func connect(){ - socket.connect() - } - - func disconnect() { - 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)") - return - } - socket.write(string: req) - } - - func didReceive(event: WebSocketEvent, client: WebSocket) { - switch event { - case .connected: - self.isConnected = true - - case .disconnected: fallthrough - case .cancelled: fallthrough - case .error: - self.isConnected = false - - case .text(let txt): - if let ev = decode_nostr_event(txt: txt) { - handleEvent(.nostr_event(ev)) - return - } - - print("decode failed for \(txt)") - // TODO: trigger event error - - default: - break - } - - handleEvent(.ws_event(event)) - } - -} - -func decode_nostr_event(txt: String) -> NostrResponse? { - return decode_data(Data(txt.utf8)) -} - -func decode_data<T: Decodable>(_ data: Data) -> T? { - let decoder = JSONDecoder() - do { - return try decoder.decode(T.self, from: data) - } catch { - print("decode_data failed for \(T.self): \(error)") - } - - return nil -} - -func make_nostr_req(_ filter: NostrFilter, sub_id: String) -> String? { - let encoder = JSONEncoder() - guard let filter_json = try? encoder.encode(filter) else { - return nil - } - let filter_json_str = String(decoding: filter_json, as: UTF8.self) - return "[\"REQ\",\"\(sub_id)\",\(filter_json_str)]" -} - diff --git a/damus/RelayConnection.swift b/damus/RelayConnection.swift @@ -0,0 +1,273 @@ +// +// NostrConnection.swift +// damus +// +// Created by William Casarin on 2022-04-02. +// + +import Foundation +import Starscream + +struct OtherEvent { + let event_id: String + let relay_url: String +} + +struct KeyEvent { + let key: String + let relay_url: String +} + +enum NostrConnectionEvent { + case ws_event(WebSocketEvent) + case nostr_event(NostrResponse) +} + +enum NostrTag { + case other_event(OtherEvent) + case key_event(KeyEvent) +} + +struct NostrSubscription { + let sub_id: String + let filter: NostrFilter +} + +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]? + + private enum CodingKeys : String, CodingKey { + case ids + case kinds + case referenced_ids = "#e" + case pubkeys = "#p" + case since + case until + case authors + } + + 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) + } +} + +enum NostrResponse: Decodable { + case event(String, NostrEvent) + case notice(String) + + init(from decoder: Decoder) throws { + var container = try decoder.unkeyedContainer() + + // Only use first item + let typ = try container.decode(String.self) + if typ == "EVENT" { + let sub_id = try container.decode(String.self) + var ev: NostrEvent + do { + ev = try container.decode(NostrEvent.self) + } catch { + print(error) + throw error + } + self = .event(sub_id, ev) + return + } else if typ == "NOTICE" { + let msg = try container.decode(String.self) + self = .notice(msg) + return + } + + throw DecodingError.dataCorrupted(.init(codingPath: [], debugDescription: "expected EVENT or NOTICE, got \(typ)")) + } +} + +struct NostrEvent: Decodable, Identifiable { + let id: String + let pubkey: String + let created_at: Int64 + let kind: Int + let tags: [[String]] + let content: String + let sig: String +} + +struct RelayInfo { + let read: Bool + let write: Bool + + static let rw = RelayInfo(read: true, write: true) +} + +struct Relay: Identifiable { + let url: URL + let info: RelayInfo + let connection: RelayConnection + + var id: String { + return get_relay_id(url) + } + +} + +func get_relay_id(_ url: URL) -> String { + return url.absoluteString +} + +enum RelayError: Error { + case RelayAlreadyExists + case RelayNotFound +} + +class RelayPool { + var relays: [Relay] = [] + let custom_handle_event: (String, NostrConnectionEvent) -> () + + init(handle_event: @escaping (String, NostrConnectionEvent) -> ()) { + self.custom_handle_event = handle_event + } + + func add_relay(_ url: URL, info: RelayInfo) throws { + let relay_id = get_relay_id(url) + if get_relay(relay_id) != nil { + throw RelayError.RelayAlreadyExists + } + let conn = RelayConnection(url: url) { event in + self.handle_event(relay_id: relay_id, event: event) + } + let relay = Relay(url: url, info: info, connection: conn) + self.relays.append(relay) + } + + func connect(to: [String]? = nil) { + let relays = to.map{ get_relays($0) } ?? self.relays + for relay in relays { + relay.connection.connect() + } + } + + func send(filter: 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) + } + } + } + + func get_relays(_ ids: [String]) -> [Relay] { + var relays: [Relay] = [] + + for id in ids { + if let relay = get_relay(id) { + relays.append(relay) + } + } + + return relays + } + + func get_relay(_ id: String) -> Relay? { + for relay in relays { + if relay.id == id { + return relay + } + } + + return nil + } + + func handle_event(relay_id: String, event: NostrConnectionEvent) { + // handle reconnect logic, etc? + custom_handle_event(relay_id, event) + } +} + +class RelayConnection: WebSocketDelegate { + var isConnected: Bool = false + var socket: WebSocket + var handleEvent: (NostrConnectionEvent) -> () + + init(url: URL, handleEvent: @escaping (NostrConnectionEvent) -> ()) { + var req = URLRequest(url: url) + req.timeoutInterval = 5 + self.socket = WebSocket(request: req) + self.handleEvent = handleEvent + + socket.delegate = self + } + + func connect(){ + socket.connect() + } + + func disconnect() { + 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)") + return + } + socket.write(string: req) + } + + func didReceive(event: WebSocketEvent, client: WebSocket) { + switch event { + case .connected: + self.isConnected = true + + case .disconnected: fallthrough + case .cancelled: fallthrough + case .error: + self.isConnected = false + + case .text(let txt): + if let ev = decode_nostr_event(txt: txt) { + handleEvent(.nostr_event(ev)) + return + } + + print("decode failed for \(txt)") + // TODO: trigger event error + + default: + break + } + + handleEvent(.ws_event(event)) + } + +} + +func decode_nostr_event(txt: String) -> NostrResponse? { + return decode_data(Data(txt.utf8)) +} + +func decode_data<T: Decodable>(_ data: Data) -> T? { + let decoder = JSONDecoder() + do { + return try decoder.decode(T.self, from: data) + } catch { + print("decode_data failed for \(T.self): \(error)") + } + + return nil +} + +func make_nostr_req(_ filter: NostrFilter, sub_id: String) -> String? { + let encoder = JSONEncoder() + guard let filter_json = try? encoder.encode(filter) else { + return nil + } + let filter_json_str = String(decoding: filter_json, as: UTF8.self) + return "[\"REQ\",\"\(sub_id)\",\(filter_json_str)]" +} +