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:
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):