diff --git a/damus.xcodeproj/project.pbxproj b/damus.xcodeproj/project.pbxproj index ac28f2b2..e1629cdb 100644 --- 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 = ""; }; + 4C75EFA527FF87A20006080F /* Nostr.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Nostr.swift; sourceTree = ""; }; 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 = ""; }; 4CE6DEE827F7A08100C66700 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; @@ -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 = ""; }; 4CE6DF0327F7A08200C66700 /* damusUITestsLaunchTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = damusUITestsLaunchTests.swift; sourceTree = ""; }; - 4CE6DF1327F7A45200C66700 /* WSConnection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WSConnection.swift; sourceTree = ""; }; - 4CE6DF1527F8DEBF00C66700 /* NostrConnection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NostrConnection.swift; sourceTree = ""; }; + 4CE6DF1527F8DEBF00C66700 /* RelayConnection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RelayConnection.swift; sourceTree = ""; }; /* 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 = ""; @@ -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 index e5586ad2..e51a94cb 100644 --- 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_event(conn_event: NostrConnectionEvent) { + 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(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 new file mode 100644 index 00000000..c454ba61 --- /dev/null +++ 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/RelayConnection.swift similarity index 62% rename from damus/NostrConnection.swift rename to damus/RelayConnection.swift index 6d89fc18..511fca20 100644 --- a/damus/NostrConnection.swift +++ b/damus/RelayConnection.swift @@ -36,7 +36,7 @@ struct NostrSubscription { struct NostrFilter: Codable { let ids: [String]? let kinds: [String]? - let event_ids: [String]? + let referenced_ids: [String]? let pubkeys: [String]? let since: Int64? let until: Int64? @@ -45,22 +45,22 @@ struct NostrFilter: Codable { private enum CodingKeys : String, CodingKey { case ids case kinds - case event_ids = "#e" + 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, event_ids: nil, pubkeys: nil, since: val, until: nil, authors: nil) + 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() @@ -82,7 +82,7 @@ enum NostrResponse: Decodable { self = .notice(msg) return } - + throw DecodingError.dataCorrupted(.init(codingPath: [], debugDescription: "expected EVENT or NOTICE, got \(typ)")) } } @@ -97,28 +97,120 @@ struct NostrEvent: Decodable, Identifiable { let sig: String } -class NostrConnection: WebSocketDelegate { +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)") @@ -126,33 +218,33 @@ class NostrConnection: WebSocketDelegate { } 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? {