damus

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

commit ffc75772f949376a7dd24491ba320c96bb2e8bab
parent 5b3fac70ede4d940c587a575e0f4869f9322159a
Author: Daniel D’Aquino <daniel@daquino.me>
Date:   Mon, 24 Mar 2025 16:26:35 -0300

NIP-65 relay list models and definitions

This commit adds the base models needed for the NIP-65 relay list support.

This introduces no user-facing changes.

Changelog-None
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>

Diffstat:
Mdamus.xcodeproj/project.pbxproj | 16++++++++++++++++
Mdamus/Models/HomeModel.swift | 2++
Mdamus/Models/UserSettingsStore.swift | 4++++
Adamus/NIP65/NIP65.swift | 167+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mdamus/Nostr/Id.swift | 13+++++++++++++
Mdamus/Nostr/NostrKind.swift | 1+
Mdamus/Nostr/Relay.swift | 32++++++++++++++++++++++++++++++++
Mdamus/Views/LoadableNostrEventView.swift | 2+-
8 files changed, 236 insertions(+), 1 deletion(-)

diff --git a/damus.xcodeproj/project.pbxproj b/damus.xcodeproj/project.pbxproj @@ -1649,6 +1649,9 @@ D7DB93052D66A44100DA1EE5 /* Undistractor.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7DB93042D66A43B00DA1EE5 /* Undistractor.swift */; }; D7DB93062D66A44100DA1EE5 /* Undistractor.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7DB93042D66A43B00DA1EE5 /* Undistractor.swift */; }; D7DB93072D66A44100DA1EE5 /* Undistractor.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7DB93042D66A43B00DA1EE5 /* Undistractor.swift */; }; + D7DB930A2D69486700DA1EE5 /* NIP65.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7DB93092D69485A00DA1EE5 /* NIP65.swift */; }; + D7DB930B2D69486700DA1EE5 /* NIP65.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7DB93092D69485A00DA1EE5 /* NIP65.swift */; }; + D7DB930C2D69486700DA1EE5 /* NIP65.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7DB93092D69485A00DA1EE5 /* NIP65.swift */; }; D7DBD41F2B02F15E002A6197 /* NostrKind.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C3BEFD32819DE8F00B3DE84 /* NostrKind.swift */; }; D7DEEF2F2A8C021E00E0C99F /* NostrEventTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7DEEF2E2A8C021E00E0C99F /* NostrEventTests.swift */; }; D7EB00B02CD59C8D00660C07 /* PresentFullScreenItemNotify.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7EB00AF2CD59C8300660C07 /* PresentFullScreenItemNotify.swift */; }; @@ -2537,6 +2540,7 @@ D7DB1FF02D5AC5D700CF06DA /* nip44.vectors.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = nip44.vectors.json; sourceTree = "<group>"; }; D7DB1FF22D5AC5E400CF06DA /* LICENSES */ = {isa = PBXFileReference; lastKnownFileType = text; path = LICENSES; sourceTree = "<group>"; }; D7DB93042D66A43B00DA1EE5 /* Undistractor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Undistractor.swift; sourceTree = "<group>"; }; + D7DB93092D69485A00DA1EE5 /* NIP65.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NIP65.swift; sourceTree = "<group>"; }; D7DEEF2E2A8C021E00E0C99F /* NostrEventTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NostrEventTests.swift; sourceTree = "<group>"; }; D7EB00AF2CD59C8300660C07 /* PresentFullScreenItemNotify.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PresentFullScreenItemNotify.swift; sourceTree = "<group>"; }; D7EDED1B2B1178FE0018B19C /* NoteContent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoteContent.swift; sourceTree = "<group>"; }; @@ -3646,6 +3650,7 @@ 4CE6DEE527F7A08100C66700 /* damus */ = { isa = PBXGroup; children = ( + D7DB93082D69478400DA1EE5 /* NIP65 */, D7DB1FDC2D5A77E500CF06DA /* NIP44 */, D755B28B2D3E7D6500BBEEFA /* NIP37 */, D78F08152D7F7F5F00FC6C75 /* NIP04 */, @@ -4044,6 +4049,14 @@ path = NIP44; sourceTree = "<group>"; }; + D7DB93082D69478400DA1EE5 /* NIP65 */ = { + isa = PBXGroup; + children = ( + D7DB93092D69485A00DA1EE5 /* NIP65.swift */, + ); + path = NIP65; + sourceTree = "<group>"; + }; E06336A72B7582D600A88E6B /* Assets */ = { isa = PBXGroup; children = ( @@ -4480,6 +4493,7 @@ F757933A29D7AECD007DEAC1 /* MediaPicker.swift in Sources */, 4CF0ABEE29844B5500D66079 /* AnyEncodable.swift in Sources */, B57B4C662B312C3700A232C0 /* NostrAuth.swift in Sources */, + D7DB930B2D69486700DA1EE5 /* NIP65.swift in Sources */, 4CB8838D296F710400DC99E7 /* Reposted.swift in Sources */, 4C3EA67728FF7A9800C48A62 /* talstr.c in Sources */, 4CE6DEE927F7A08100C66700 /* ContentView.swift in Sources */, @@ -5311,6 +5325,7 @@ 82D6FBE02CD99F7900C925F4 /* ReactionsSettingsView.swift in Sources */, 82D6FBE12CD99F7900C925F4 /* NotificationSettingsView.swift in Sources */, 82D6FBE22CD99F7900C925F4 /* AppearanceSettingsView.swift in Sources */, + D7DB930A2D69486700DA1EE5 /* NIP65.swift in Sources */, 82D6FBE32CD99F7900C925F4 /* KeySettingsView.swift in Sources */, 82D6FBE42CD99F7900C925F4 /* ZapSettingsView.swift in Sources */, 82D6FBE52CD99F7900C925F4 /* TranslationSettingsView.swift in Sources */, @@ -5715,6 +5730,7 @@ D73E5F012C6A97F4007EB227 /* ZapTypePicker.swift in Sources */, D73E5F022C6A97F4007EB227 /* ZapUserView.swift in Sources */, D73E5F032C6A97F4007EB227 /* ProfileZapLinkView.swift in Sources */, + D7DB930C2D69486700DA1EE5 /* NIP65.swift in Sources */, D73E5F042C6A97F4007EB227 /* AboutView.swift in Sources */, D73E5F052C6A97F4007EB227 /* ProfileName.swift in Sources */, D73E5F062C6A97F4007EB227 /* ProfilePictureSelector.swift in Sources */, diff --git a/damus/Models/HomeModel.swift b/damus/Models/HomeModel.swift @@ -225,6 +225,8 @@ class HomeModel: ContactsDelegate { // TODO: Implement draft syncing with relays. We intentionally do not support that as of writing. See `DraftsModel.swift` for other details // try? damus_state.drafts.load(wrapped_draft_note: ev, with: damus_state) break + case .relay_list: + break // This will be handled by `UserRelayListManager` } } diff --git a/damus/Models/UserSettingsStore.swift b/damus/Models/UserSettingsStore.swift @@ -336,6 +336,10 @@ class UserSettingsStore: ObservableObject { @Setting(key: "draft_event_ids", default_value: nil) var draft_event_ids: [String]? + // TODO: Get rid of this once we have NostrDB query capabilities integrated + @Setting(key: "latest_relay_list_event_id", default_value: nil) + var latestRelayListEventIdHex: String? + // MARK: Helper types enum NotificationsMode: String, CaseIterable, Identifiable, StringCodable, Equatable { diff --git a/damus/NIP65/NIP65.swift b/damus/NIP65/NIP65.swift @@ -0,0 +1,167 @@ +// +// NIP65.swift +// damus +// +// Created by Daniel D’Aquino on 2025-02-21. +// +// Some text excerpts taken from the Nostr Protocol itself (which are public domain) + +import OrderedCollections +import Foundation + +/// Includes models and functions for working with NIP-65 +struct NIP65: Sendable {} + +extension NIP65 { + /// Models a NIP-65 relay list + struct RelayList: NostrEventConvertible, Sendable { + let relays: OrderedDictionary<RelayURL, RelayItem> + + // MARK: - Initialization + + init(event: NdbNote) throws(NIP65DecodingError) { + guard event.known_kind == .relay_list else { throw .notRelayList } + var relays: [RelayItem] = [] + for tag in event.tags { + guard let relay = try RelayItem.fromTag(tag: tag) else { continue } + relays.append(relay) + } + self.relays = Self.relayOrderedDictionary(from: relays) + } + + init?(event: NdbNote?) throws(NIP65DecodingError) { + guard let event else { return nil } + try self.init(event: event) + } + + init(relays: [RelayItem]) { + self.relays = Self.relayOrderedDictionary(from: relays) + } + + init(relays: [RelayURL]) { + let relayItemList = relays.map({ RelayItem(url: $0, rwConfiguration: .readWrite) }) + self.relays = Self.relayOrderedDictionary(from: relayItemList) + } + + private static func relayOrderedDictionary(from relayList: [RelayItem]) -> OrderedDictionary<RelayURL, RelayItem> { + var seenUrls: Set<RelayURL> = [] + return OrderedDictionary(uniqueKeysWithValues: relayList.compactMap({ + // We need to ensure the keys are unique to avoid assertion errors from OrderedDictionary + guard !seenUrls.contains($0.url) else { return nil } + seenUrls.insert($0.url) + return ($0.url, $0) + })) + } + + + // MARK: - Conversion to a Nostr Event + + func toNostrEvent(keypair: FullKeypair, timestamp: UInt32? = nil) -> NostrEvent? { + return NdbNote( + content: "", + keypair: keypair.to_keypair(), + kind: NostrKind.relay_list.rawValue, + tags: self.relays.values.map({ $0.tag }), + createdAt: timestamp ?? UInt32(Date.now.timeIntervalSince1970) + ) + } + } +} + +extension NIP65 { + /// An error thrown when decoding an item into a NIP-65 relay list + enum NIP65DecodingError: Error { + /// The Nostr event being converted is not a NIP-65 relay list + case notRelayList + /// The relay URL is invalid + case invalidRelayURL + ///The relay RW marker is invalid + case invalidRelayMarker + } +} + +extension NIP65.RelayList { + /// An item referencing a relay and its configuration inside a relay list + struct RelayItem: ThrowingTagConvertible, Sendable { + typealias E = NIP65.NIP65DecodingError + + let url: RelayURL + let rwConfiguration: RWConfiguration + + /// The raw tag sequence in a Nostr event + var tag: [String] { + var tag = ["r", url.absoluteString] + if let rwMarker = rwConfiguration.tagItem { tag.append(rwMarker) } + return tag + } + + /// Initialize a new relay item from a Nostr event's tag sequence + static func fromTag(tag: TagSequence) throws(E) -> NIP65.RelayList.RelayItem? { + var i = tag.makeIterator() + + guard tag.count >= 2, + let t0 = i.next(), + let key = t0.single_char, + let rkey = RefId.RefKey(rawValue: key), + let t1 = i.next() + else { return nil } + + let t2 = i.next() + + switch rkey { + case .r: return try self.fromRawInfo(urlString: t1.string(), rwMarker: t2?.string()) + // Keep options explicit to make compiler prompt developer on whether to ignore or handle new future options + case .e, .p, .q, .t, .d, .a: return nil + } + } + + /// Initializes a Relay Item based on raw information + static func fromRawInfo(urlString: String, rwMarker: String?) throws(NIP65.NIP65DecodingError) -> NIP65.RelayList.RelayItem? { + guard let relayUrl = RelayURL(urlString) else { throw .invalidRelayURL } + guard let rwConfiguration = RWConfiguration.fromTagItem(rwMarker) else { throw .invalidRelayMarker } + return NIP65.RelayList.RelayItem(url: relayUrl, rwConfiguration: rwConfiguration) + } + } +} + +extension NIP65.RelayList.RelayItem { + /// The read/write configuration for a relay item + enum RWConfiguration: TagItemConvertible { + case read + case write + case readWrite + + static let READ_MARKER: String = "read" + static let WRITE_MARKER: String = "write" + + var canRead: Bool { + switch self { + case .read, .readWrite: return true + case .write: return false + } + } + + var canWrite: Bool { + switch self { + case .write, .readWrite: return true + case .read: return false + } + } + + /// A raw Nostr Event tag item + var tagItem: String? { + switch self { + case .read: Self.READ_MARKER + case .write: Self.WRITE_MARKER + case .readWrite: nil + } + } + + /// Initialize this from a raw Nostr Event tag item + static func fromTagItem(_ item: String?) -> Self? { + if item == READ_MARKER { return .read } + if item == WRITE_MARKER { return .write } + return .readWrite + } + } +} diff --git a/damus/Nostr/Id.swift b/damus/Nostr/Id.swift @@ -34,6 +34,19 @@ protocol TagConvertible { static func from_tag(tag: TagSequence) -> Self? } +/// Protocol for types that can be converted from/to a tag sequence with the possibilty of an error +protocol ThrowingTagConvertible { + associatedtype E: Error + var tag: [String] { get } + static func fromTag(tag: TagSequence) throws(E) -> Self? +} + +/// Protocol for types that can be converted from/to a tag item +protocol TagItemConvertible { + var tagItem: String? { get } + static func fromTagItem(_ item: String?) -> Self? +} + struct QuoteId: IdType, TagKey, TagConvertible { let id: Data diff --git a/damus/Nostr/NostrKind.swift b/damus/Nostr/NostrKind.swift @@ -19,6 +19,7 @@ enum NostrKind: UInt32, Codable { case like = 7 case chat = 42 case mute_list = 10000 + case relay_list = 10002 case list_deprecated = 30000 case draft = 31234 case longform = 30023 diff --git a/damus/Nostr/Relay.swift b/damus/Nostr/Relay.swift @@ -17,6 +17,15 @@ public struct LegacyKind3RelayRWConfiguration: Codable, Sendable { } static let rw = LegacyKind3RelayRWConfiguration(read: true, write: true) + + func toNIP65RWConfiguration() -> NIP65.RelayList.RelayItem.RWConfiguration? { + switch (self.read, self.write) { + case (false, true): return .write + case (true, false): return .read + case (true, true): return .readWrite + default: return nil + } + } } enum RelayVariant { @@ -162,3 +171,26 @@ extension RelayPool { case RelayAlreadyExists } } + + +// MARK: - Extension to bridge NIP-65 relay list structs with app-native objects + +extension NIP65.RelayList { + static func fromLegacyContactList(_ contactList: NdbNote) throws(BridgeError) -> Self { + guard let relayListInfo = decode_json_relays(contactList.content) else { throw .couldNotDecodeRelayListInfo } + let relayItems = relayListInfo.map({ url, rwConfiguration in + return RelayItem(url: url, rwConfiguration: rwConfiguration.toNIP65RWConfiguration() ?? .readWrite) + }) + return NIP65.RelayList(relays: relayItems) + } + + static func fromLegacyContactList(_ contactList: NdbNote?) throws(BridgeError) -> Self? { + guard let contactList = contactList else { return nil } + return try fromLegacyContactList(contactList) + } + + enum BridgeError: Error { + case couldNotDecodeRelayListInfo + } +} + diff --git a/damus/Views/LoadableNostrEventView.swift b/damus/Views/LoadableNostrEventView.swift @@ -74,7 +74,7 @@ class LoadableNostrEventViewModel: ObservableObject { case .zap, .zap_request: guard let zap = await get_zap(from: ev, state: damus_state) else { return .not_found } return .loaded(route: Route.Zaps(target: zap.target)) - case .contacts, .metadata, .delete, .boost, .chat, .mute_list, .list_deprecated, .draft, .longform, .nwc_request, .nwc_response, .http_auth, .status: + case .contacts, .metadata, .delete, .boost, .chat, .mute_list, .list_deprecated, .draft, .longform, .nwc_request, .nwc_response, .http_auth, .status, .relay_list: return .unknown_or_unsupported_kind } case .naddr(let naddr):