Consume NIP-19 relay hints for event fetching

Extract and use relay hints from bech32 entities (nevent, nprofile, naddr)
and event tag references (e, q tags) to fetch events from hinted relays
not in the user's relay pool.

Changes:
- Parse relay hints from bech32 TLV data in URLHandler
- Pass relay hints through SearchType and NoteReference enums
- Add ensureConnected() to RelayPool for ephemeral relay connections
- Implement ephemeral relay lease management with race condition protection
- Add repostTarget() helper to extract relay hints from repost e tags
- Add QuoteRef struct to preserve relay hints from q tags (NIP-10/NIP-18)
- Support relay hints in replies with author pubkey in e-tags (NIP-10)
- Implement fallback broadcast when hinted relays don't respond
- Add comprehensive test coverage for relay hint functionality
- Add DEBUG logging for relay hint tracing during development

Implementation details:
- Connect to hinted relays as ephemeral, returning early when first connects
- Use total deadline to prevent timeout accumulation across hint attempts
- Decrement lease count before suspension points to ensure atomicity
- Fall back to broadcast if hints don't resolve or respond

Closes: https://github.com/damus-io/damus/issues/1147
Changelog-Added: Added relay hint support for nevent, nprofile, naddr links and event tag references (reposts, quotes, replies)
Signed-off-by: alltheseas
Signed-off-by: Daniel D'Aquino <daniel@daquino.me>
Co-authored-by: alltheseas <alltheseas@users.noreply.github.com>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Co-authored-by: Daniel D'Aquino <daniel@daquino.me
This commit is contained in:
alltheseas
2026-02-02 20:52:41 -06:00
committed by GitHub
parent 6f8e2d3064
commit 9a1ae6f9b5
27 changed files with 1522 additions and 128 deletions

View File

@@ -1704,6 +1704,7 @@
D776BE412F232B17002DA1C9 /* EntityPreloader.swift in Sources */ = {isa = PBXBuildFile; fileRef = D776BE3F2F232B17002DA1C9 /* EntityPreloader.swift */; };
D776BE422F232B17002DA1C9 /* EntityPreloader.swift in Sources */ = {isa = PBXBuildFile; fileRef = D776BE3F2F232B17002DA1C9 /* EntityPreloader.swift */; };
D776BE442F23301A002DA1C9 /* EntityPreloaderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D776BE432F233012002DA1C9 /* EntityPreloaderTests.swift */; };
D77A96BF2F3131BE00CC3246 /* RelayHintsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D77A96BE2F3131BE00CC3246 /* RelayHintsTests.swift */; };
D77BFA0B2AE3051200621634 /* ProfileActionSheetView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D77BFA0A2AE3051200621634 /* ProfileActionSheetView.swift */; };
D77DA2C42F19CA48000B7093 /* AsyncStreamUtilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = D77DA2C32F19CA40000B7093 /* AsyncStreamUtilities.swift */; };
D77DA2C52F19CA48000B7093 /* AsyncStreamUtilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = D77DA2C32F19CA40000B7093 /* AsyncStreamUtilities.swift */; };
@@ -2826,6 +2827,7 @@
D773BC5E2C6D538500349F0A /* CommentItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommentItem.swift; sourceTree = "<group>"; };
D776BE3F2F232B17002DA1C9 /* EntityPreloader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EntityPreloader.swift; sourceTree = "<group>"; };
D776BE432F233012002DA1C9 /* EntityPreloaderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EntityPreloaderTests.swift; sourceTree = "<group>"; };
D77A96BE2F3131BE00CC3246 /* RelayHintsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RelayHintsTests.swift; sourceTree = "<group>"; };
D77BFA0A2AE3051200621634 /* ProfileActionSheetView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileActionSheetView.swift; sourceTree = "<group>"; };
D77DA2C32F19CA40000B7093 /* AsyncStreamUtilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AsyncStreamUtilities.swift; sourceTree = "<group>"; };
D77DA2C72F19D452000B7093 /* NegentropyUtilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NegentropyUtilities.swift; sourceTree = "<group>"; };
@@ -3873,6 +3875,7 @@
4CE6DEF627F7A08200C66700 /* damusTests */ = {
isa = PBXGroup;
children = (
D77A96BE2F3131BE00CC3246 /* RelayHintsTests.swift */,
D776BE432F233012002DA1C9 /* EntityPreloaderTests.swift */,
D77DA2CD2F1C2596000B7093 /* SubscriptionManagerNegentropyTests.swift */,
D74723ED2F15B0D6002DA12A /* NegentropySupportTests.swift */,
@@ -6383,6 +6386,7 @@
4CB883AE2976FA9300DC99E7 /* FormatTests.swift in Sources */,
D72A2D052AD9C1B5002AFF62 /* MockDamusState.swift in Sources */,
E06336AA2B75832100A88E6B /* ImageMetadataTest.swift in Sources */,
D77A96BF2F3131BE00CC3246 /* RelayHintsTests.swift in Sources */,
4C363AA02828A8DD006E126D /* LikeTests.swift in Sources */,
D7A0D8752D1FE67900DCBE59 /* EditPictureControlTests.swift in Sources */,
D776BE442F23301A002DA1C9 /* EntityPreloaderTests.swift in Sources */,

View File

@@ -1112,16 +1112,18 @@ func handle_post_notification(keypair: FullKeypair, postbox: PostBox, events: Ev
extension LossyLocalNotification {
/// Computes a view open action from a mention reference.
/// Use this when opening a user-presentable interface to a specific mention reference.
/// Converts this mention's NIP-19 reference into a UI action for the app.
///
/// Maps NPUB and NPROFILE references to profile routes, NOTE/NEVENT/NADDR references to loadable note routes, NSCRIPT to a script view, and returns an error sheet for deprecated or unsafe references (`nrelay`, `nsec`).
/// - Returns: A `ContentView.ViewOpenAction` that represents the route or sheet to present for this mention.
func toViewOpenAction() -> ContentView.ViewOpenAction {
switch self.mention.nip19 {
case .npub(let pubkey):
return .route(.ProfileByKey(pubkey: pubkey))
case .note(let noteId):
return .route(.LoadableNostrEvent(note_reference: .note_id(noteId)))
return .route(.LoadableNostrEvent(note_reference: .note_id(noteId, relays: [])))
case .nevent(let nEvent):
// TODO: Improve this by implementing a route that handles nevents with their relay hints.
return .route(.LoadableNostrEvent(note_reference: .note_id(nEvent.noteid)))
return .route(.LoadableNostrEvent(note_reference: .note_id(nEvent.noteid, relays: nEvent.relays)))
case .nprofile(let nProfile):
// TODO: Improve this by implementing a profile route that handles nprofiles with their relay hints.
return .route(.ProfileByKey(pubkey: nProfile.author))
@@ -1152,4 +1154,4 @@ func logout(_ state: DamusState?)
{
state?.close()
notify(.logout)
}
}

View File

@@ -409,25 +409,89 @@ extension NostrNetworkManager {
// MARK: - Finding specific data from Nostr
/// Finds a non-replaceable event based on a note ID
/// Finds a non-replaceable event based on a note ID.
///
/// When relay hints are provided, they get a short exclusive window to respond.
/// If no event is found within that window, the remaining time is used to broadcast
/// to all connected relays. The `timeout` parameter is a total deadline for both phases.
func lookup(noteId: NoteId, to targetRelays: [RelayURL]? = nil, timeout: Duration? = nil) async throws -> NdbNoteLender? {
let filter = NostrFilter(ids: [noteId], limit: 1)
// Since note ids point to immutable objects, we can do a simple ndb lookup first
if let noteKey = try? self.ndb.lookup_note_key(noteId) {
return NdbNoteLender(ndb: self.ndb, noteKey: noteKey)
}
// Not available in local ndb, stream from network
outerLoop: for await item in await self.pool.subscribe(filters: [NostrFilter(ids: [noteId], limit: 1)], to: targetRelays, eoseTimeout: timeout) {
let filter = NostrFilter(ids: [noteId], limit: 1)
let totalTimeout = timeout ?? .seconds(10)
let startTime = ContinuousClock.now
// If relay hints provided, try them first with a short timeout
if let targetRelays, !targetRelays.isEmpty {
// Acquire ephemeral relays and connect to them
await self.pool.acquireEphemeralRelays(targetRelays)
defer {
Task { await self.pool.releaseEphemeralRelays(targetRelays) }
}
let connectedRelays = await self.pool.ensureConnected(to: targetRelays)
guard !connectedRelays.isEmpty else {
#if DEBUG
Self.logger.info("lookup(noteId): No hint relays connected, skipping to broadcast")
#endif
return await fetchFromRelays(filter: filter, relays: nil, timeout: totalTimeout)
}
// Use min of 3 seconds or half of total timeout for hint phase
let hintTimeout = min(.seconds(3), totalTimeout / 2)
#if DEBUG
Self.logger.info("lookup(noteId): Trying \(connectedRelays.count)/\(targetRelays.count) hint relay(s) with \(hintTimeout) timeout")
#endif
let result = await fetchFromRelays(filter: filter, relays: connectedRelays, timeout: hintTimeout)
if let result {
return result
}
// Calculate remaining time for broadcast phase
let elapsed = ContinuousClock.now - startTime
let remaining = totalTimeout - elapsed
guard remaining > .zero else {
#if DEBUG
Self.logger.info("lookup(noteId): Total timeout exceeded, skipping broadcast")
#endif
return nil
}
// Hint relays didn't respond, fallback to broadcast with remaining time
#if DEBUG
Self.logger.info("lookup(noteId): Hint relays didn't respond, falling back to broadcast (\(remaining) remaining)")
#endif
return await fetchFromRelays(filter: filter, relays: nil, timeout: remaining)
}
// No hints, broadcast to all relays
return await fetchFromRelays(filter: filter, relays: nil, timeout: totalTimeout)
}
/// Fetches the first event matching the filter from the specified relays.
///
/// - Parameters:
/// - filter: The NostrFilter to match events against.
/// - relays: Optional relay URLs to query. If nil, broadcasts to all connected relays.
/// - timeout: Maximum duration to wait for a response.
/// - Returns: An `NdbNoteLender` for the first matching event, or `nil` if EOSE is received
/// or the timeout expires without finding a match.
private func fetchFromRelays(filter: NostrFilter, relays: [RelayURL]?, timeout: Duration) async -> NdbNoteLender? {
for await item in await self.pool.subscribe(filters: [filter], to: relays, eoseTimeout: timeout) {
switch item {
case .event(let event):
return NdbNoteLender(ownedNdbNote: event)
case .eose:
break outerLoop
return nil
}
}
return nil
}
@@ -439,32 +503,53 @@ extension NostrNetworkManager {
return events
}
/// Finds a replaceable event based on an `naddr` address.
///
/// Finds a Nostr event that corresponds to the provided naddr identifier.
/// - Parameters:
/// - naddr: the `naddr` address
/// - naddr: The NAddr (network address) that identifies the target replaceable event (contains kind, author, and identifier).
/// - targetRelays: Optional relay URLs to hint where to search; the method may acquire ephemeral relays and will use only the subset of those that become connected.
/// - timeout: Optional duration to bound the search.
/// - Returns: The matching `NostrEvent` whose first referenced parameter equals `naddr.identifier`, or `nil` if no matching event is found.
func lookup(naddr: NAddr, to targetRelays: [RelayURL]? = nil, timeout: Duration? = nil) async -> NostrEvent? {
var nostrKinds: [NostrKind]? = NostrKind(rawValue: naddr.kind).map { [$0] }
var connectedTargetRelays = targetRelays
var ephemeralRelays: [RelayURL] = []
if let relays = targetRelays, !relays.isEmpty {
await self.pool.acquireEphemeralRelays(relays)
ephemeralRelays = relays
let connectedRelays = await self.pool.ensureConnected(to: relays)
connectedTargetRelays = connectedRelays.isEmpty ? nil : connectedRelays
#if DEBUG
Self.logger.info("lookup(naddr): Using \(connectedRelays.count)/\(relays.count) relay hints: \(connectedRelays.map { $0.absoluteString }.joined(separator: ", "), privacy: .public)")
#endif
}
defer {
if !ephemeralRelays.isEmpty {
Task { await self.pool.releaseEphemeralRelays(ephemeralRelays) }
}
}
let nostrKinds: [NostrKind]? = NostrKind(rawValue: naddr.kind).map { [$0] }
let filter = NostrFilter(kinds: nostrKinds, authors: [naddr.author])
for await noteLender in self.streamExistingEvents(filters: [filter], to: targetRelays, timeout: timeout) {
// TODO: This can be refactored to borrow the note instead of copying it. But we need to implement `referenced_params` on `UnownedNdbNote` to do so
for await noteLender in self.streamExistingEvents(filters: [filter], to: connectedTargetRelays, timeout: timeout) {
guard let event = noteLender.justGetACopy() else { continue }
if event.referenced_params.first?.param.string() == naddr.identifier {
return event
}
}
return nil
}
// TODO: Improve this. This is mostly intact to keep compatibility with its predecessor, but we can do better
/// Searches for a profile or event specified by `query` and returns the first matching result.
/// The function first checks the local NDB cache and, if not found, queries relays (honoring any relay hints in the query).
/// - Parameter query: Specifies what to find (profile by pubkey or event by id) and optional relay hints to use for network lookup.
/// - Returns: A `FoundEvent` containing the matched profile or event, or `nil` if no match is found.
func findEvent(query: FindEvent) async -> FoundEvent? {
var filter: NostrFilter? = nil
let find_from = query.find_from
let query = query.type
switch query {
case .profile(let pubkey):
let profileNotNil = try? self.ndb.lookup_profile(pubkey, borrow: { pr in
@@ -483,12 +568,28 @@ extension NostrNetworkManager {
}
filter = NostrFilter(ids: [evid], limit: 1)
}
var attempts: Int = 0
var has_event = false
guard let filter else { return nil }
for await noteLender in self.streamExistingEvents(filters: [filter], to: find_from) {
var targetRelays = find_from
var ephemeralRelays: [RelayURL] = []
if let relays = find_from, !relays.isEmpty {
await self.pool.acquireEphemeralRelays(relays)
ephemeralRelays = relays
let connectedRelays = await self.pool.ensureConnected(to: relays)
targetRelays = connectedRelays.isEmpty ? nil : connectedRelays
#if DEBUG
Self.logger.info("findEvent: Using \(connectedRelays.count)/\(relays.count) relay hints: \(connectedRelays.map { $0.absoluteString }.joined(separator: ", "), privacy: .public)")
#endif
}
defer {
if !ephemeralRelays.isEmpty {
Task { await self.pool.releaseEphemeralRelays(ephemeralRelays) }
}
}
for await noteLender in self.streamExistingEvents(filters: [filter], to: targetRelays) {
let foundEvent: FoundEvent? = try? noteLender.borrow({ event in
switch query {
case .profile:
@@ -659,4 +760,4 @@ extension NostrNetworkManager {
/// Preload metadata for authors and referenced profiles
case preload
}
}
}

View File

@@ -49,22 +49,22 @@ protocol TagItemConvertible {
struct QuoteId: IdType, TagKey, TagConvertible {
let id: Data
init(_ data: Data) {
self.id = data
}
/// The note id being quoted
var note_id: NoteId {
NoteId(self.id)
}
var keychar: AsciiCharacter { "q" }
var tag: [String] {
["q", self.hex()]
}
static func from_tag(tag: TagSequence) -> QuoteId? {
var i = tag.makeIterator()
@@ -80,6 +80,52 @@ struct QuoteId: IdType, TagKey, TagConvertible {
}
}
/// A quote reference with optional relay hints for fetching.
///
/// Per NIP-10/NIP-18, `q` tags include a relay URL at position 2 where the quoted
/// event can be found.
///
/// Note: The NIPs allow `q` tags to contain either event IDs (hex) or event addresses
/// (`<kind>:<pubkey>:<d>` for replaceable events). This implementation currently only
/// supports hex event IDs; quotes of addressable events are not yet handled.
struct QuoteRef: TagConvertible {
let quote_id: QuoteId
let relayHints: [RelayURL]
/// The note ID being quoted
var note_id: NoteId {
quote_id.note_id
}
var tag: [String] {
var tagBuilder = ["q", quote_id.hex()]
if let relay = relayHints.first {
tagBuilder.append(relay.absoluteString)
}
return tagBuilder
}
/// Parses a `q` tag into a QuoteRef, preserving relay hints from position 2.
///
/// Only parses `q` tags containing hex event IDs. Tags with event addresses
/// (`<kind>:<pubkey>:<d>`) are not currently supported and will return nil.
static func from_tag(tag: TagSequence) -> QuoteRef? {
var i = tag.makeIterator()
guard tag.count >= 2,
let t0 = i.next(),
let key = t0.single_char,
key == "q",
let t1 = i.next(),
let data = t1.id()
else { return nil }
let quoteId = QuoteId(data)
let relayHints = tag.relayHints
return QuoteRef(quote_id: quoteId, relayHints: relayHints)
}
}
struct Privkey: IdType {
let id: Data

View File

@@ -122,6 +122,11 @@ struct MentionRef: TagKeys, TagConvertible, Equatable, Hashable {
}
}
/// Parses a tag sequence into a MentionRef, preserving relay hints.
///
/// Per NIP-01/NIP-10, position 2 in `e`, `p`, and `a` tags contains an optional relay URL.
/// When present, this method creates `nevent`/`nprofile`/`naddr` variants that preserve
/// the relay hint for later use in event fetching.
static func from_tag(tag: TagSequence) -> MentionRef? {
guard tag.count >= 2 else { return nil }
@@ -135,23 +140,35 @@ struct MentionRef: TagKeys, TagConvertible, Equatable, Hashable {
return nil
}
let relayHints = tag.relayHints
switch mention_type {
case .p:
guard let data = element.id() else { return nil }
return .init(nip19: .npub(Pubkey(data)))
let pubkey = Pubkey(data)
if relayHints.isEmpty {
return .init(nip19: .npub(pubkey))
}
return .init(nip19: .nprofile(NProfile(author: pubkey, relays: relayHints)))
case .e:
guard let data = element.id() else { return nil }
return .init(nip19: .note(NoteId(data)))
let noteId = NoteId(data)
if relayHints.isEmpty {
return .init(nip19: .note(noteId))
}
#if DEBUG
print("[relay-hints] e tag: Found \(relayHints.count) hint(s) for \(noteId.hex().prefix(8))...: \(relayHints.map { $0.absoluteString })")
#endif
return .init(nip19: .nevent(NEvent(noteid: noteId, relays: relayHints)))
case .a:
let str = element.string()
let data = str.split(separator: ":")
if(data.count != 3) { return nil }
guard data.count == 3 else { return nil }
guard let pubkey = Pubkey(hex: String(data[1])) else { return nil }
guard let kind = UInt32(data[0]) else { return nil }
return .init(nip19: .naddr(NAddr(identifier: String(data[2]), author: pubkey, relays: [], kind: kind)))
case .r: return .init(nip19: .nrelay(element.string()))
return .init(nip19: .naddr(NAddr(identifier: String(data[2]), author: pubkey, relays: relayHints, kind: kind)))
case .r:
return .init(nip19: .nrelay(element.string()))
}
}

View File

@@ -834,6 +834,68 @@ func first_eref_mention(ndb: Ndb, ev: NostrEvent, keypair: Keypair) -> Mention<N
})
}
/// Represents a note mention with optional relay hints for fetching.
struct NoteMentionWithHints {
let noteId: NoteId
let relayHints: [RelayURL]
let index: Int?
}
/// Finds the first event reference mention in a note's content, preserving relay hints.
///
/// Per NIP-19, `nevent` bech32 entities may include relay hints. This function extracts
/// those hints so they can be used when fetching the referenced event.
///
/// If no inline mention is found in the content, falls back to checking `q` tags (NIP-10/NIP-18)
/// to support quote reposts that don't embed the quoted note inline.
///
/// - Parameters:
/// - ndb: The nostrdb instance.
/// - ev: The event to search.
/// - keypair: The keypair for decryption if needed.
/// - Returns: A `NoteMentionWithHints` containing the note ID and relay hints, or nil if not found.
func first_eref_mention_with_hints(ndb: Ndb, ev: NostrEvent, keypair: Keypair) -> NoteMentionWithHints? {
// First check content blocks for inline mentions
let inlineMention: NoteMentionWithHints? = try? NdbBlockGroup.borrowBlockGroup(event: ev, using: ndb, and: keypair, borrow: { blockGroup in
return blockGroup.forEachBlock({ index, block in
switch block {
case .mention(let mention):
guard let mentionRef = MentionRef(block: mention) else { return .loopContinue }
switch mentionRef.nip19 {
case .note(let noteId):
return .loopReturn(NoteMentionWithHints(noteId: noteId, relayHints: [], index: index))
case .nevent(let nEvent):
#if DEBUG
if !nEvent.relays.isEmpty {
print("[relay-hints] Inline nevent: Found \(nEvent.relays.count) hint(s) for \(nEvent.noteid.hex().prefix(8))...: \(nEvent.relays.map { $0.absoluteString })")
}
#endif
return .loopReturn(NoteMentionWithHints(noteId: nEvent.noteid, relayHints: nEvent.relays, index: index))
default:
return .loopContinue
}
default:
return .loopContinue
}
})
})
if let inlineMention {
return inlineMention
}
// Fall back to q tags (NIP-10/NIP-18 quote reposts)
guard let quoteRef = ev.referenced_quote_refs.first else {
return nil
}
#if DEBUG
if !quoteRef.relayHints.isEmpty {
print("[relay-hints] Quote: Found q tag with \(quoteRef.relayHints.count) hint(s) for \(quoteRef.note_id.hex().prefix(8))...: \(quoteRef.relayHints.map { $0.absoluteString })")
}
#endif
return NoteMentionWithHints(noteId: quoteRef.note_id, relayHints: quoteRef.relayHints, index: nil)
}
func separate_invoices(ndb: Ndb, ev: NostrEvent, keypair: Keypair) -> [Invoice]? {
return try? NdbBlockGroup.borrowBlockGroup(event: ev, using: ndb, and: keypair, borrow: { blockGroup in
let invoiceBlocks: [Invoice] = (try? blockGroup.reduce(initialResult: [Invoice](), { index, invoices, block in

View File

@@ -52,6 +52,11 @@ class RelayPool {
var delegate: Delegate?
private(set) var signal: SignalModel = SignalModel()
/// Tracks active leases on ephemeral relays to prevent premature cleanup.
/// Each lookup that uses an ephemeral relay acquires a lease; cleanup only
/// happens when the last lease is released.
private var ephemeralLeases: [RelayURL: Int] = [:]
let network_monitor = NWPathMonitor()
private let network_monitor_queue = DispatchQueue(label: "io.damus.network_monitor")
private var last_network_status: NWPath.Status = .unsatisfied
@@ -159,23 +164,77 @@ class RelayPool {
Log.debug("Registering %s handler, current: %d", for: .networking, sub_id, self.handlers.count)
}
/// Removes the relay with the given URL from the pool, permanently disables its connection, and ensures it is disconnected.
/// - Parameters:
/// - relay_id: The RelayURL identifying the relay to disable and remove.
@MainActor
func remove_relay(_ relay_id: RelayURL) async {
var i: Int = 0
await self.disconnect(to: [relay_id])
for relay in relays {
if relay.id == relay_id {
relay.connection.disablePermanently()
relays.remove(at: i)
break
}
i += 1
}
}
/// Acquires a lease on ephemeral relays to prevent them from being cleaned up
/// Increment lease counts for the given ephemeral relay URLs to prevent their removal while leased.
/// - Parameters:
/// - relayURLs: The relay URLs whose ephemeral lease counts will be incremented; each URL's lease count is increased by one.
func acquireEphemeralRelays(_ relayURLs: [RelayURL]) {
for url in relayURLs {
ephemeralLeases[url, default: 0] += 1
#if DEBUG
print("[RelayPool] Acquired lease on ephemeral relay \(url.absoluteString), count: \(ephemeralLeases[url] ?? 0)")
#endif
}
}
/// Releases leases on ephemeral relays. When the last lease is released,
/// Releases one lease for each specified relay and removes any ephemeral relay when its last lease is released.
/// - Parameters:
/// - relayURLs: Relay URLs whose leases should be decremented. If a relay's lease count reaches zero and the relay is marked ephemeral, the relay will be removed. Relays not present in the lease table are ignored.
func releaseEphemeralRelays(_ relayURLs: [RelayURL]) async {
for url in relayURLs {
guard let count = ephemeralLeases[url], count > 0 else { continue }
// Decrement immediately (atomic with respect to this actor, before any suspension)
let newCount = count - 1
ephemeralLeases[url] = newCount == 0 ? nil : newCount
#if DEBUG
print("[RelayPool] Released lease on ephemeral relay \(url.absoluteString), count: \(newCount)")
#endif
if newCount == 0 {
// Check if relay exists and is ephemeral
if let relay = await get_relay(url), relay.descriptor.ephemeral {
// Re-check: only remove if lease is still nil (not re-acquired during await)
guard ephemeralLeases[url] == nil else {
#if DEBUG
print("[RelayPool] Lease re-acquired during check, skipping removal: \(url.absoluteString)")
#endif
continue
}
#if DEBUG
print("[RelayPool] Removing ephemeral relay: \(url.absoluteString)")
#endif
await remove_relay(url)
}
}
}
}
/// Adds and registers a new relay in the pool using the provided descriptor.
/// - Parameter desc: Descriptor for the relay to add (includes its URL, metadata, and whether it is ephemeral).
/// - Throws: `RelayError.RelayAlreadyExists` if a relay with the same URL is already present in the pool.
func add_relay(_ desc: RelayDescriptor) async throws(RelayError) {
let relay_id = desc.url
if await get_relay(relay_id) != nil {
@@ -188,6 +247,16 @@ class RelayPool {
case .string(let str) = msg
else { return }
#if DEBUG
if desc.ephemeral {
if str.hasPrefix("[\"EVENT\"") {
print("[RelayPool] Received EVENT from ephemeral relay \(relay_id.absoluteString): \(str.prefix(200))...")
} else if str.hasPrefix("[\"EOSE\"") {
print("[RelayPool] Received EOSE from ephemeral relay \(relay_id.absoluteString)")
}
}
#endif
let _ = self.ndb?.processEvent(str, originRelayURL: relay_id)
self.message_received_function?((str, desc))
})
@@ -195,11 +264,116 @@ class RelayPool {
await self.appendRelayToList(relay: relay)
}
/// Appends the given Relay to the pool's internal list of relays.
@MainActor
private func appendRelayToList(relay: Relay) {
self.relays.append(relay)
}
/// Ensures the given relay URLs are connected, adding them as ephemeral relays if not already in the pool.
/// Returns the list of relay URLs that are actually connected (ready for subscriptions).
///
/// Callers should use `acquireEphemeralRelays` before the lookup and `releaseEphemeralRelays` after.
///
/// - Parameters:
/// - relayURLs: The relay URLs to ensure are connected
/// - timeout: Maximum time to wait for pending connections (default 2s). Returns early when first relay connects.
/// Ensure the given relays are present in the pool and return those that are connected.
///
/// This will add missing URLs as ephemeral relays, initiate connections for relays that are not connected, and wait up to `timeout` for connections to establish. Once any relay connects, the method allows a short grace period for additional relays to connect before returning.
/// - Parameters:
/// - relayURLs: The relay URLs to ensure connectivity for. Missing URLs will be added as ephemeral relays.
/// - timeout: Maximum time to wait for connections (default: 2 seconds). A short grace period (300 ms) is applied after the first relay connects.
/// - Returns: The subset of `relayURLs` that are currently connected (includes relays that were already connected and those that became connected during the wait).
func ensureConnected(to relayURLs: [RelayURL], timeout: Duration = .seconds(2)) async -> [RelayURL] {
var toConnect: [RelayURL] = []
var alreadyConnected: [RelayURL] = []
for url in relayURLs {
if let existing = await get_relay(url) {
if existing.connection.isConnected {
alreadyConnected.append(url)
#if DEBUG
print("[RelayPool] Relay \(url.absoluteString) already connected")
#endif
} else {
toConnect.append(url)
}
continue
}
let descriptor = RelayDescriptor(url: url, info: .readWrite, variant: .ephemeral)
do {
try await add_relay(descriptor)
toConnect.append(url)
#if DEBUG
print("[RelayPool] Added ephemeral relay: \(url.absoluteString)")
#endif
} catch {
#if DEBUG
print("[RelayPool] Failed to add relay \(url.absoluteString): \(error)")
#endif
}
}
guard !toConnect.isEmpty else { return alreadyConnected }
await connect(to: toConnect)
let checkInterval: Duration = .milliseconds(50)
let overallDeadline = ContinuousClock.now + timeout
var graceDeadline: ContinuousClock.Instant? = alreadyConnected.isEmpty ? nil : ContinuousClock.now + .milliseconds(300)
// Wait for relays to connect. Once the first connects, start a grace period for others.
waitLoop: while ContinuousClock.now < overallDeadline {
do {
try await Task.sleep(for: checkInterval)
} catch {
break
}
// Check if any relay has connected
var anyConnected = false
for url in toConnect {
if let relay = await get_relay(url), relay.connection.isConnected {
anyConnected = true
break
}
}
if anyConnected && graceDeadline == nil {
// Start grace period on first connection
graceDeadline = ContinuousClock.now + .milliseconds(300)
}
// Exit once grace period expires (check every iteration if deadline is set)
if let deadline = graceDeadline, ContinuousClock.now >= deadline {
break waitLoop
}
}
// Collect all connected relays
var connected = alreadyConnected
for url in toConnect {
if let relay = await get_relay(url), relay.connection.isConnected {
connected.append(url)
#if DEBUG
print("[RelayPool] Relay \(url.absoluteString) connected: true")
#endif
} else {
#if DEBUG
print("[RelayPool] Relay \(url.absoluteString) connected: false (excluded)")
#endif
}
}
return connected
}
/// Attaches a `RelayLog` to the connection for the specified relay and records the current network status in the log.
/// - Parameters:
/// - log: The `RelayLog` instance to attach to the relay's connection.
/// - relay_id: The `RelayURL` identifying the relay whose connection will receive the log.
func setLog(_ log: RelayLog, for relay_id: RelayURL) async {
// add the current network state to the log
log.add("Network state: \(network_monitor.currentPath.status)")
@@ -252,9 +426,22 @@ class RelayPool {
}
}
/// Gets relays matching the provided relay URLs, or all relays when no targets are specified.
/// - Parameter targetRelays: Optional list of relay URLs to filter by. If `nil`, the pool's full relay list is returned.
/// - Returns: An array of `Relay` instances corresponding to the requested URLs; any requested URL not present in the pool is omitted from the result.
@MainActor
func getRelays(targetRelays: [RelayURL]? = nil) -> [Relay] {
targetRelays.map{ get_relays($0) } ?? self.relays
let result = targetRelays.map{ get_relays($0) } ?? self.relays
#if DEBUG
if let targets = targetRelays {
let found = result.map { $0.descriptor.url.absoluteString }
let requested = targets.map { $0.absoluteString }
if found.count != targets.count {
print("[RelayPool] getRelays: MISMATCH! requested=\(requested) but found=\(found)")
}
}
#endif
return result
}
/// Deletes queued up requests that should not persist between app sessions (i.e. when the app goes to background then back to foreground)
@@ -305,10 +492,22 @@ class RelayPool {
/// - filters: The filters specifying the desired content.
/// - desiredRelays: The desired relays which to subsctibe to. If `nil`, it defaults to the `RelayPool`'s default list
/// - eoseTimeout: The maximum timeout which to give up waiting for the eoseSignal
/// - Returns: Returns an async stream that callers can easily consume via a for-loop
/// Open a subscription for the given filters and provide a stream of matching items and EOSE notifications.
/// - Parameters:
/// - filters: The list of NostrFilter objects that define which events to receive.
/// - desiredRelays: Optional list of RelayURL to subscribe to; when `nil` the pool's relays are used.
/// - eoseTimeout: Optional timeout to wait before emitting an EOSE if not all relays have reported EOSE; defaults to 5 seconds.
/// - id: Optional UUID to use as the subscription identifier; a new UUID is generated when `nil`.
/// - Returns: An AsyncStream that yields StreamItem values representing matched events and end-of-stream (EOSE) notifications for this subscription. The stream deduplicates events by their NoteId. When the stream terminates it will unsubscribe from the chosen relays and remove the internal handler.
func subscribe(filters: [NostrFilter], to desiredRelays: [RelayURL]? = nil, eoseTimeout: Duration? = nil, id: UUID? = nil) async -> AsyncStream<StreamItem> {
let eoseTimeout = eoseTimeout ?? .seconds(5)
let desiredRelays = await getRelays(targetRelays: desiredRelays)
#if DEBUG
print("[RelayPool] subscribe: requested=\(desiredRelays.map { $0.descriptor.url.absoluteString }), pool has \(await relays.count) relays")
if let ids = filters.first?.ids {
print("[RelayPool] subscribe: filter ids=\(ids.map { $0.hex() })")
}
#endif
let startTime = CFAbsoluteTimeGetCurrent()
return AsyncStream<StreamItem> { continuation in
let id = id ?? UUID()
@@ -439,6 +638,13 @@ class RelayPool {
}
}
/// Dispatches a Nostr request to the pool's matching relays, writing a local copy to the NostrDB and queuing the request for any relay that is not currently connected.
///
/// Filters target relays by their read/write capabilities and, optionally, by ephemeral status; connected relays receive the request immediately and disconnected relays have the request queued for later delivery. Sent messages are reported via `message_sent_function` when available.
/// - Parameters:
/// - req: The Nostr request to send.
/// - to: Optional list of relay URLs to restrict delivery to; `nil` targets the pool's default set of relays.
/// - skip_ephemeral: If `true`, skip ephemeral relays when sending the request.
func send_raw(_ req: NostrRequestType, to: [RelayURL]? = nil, skip_ephemeral: Bool = true) async {
let relays = await getRelays(targetRelays: to)
@@ -463,6 +669,11 @@ class RelayPool {
}
relay.connection.send(req, callback: { str in
#if DEBUG
if relay.descriptor.ephemeral && str.hasPrefix("[\"REQ\"") {
print("[RelayPool] Sending REQ to ephemeral relay \(relay.id.absoluteString): \(str)")
}
#endif
self.message_sent_function?((str, relay))
})
}
@@ -699,4 +910,3 @@ extension RelayPool {
}
}

View File

@@ -27,19 +27,23 @@ enum Marker: String {
}
}
/// A reference to a note event, with optional relay hint, marker, and author pubkey.
/// Per NIP-10: `["e", <event-id>, <relay-url>, <marker>, <pubkey>]`
struct NoteRef: IdType, TagConvertible, Equatable {
let note_id: NoteId
let relay: String?
let marker: Marker?
let pubkey: Pubkey?
var id: Data {
self.note_id.id
}
init(note_id: NoteId, relay: String? = nil, marker: Marker? = nil) {
init(note_id: NoteId, relay: String? = nil, marker: Marker? = nil, pubkey: Pubkey? = nil) {
self.note_id = note_id
self.relay = relay
self.marker = marker
self.pubkey = pubkey
}
static func note_id(_ note_id: NoteId) -> NoteRef {
@@ -50,19 +54,26 @@ struct NoteRef: IdType, TagConvertible, Equatable {
self.note_id = NoteId(data)
self.relay = nil
self.marker = nil
self.pubkey = nil
}
/// Generates a tag array per NIP-10: `["e", <event-id>, <relay-url>, <marker>, <pubkey>]`
var tag: [String] {
var t = ["e", self.hex()]
if let marker {
t.append(relay ?? "")
t.append(marker.rawValue)
if let pubkey {
t.append(pubkey.hex())
}
} else if let relay {
t.append(relay)
}
return t
}
/// Parses a NoteRef from a tag per NIP-10: `["e", <event-id>, <relay-url>, <marker>, <pubkey>]`
/// Only parses pubkey from position 4 when a valid marker is present in position 3.
static func from_tag(tag: TagSequence) -> NoteRef? {
guard tag.count >= 2 else { return nil }
@@ -78,14 +89,19 @@ struct NoteRef: IdType, TagConvertible, Equatable {
var relay: String? = nil
var marker: Marker? = nil
var pubkey: Pubkey? = nil
if tag.count >= 3, let r = i.next() {
relay = r.string()
if tag.count >= 4, let m = i.next() {
marker = Marker(m)
// Only parse pubkey when marker is recognized per NIP-10
if marker != nil, tag.count >= 5, let pk = i.next(), let pubkeyData = pk.id() {
pubkey = Pubkey(pubkeyData)
}
}
}
return NoteRef(note_id: note_id, relay: relay, marker: marker)
return NoteRef(note_id: note_id, relay: relay, marker: marker, pubkey: pubkey)
}
}

View File

@@ -143,22 +143,22 @@ struct ChatEventView: View {
}
}
if let replying_to = event.direct_replies(),
replying_to != selected_event.id {
ReplyQuoteView(keypair: damus_state.keypair, quoter: event, event_id: replying_to, state: damus_state, thread: thread, options: reply_quote_options)
if let reply_ref = event.direct_reply_ref(),
reply_ref.note_id != selected_event.id {
ReplyQuoteView(keypair: damus_state.keypair, quoter: event, event_id: reply_ref.note_id, state: damus_state, thread: thread, options: reply_quote_options, relayHint: reply_ref.relay)
.background(is_ours ? DamusColors.adaptablePurpleBackground2 : DamusColors.adaptableGrey2)
.foregroundColor(is_ours ? Color.damusAdaptablePurpleForeground : Color.damusAdaptableBlack)
.cornerRadius(5)
.onTapGesture {
self.scroll_to_event?(replying_to)
self.scroll_to_event?(reply_ref.note_id)
}
}
let blur_images = should_blur_images(settings: damus_state.settings, contacts: damus_state.contacts, ev: event, our_pubkey: damus_state.pubkey)
NoteContentView(damus_state: damus_state, event: event, blur_images: blur_images, size: .normal, options: [.truncate_content])
.padding(2)
if let mention = first_eref_mention(ndb: damus_state.ndb, ev: event, keypair: damus_state.keypair) {
MentionView(damus_state: damus_state, mention: mention)
if let mention = first_eref_mention_with_hints(ndb: damus_state.ndb, ev: event, keypair: damus_state.keypair) {
MentionView(damus_state: damus_state, mention: .note(mention.noteId, index: mention.index), relayHints: mention.relayHints)
.background(DamusColors.adaptableWhite)
.clipShape(RoundedRectangle(cornerSize: CGSize(width: 10, height: 10)))
}

View File

@@ -7,6 +7,9 @@
import SwiftUI
/// Displays a compact preview of the event being replied to.
///
/// Supports NIP-10 relay hints to fetch events from relays not in the user's pool.
struct ReplyQuoteView: View {
let keypair: Keypair
let quoter: NostrEvent
@@ -14,13 +17,24 @@ struct ReplyQuoteView: View {
let state: DamusState
@ObservedObject var thread: ThreadModel
let options: EventViewOptions
let relayHint: String?
init(keypair: Keypair, quoter: NostrEvent, event_id: NoteId, state: DamusState, thread: ThreadModel, options: EventViewOptions, relayHint: String? = nil) {
self.keypair = keypair
self.quoter = quoter
self.event_id = event_id
self.state = state
self.thread = thread
self.options = options
self.relayHint = relayHint
}
@State var can_show_event = true
func update_should_show_event(event: NdbNote) async {
self.can_show_event = await should_show_event(event: event, damus_state: state)
}
func content(event: NdbNote) -> some View {
ZStack(alignment: .leading) {
VStack(alignment: .leading) {
@@ -65,6 +79,14 @@ struct ReplyQuoteView: View {
.onAppear {
Task { await self.update_should_show_event(event: event) }
}
} else if let relayHint, let relayURL = RelayURL(relayHint) {
// Event not in cache - try to fetch using relay hint
EventLoaderView(damus_state: state, event_id: event_id, relayHints: [relayURL]) { loaded_event in
self.content(event: loaded_event)
.onAppear {
Task { await self.update_should_show_event(event: loaded_event) }
}
}
}
}
}

View File

@@ -17,8 +17,8 @@ struct DMView: View {
var Mention: some View {
Group {
if let mention = first_eref_mention(ndb: damus_state.ndb, ev: event, keypair: damus_state.keypair) {
BuilderEventView(damus: damus_state, event_id: mention.ref)
if let mention = first_eref_mention_with_hints(ndb: damus_state.ndb, ev: event, keypair: damus_state.keypair) {
BuilderEventView(damus: damus_state, event_id: mention.noteId, relayHints: mention.relayHints)
} else {
EmptyView()
}

View File

@@ -7,21 +7,34 @@
import SwiftUI
/// A view that displays an embedded/quoted Nostr event.
///
/// Supports NIP-01/NIP-10 relay hints to fetch events from relays not in the user's pool.
struct BuilderEventView: View {
let damus: DamusState
let event_id: NoteId
let event: NostrEvent?
let relayHints: [RelayURL]
/// Creates a builder event view with a pre-loaded event.
init(damus: DamusState, event: NostrEvent) {
self.event = event
self.damus = damus
self.event_id = event.id
self.relayHints = []
}
init(damus: DamusState, event_id: NoteId) {
/// Creates a builder event view that will load the event by ID.
///
/// - Parameters:
/// - damus: The app's shared state.
/// - event_id: The ID of the event to load.
/// - relayHints: Optional relay URLs where the event may be found (per NIP-01/NIP-10).
init(damus: DamusState, event_id: NoteId, relayHints: [RelayURL] = []) {
self.event_id = event_id
self.damus = damus
self.event = nil
self.relayHints = relayHints
}
func Event(event: NostrEvent) -> some View {
@@ -39,7 +52,7 @@ struct BuilderEventView: View {
if let event {
self.Event(event: event)
} else {
EventLoaderView(damus_state: damus, event_id: self.event_id) { loaded_event in
EventLoaderView(damus_state: damus, event_id: self.event_id, relayHints: relayHints) { loaded_event in
self.Event(event: loaded_event)
}
}

View File

@@ -7,18 +7,29 @@
import SwiftUI
/// This view handles the loading logic for Nostr events, so that you can easily use views that require `NostrEvent`, even if you only have a `NoteId`
/// This view handles the loading logic for Nostr events, so that you can easily use views that require `NostrEvent`, even if you only have a `NoteId`.
///
/// Supports NIP-01/NIP-10 relay hints to fetch events from relays not in the user's pool.
struct EventLoaderView<Content: View>: View {
let damus_state: DamusState
let event_id: NoteId
let relayHints: [RelayURL]
@State var event: NostrEvent?
@State var subscription_uuid: String = UUID().description
@State var loadingTask: Task<Void, Never>? = nil
let content: (NostrEvent) -> Content
init(damus_state: DamusState, event_id: NoteId, @ViewBuilder content: @escaping (NostrEvent) -> Content) {
/// Creates an event loader view.
///
/// - Parameters:
/// - damus_state: The app's shared state.
/// - event_id: The ID of the event to load.
/// - relayHints: Optional relay URLs where the event may be found (per NIP-01/NIP-10).
/// - content: A view builder that receives the loaded event.
init(damus_state: DamusState, event_id: NoteId, relayHints: [RelayURL] = [], @ViewBuilder content: @escaping (NostrEvent) -> Content) {
self.damus_state = damus_state
self.event_id = event_id
self.relayHints = relayHints
self.content = content
let event = damus_state.events.lookup(event_id)
_event = State(initialValue: event)
@@ -31,8 +42,19 @@ struct EventLoaderView<Content: View>: View {
func subscribe() {
self.loadingTask?.cancel()
self.loadingTask = Task {
let lender = try? await damus_state.nostrNetwork.reader.lookup(noteId: self.event_id)
let targetRelays = relayHints.isEmpty ? nil : relayHints
#if DEBUG
if let targetRelays, !targetRelays.isEmpty {
print("[relay-hints] EventLoaderView: Loading event \(event_id.hex().prefix(8))... with \(targetRelays.count) relay hint(s): \(targetRelays.map { $0.absoluteString })")
}
#endif
let lender = try? await damus_state.nostrNetwork.reader.lookup(noteId: self.event_id, to: targetRelays)
lender?.justUseACopy({ event = $0 })
#if DEBUG
if let targetRelays, !targetRelays.isEmpty {
print("[relay-hints] EventLoaderView: Event \(event_id.hex().prefix(8))... loaded: \(event != nil)")
}
#endif
}
}

View File

@@ -35,12 +35,12 @@ struct EventShell<Content: View>: View {
!options.contains(.no_action_bar)
}
func get_mention(ndb: Ndb) -> Mention<NoteId>? {
func get_mention(ndb: Ndb) -> NoteMentionWithHints? {
if self.options.contains(.nested) || self.options.contains(.no_mentions) {
return nil
}
return first_eref_mention(ndb: ndb, ev: event, keypair: state.keypair)
return first_eref_mention_with_hints(ndb: ndb, ev: event, keypair: state.keypair)
}
var ActionBar: some View {
@@ -76,7 +76,7 @@ struct EventShell<Content: View>: View {
content
if let mention = get_mention(ndb: state.ndb) {
MentionView(damus_state: state, mention: mention)
MentionView(damus_state: state, mention: .note(mention.noteId, index: mention.index), relayHints: mention.relayHints)
}
if has_action_bar {
@@ -108,7 +108,7 @@ struct EventShell<Content: View>: View {
if !options.contains(.no_mentions),
let mention = get_mention(ndb: state.ndb)
{
MentionView(damus_state: state, mention: mention)
MentionView(damus_state: state, mention: .note(mention.noteId, index: mention.index), relayHints: mention.relayHints)
.padding(.horizontal)
}

View File

@@ -36,6 +36,11 @@ struct EventView: View {
if event.known_kind == .boost {
if let inner_ev = event.get_inner_event(cache: damus.events) {
RepostedEvent(damus: damus, event: event, inner_ev: inner_ev, options: options)
} else if let target = event.repostTarget() {
// Inner event not in cache - load using relay hints from e tag (NIP-18)
EventLoaderView(damus_state: damus, event_id: target.noteId, relayHints: target.relayHints) { loaded_event in
RepostedEvent(damus: damus, event: event, inner_ev: loaded_event, options: options)
}
} else {
EmptyView()
}

View File

@@ -7,19 +7,30 @@
import SwiftUI
/// A view that renders an inline mention of a Nostr event.
///
/// Supports NIP-01/NIP-10 relay hints to fetch events from relays not in the user's pool.
struct MentionView: View {
let damus_state: DamusState
let mention: Mention<NoteId>
init(damus_state: DamusState, mention: Mention<NoteId>) {
let relayHints: [RelayURL]
/// Creates a mention view.
///
/// - Parameters:
/// - damus_state: The app's shared state.
/// - mention: The mention containing the note ID.
/// - relayHints: Optional relay URLs where the event may be found (per NIP-01/NIP-10).
init(damus_state: DamusState, mention: Mention<NoteId>, relayHints: [RelayURL] = []) {
self.damus_state = damus_state
self.mention = mention
self.relayHints = relayHints
}
var body: some View {
EventLoaderView(damus_state: damus_state, event_id: mention.ref) { event in
EventLoaderView(damus_state: damus_state, event_id: mention.ref, relayHints: relayHints) { event in
EventMutingContainerView(damus_state: damus_state, event: event) {
BuilderEventView(damus: damus_state, event_id: mention.ref)
BuilderEventView(damus: damus_state, event_id: mention.ref, relayHints: relayHints)
}
}
}

View File

@@ -31,6 +31,9 @@ class LoadableNostrEventViewModel: ObservableObject {
Task { await self.load() }
}
/// Starts loading the referenced Nostr event and updates the view model's `state` with the result or a timeout outcome.
///
/// This launches a dedicated task that runs the loading logic and a separate timeout task that cancels the loader after `TIMEOUT`. If the timeout fires, `state` is set to `.not_found`. If the load finishes first, the timeout task is cancelled.
func load() async {
// Start the loading process in a separate task to manage the timeout independently.
let loadTask = Task { @MainActor in
@@ -48,18 +51,34 @@ class LoadableNostrEventViewModel: ObservableObject {
timeoutTask.cancel() // Cancel the timeout task if loading finishes earlier.
}
/// Asynchronously find an event from NostrDB or from the network (if not available on NostrDB)
private func loadEvent(noteId: NoteId) async -> NostrEvent? {
let res = await damus_state.nostrNetwork.reader.findEvent(query: .event(evid: noteId))
/// Loads the Nostr event identified by `noteId`, optionally restricting the lookup to specific relays.
/// - Parameters:
/// - relays: An array of relay URLs to restrict the lookup to. If empty, the lookup is not restricted to any relays.
/// - Returns: The `NostrEvent` matching `noteId` if found, `nil` otherwise.
private func loadEvent(noteId: NoteId, relays: [RelayURL]) async -> NostrEvent? {
let targetRelays = relays.isEmpty ? nil : relays
let res = await damus_state.nostrNetwork.reader.findEvent(query: .event(evid: noteId, find_from: targetRelays))
guard let res, case .event(let ev) = res else { return nil }
return ev
}
/// Gets the note reference and tries to load it, outputting a new state for this view model.
/// Resolve a NoteReference into a ThreadModelLoadingState describing how the referenced note should be presented.
///
/// For a `.note_id` reference this attempts to load the event (honoring optional relay hints) and maps event kinds as follows:
/// - `.text` or `.highlight` `.loaded` with a `Route.Thread`.
/// - `.dm` `.loaded` with a `Route.DMChat` for the corresponding DM model.
/// - `.like` follows the first referenced note ID (propagating the same relay hints) and resolves it recursively.
/// - `.zap` or `.zap_request` resolves a zap and, if found, returns `.loaded` with a `Route.Zaps`.
/// - any other known kind `.unknown_or_unsupported_kind`.
/// If the event cannot be retrieved or a required referenced note/zap is missing, returns `.not_found`.
///
/// For an `.naddr` reference this looks up the event (using relays from the NAddr if provided) and returns `.loaded` with a `Route.Thread` when found or `.not_found` when not found.
/// - Parameter note_reference: The note identifier to resolve; may include relay hints for relay-aware lookup.
/// - Returns: A `ThreadModelLoadingState` indicating the resolved presentation route (`.loaded(route: Route)`), `.not_found` when the note or required referenced data is missing, or `.unknown_or_unsupported_kind` when the event kind cannot be presented.
private func executeLoadingLogic(note_reference: NoteReference) async -> ThreadModelLoadingState {
switch note_reference {
case .note_id(let note_id):
guard let ev = await self.loadEvent(noteId: note_id) else { return .not_found }
case .note_id(let note_id, let relays):
guard let ev = await self.loadEvent(noteId: note_id, relays: relays) else { return .not_found }
guard let known_kind = ev.known_kind else { return .unknown_or_unsupported_kind }
switch known_kind {
case .text, .highlight, .longform:
@@ -68,9 +87,9 @@ class LoadableNostrEventViewModel: ObservableObject {
let dm_model = damus_state.dms.lookup_or_create(ev.pubkey)
return .loaded(route: Route.DMChat(dms: dm_model))
case .like:
// Load the event that this reaction refers to.
guard let first_referenced_note_id = ev.referenced_ids.first else { return .not_found }
return await self.executeLoadingLogic(note_reference: .note_id(first_referenced_note_id))
// Pass the same relay hints - the referenced note is likely on the same relay as the reaction
return await self.executeLoadingLogic(note_reference: .note_id(first_referenced_note_id, relays: relays))
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))
@@ -78,9 +97,8 @@ class LoadableNostrEventViewModel: ObservableObject {
return .unknown_or_unsupported_kind
}
case .naddr(let naddr):
guard let event = await damus_state.nostrNetwork.reader.lookup(naddr: naddr) else {
return .not_found
}
let targetRelays = naddr.relays.isEmpty ? nil : naddr.relays
guard let event = await damus_state.nostrNetwork.reader.lookup(naddr: naddr, to: targetRelays) else { return .not_found }
return .loaded(route: Route.Thread(thread: ThreadModel(event: event, damus_state: damus_state)))
}
}
@@ -93,7 +111,7 @@ class LoadableNostrEventViewModel: ObservableObject {
}
enum NoteReference: Hashable {
case note_id(NoteId)
case note_id(NoteId, relays: [RelayURL])
case naddr(NAddr)
}
}
@@ -273,5 +291,5 @@ extension LoadableNostrEventView {
}
#Preview("Loadable") {
LoadableNostrEventView(state: test_damus_state, note_reference: .note_id(test_thread_note_1.id))
}
LoadableNostrEventView(state: test_damus_state, note_reference: .note_id(test_thread_note_1.id, relays: []))
}

View File

@@ -93,8 +93,8 @@ struct SelectedEventView: View {
var Mention: some View {
Group {
if let mention = first_eref_mention(ndb: damus.ndb, ev: event, keypair: damus.keypair) {
MentionView(damus_state: damus, mention: mention)
if let mention = first_eref_mention_with_hints(ndb: damus.ndb, ev: event, keypair: damus.keypair) {
MentionView(damus_state: damus, mention: .note(mention.noteId, index: mention.index), relayHints: mention.relayHints)
.padding(.horizontal)
}
}

View File

@@ -921,21 +921,25 @@ private func isAlphanumeric(_ char: Character) -> Bool {
return char.isLetter || char.isNumber
}
/// Generates NIP-10 compliant e-tags for replies.
/// Format: `["e", <event-id>, <relay-url>, <marker>, <pubkey>]`
func nip10_reply_tags(replying_to: NostrEvent, keypair: Keypair, relayURL: RelayURL?) -> [[String]] {
guard let nip10 = replying_to.thread_reply() else {
// we're replying to a post that isn't in a thread,
// just add a single reply-to-root tag
return [["e", replying_to.id.hex(), relayURL?.absoluteString ?? "", "root"]]
return [["e", replying_to.id.hex(), relayURL?.absoluteString ?? "", "root", replying_to.pubkey.hex()]]
}
// otherwise use the root tag from the parent's nip10 reply and include the note
// that we are replying to's note id.
let tags = [
["e", nip10.root.note_id.hex(), nip10.root.relay ?? "", "root"],
["e", replying_to.id.hex(), relayURL?.absoluteString ?? "", "reply"]
]
var rootTag = ["e", nip10.root.note_id.hex(), nip10.root.relay ?? "", "root"]
if let rootPubkey = nip10.root.pubkey {
rootTag.append(rootPubkey.hex())
}
return tags
let replyTag = ["e", replying_to.id.hex(), relayURL?.absoluteString ?? "", "reply", replying_to.pubkey.hex()]
return [rootTag, replyTag]
}
func build_post(state: DamusState, action: PostAction, draft: DraftArtifacts) async -> NostrPost {

View File

@@ -101,18 +101,18 @@ struct InnerSearchResults: View {
case .nip05(let addr):
SearchingEventView(state: damus_state, search_type: .nip05(addr))
case .profile(let pubkey):
SearchingEventView(state: damus_state, search_type: .profile(pubkey))
SearchingEventView(state: damus_state, search_type: .profile(pubkey, relays: []))
case .hex(let h):
VStack(spacing: 10) {
SearchingEventView(state: damus_state, search_type: .event(NoteId(h)))
SearchingEventView(state: damus_state, search_type: .profile(Pubkey(h)))
}
SearchingEventView(state: damus_state, search_type: .event(NoteId(h), relays: []))
SearchingEventView(state: damus_state, search_type: .profile(Pubkey(h), relays: []))
}
case .note(let nid):
SearchingEventView(state: damus_state, search_type: .event(nid))
SearchingEventView(state: damus_state, search_type: .event(nid, relays: []))
case .nevent(let nevent):
SearchingEventView(state: damus_state, search_type: .event(nevent.noteid))
SearchingEventView(state: damus_state, search_type: .event(nevent.noteid, relays: nevent.relays))
case .nprofile(let nprofile):
SearchingEventView(state: damus_state, search_type: .profile(nprofile.author))
SearchingEventView(state: damus_state, search_type: .profile(nprofile.author, relays: nprofile.relays))
case .naddr(let naddr):
SearchingEventView(state: damus_state, search_type: .naddr(naddr))
case .multi(let multi):
@@ -199,13 +199,12 @@ struct SearchResultsView: View {
}
}
/*
struct SearchResultsView_Previews: PreviewProvider {
static var previews: some View {
SearchResultsView(damus_state: test_damus_state(), s)
}
}
*/
/// Interprets a raw search string and maps it to an appropriate `Search` case.
/// - Parameters:
/// - profiles: Profile index used when resolving profile-lookups from the query.
/// - contacts: Contact list used to prioritize or resolve profile-lookups.
/// - search new: The raw user-provided search string to interpret.
/// - Returns: A `Search` value representing the parsed query (e.g., `.nip05`, `.hashtag`, `.hex`, `.profile`, `.note`, `.nevent`, `.nprofile`, `.naddr`, or `.multi`), or `nil` if the input string is empty.
@MainActor
func search_for_string(profiles: Profiles, contacts: Contacts, search new: String) -> Search? {
@@ -240,6 +239,10 @@ func search_for_string(profiles: Profiles, contacts: Contacts, search new: Strin
}
if searchQuery.starts(with: "nevent"), case let .nevent(nevent) = Bech32Object.parse(searchQuery) {
#if DEBUG
print("[nevent] Parsed note ID: \(nevent.noteid.hex())")
print("[nevent] Parsed \(nevent.relays.count) relay hints: \(nevent.relays.map { $0.absoluteString })")
#endif
return .nevent(nevent)
}

View File

@@ -15,8 +15,8 @@ enum SearchState {
}
enum SearchType: Equatable {
case event(NoteId)
case profile(Pubkey)
case event(NoteId, relays: [RelayURL])
case profile(Pubkey, relays: [RelayURL])
case nip05(String)
case naddr(NAddr)
}
@@ -32,15 +32,23 @@ struct SearchingEventView: View {
switch search_type {
case .nip05:
return "Nostr Address"
case .profile:
case .profile(_, _):
return "Profile"
case .event:
case .event(_, _):
return "Note"
case .naddr:
return "Naddr"
}
}
/// Performs the search described by `search` and updates `search_state` with the outcome.
///
/// Starts the lookup for the provided SearchType and transitions `search_state` from `.searching` to one of:
/// - `.found(event)` when the target event is located,
/// - `.found_profile(pubkey)` when a profile pubkey is located (including nip05 resolution),
/// - `.not_found` when the lookup fails or yields no result.
/// - Parameters:
/// - search: The SearchType to perform (nip05, event with optional relays, profile with optional relays, or naddr).
func handle_search(search: SearchType) {
self.search_state = .searching
@@ -76,18 +84,20 @@ struct SearchingEventView: View {
}
}
case .event(let note_id):
case .event(let note_id, let relays):
Task {
let res = await state.nostrNetwork.reader.findEvent(query: .event(evid: note_id))
let targetRelays = relays.isEmpty ? nil : relays
let res = await state.nostrNetwork.reader.findEvent(query: .event(evid: note_id, find_from: targetRelays))
guard case .event(let ev) = res else {
self.search_state = .not_found
return
}
self.search_state = .found(ev)
}
case .profile(let pubkey):
case .profile(let pubkey, let relays):
Task {
let res = await state.nostrNetwork.reader.findEvent(query: .profile(pubkey: pubkey))
let targetRelays = relays.isEmpty ? nil : relays
let res = await state.nostrNetwork.reader.findEvent(query: .profile(pubkey: pubkey, find_from: targetRelays))
guard case .profile(let pubkey) = res else {
self.search_state = .not_found
return
@@ -96,7 +106,8 @@ struct SearchingEventView: View {
}
case .naddr(let naddr):
Task {
let res = await state.nostrNetwork.reader.lookup(naddr: naddr)
let targetRelays = naddr.relays.isEmpty ? nil : naddr.relays
let res = await state.nostrNetwork.reader.lookup(naddr: naddr, to: targetRelays)
guard let res = res else {
self.search_state = .not_found
return
@@ -141,6 +152,6 @@ struct SearchingEventView: View {
struct SearchingEventView_Previews: PreviewProvider {
static var previews: some View {
let state = test_damus_state
SearchingEventView(state: state, search_type: .event(test_note.id))
SearchingEventView(state: state, search_type: .event(test_note.id, relays: []))
}
}
}

View File

@@ -20,13 +20,23 @@ struct DamusURLHandler {
/// - Parameters:
/// - damus_state: The Damus state. May be mutated as part of this function
/// - url: The URL to be opened
/// - Returns: A view to be shown to the user
/// Computes the UI action to perform for an incoming URL and returns the corresponding view or sheet action.
/// - Parameters:
/// - damus_state: The app state used for constructing models, performing network lookups, opening wallet connections, and handling Purple URLs. This state may be mutated (for example, when initiating a wallet connection or delegating to the Purple handler).
/// - url: The incoming URL to parse and handle.
/// - Returns: A `ContentView.ViewOpenAction` that represents the action to take for the URL typically a route to a specific view (profile, thread, search, wallet, script, loadable event), a sheet (error or wallet selection), or an external URL action if applicable. If the URL cannot be parsed, the returned action is an error sheet describing the failure.
static func handle_opening_url_and_compute_view_action(damus_state: DamusState, url: URL) async -> ContentView.ViewOpenAction {
let parsed_url_info = parse_url(url: url)
switch parsed_url_info {
case .profile(let pubkey):
return .route(.ProfileByKey(pubkey: pubkey))
case .profile_reference(let pubkey, let relays):
guard !relays.isEmpty else { return .route(.ProfileByKey(pubkey: pubkey)) }
Task {
let _ = await damus_state.nostrNetwork.reader.findEvent(query: .profile(pubkey: pubkey, find_from: relays))
}
return .route(.ProfileByKey(pubkey: pubkey))
case .filter(let nostrFilter):
let search = SearchModel(state: damus_state, search: nostrFilter)
return .route(.Search(search: search))
@@ -67,7 +77,11 @@ struct DamusURLHandler {
/// This function does not cause any mutations on the app, or any side-effects.
///
/// - Parameter url: The URL to be parsed
/// - Returns: Structured information about the contents inside the URL. Returns `nil` if URL is not compatible, invalid, or could not be parsed for some reason.
/// Interprets a URL as a Damus/nostr resource and produces the corresponding ParsedURLInfo.
///
/// Recognizes Damus purple links, WalletConnect URLs, Bech32 nevent/nprofile forms (including relay hints),
/// and decoded nostr URIs for refs (pubkey, event, naddr, hashtag), filters, scripts, and invoices.
/// - Returns: A `ParsedURLInfo` describing the interpreted resource, or `nil` if the URL cannot be interpreted.
static func parse_url(url: URL) -> ParsedURLInfo? {
if let purple_url = DamusPurpleURL(url: url) {
return .purple(purple_url)
@@ -76,7 +90,26 @@ struct DamusURLHandler {
if let nwc = WalletConnectURL(str: url.absoluteString) {
return .wallet_connect(nwc)
}
// Parse nevent/nprofile directly since decode_nostr_uri discards relay hints
let uri = remove_nostr_uri_prefix(url.absoluteString)
if uri.hasPrefix("nevent"), case .nevent(let nevent) = Bech32Object.parse(uri) {
#if DEBUG
if !nevent.relays.isEmpty {
print("[relay-hints] URL nevent: Found \(nevent.relays.count) hint(s) for \(nevent.noteid.hex().prefix(8))...: \(nevent.relays.map { $0.absoluteString })")
}
#endif
return .event_reference(.note_id(nevent.noteid, relays: nevent.relays))
}
if uri.hasPrefix("nprofile"), case .nprofile(let nprofile) = Bech32Object.parse(uri) {
#if DEBUG
if !nprofile.relays.isEmpty {
print("[relay-hints] URL nprofile: Found \(nprofile.relays.count) hint(s) for \(nprofile.author.hex().prefix(8))...: \(nprofile.relays.map { $0.absoluteString })")
}
#endif
return .profile_reference(nprofile.author, relays: nprofile.relays)
}
guard let link = decode_nostr_uri(url.absoluteString) else {
return nil
}
@@ -87,7 +120,7 @@ struct DamusURLHandler {
case .pubkey(let pk):
return .profile(pk)
case .event(let noteid):
return .event_reference(.note_id(noteid))
return .event_reference(.note_id(noteid, relays: []))
case .hashtag(let ht):
return .filter(.filter_hashtag([ht.hashtag]))
case .param, .quote, .reference:
@@ -111,6 +144,7 @@ struct DamusURLHandler {
enum ParsedURLInfo {
case profile(Pubkey)
case profile_reference(Pubkey, relays: [RelayURL])
case filter(NostrFilter)
case event(NostrEvent)
case event_reference(LoadableNostrEventViewModel.NoteReference)
@@ -119,4 +153,4 @@ struct DamusURLHandler {
case purple(DamusPurpleURL)
case invoice(Invoice)
}
}
}

View File

@@ -147,6 +147,12 @@ final class NIP10Tests: XCTestCase {
XCTAssertEqual(tr.is_reply_to_root, true)
}
/// Tests NIP-10 relay hint behavior and pubkey propagation in reply tags.
///
/// Validates that when building a reply:
/// - Root "e" tag lacks pubkey (since parent's e-tag didn't include one)
/// - Reply "e" tag includes the `replying_to.pubkey` at position 4
/// - Corresponding "p" tags are present for propagated pubkeys
func test_marker_reply() async {
let note_json = """
{
@@ -184,10 +190,13 @@ final class NIP10Tests: XCTestCase {
let reply = await build_post(state: test_damus_state, post: .init(string: "hello"), action: .replying_to(note), uploadedMedias: [], pubkeys: [pk] + note.referenced_pubkeys.map({pk in pk}))
let root_hex = "00152d2945459fb394fed2ea95af879c903c4ec42d96327a739fa27c023f20e0"
// NIP-10 format: ["e", <event-id>, <relay-url>, <marker>, <pubkey>]
// Root tag doesn't have pubkey since parent event's e-tag didn't include one
// Reply tag includes replying_to.pubkey
XCTAssertEqual(reply.tags,
[
["e", root_hex, "wss://nostr.mutinywallet.com/", "root"],
["e", replying_to_hex, "", "reply"],
["e", replying_to_hex, "", "reply", "5b0183ab6c3e322bf4d41c6b3aef98562a144847b7499543727c5539a114563e"],
["p", "5b0183ab6c3e322bf4d41c6b3aef98562a144847b7499543727c5539a114563e"],
["p", "6e75f7972397ca3295e0f4ca0fbc6eb9cc79be85bafdd56bd378220ca8eee74e"],
])
@@ -254,4 +263,175 @@ final class NIP10Tests: XCTestCase {
}
}
// MARK: - Relay Hints Tests
/// Tests that NoteRef correctly parses pubkey from position 4 of e-tags per NIP-10.
func test_noteref_parses_pubkey_from_etag() {
// NIP-10 format: ["e", <event-id>, <relay-url>, <marker>, <pubkey>]
let eventIdHex = "a32d70d331f4bea7a859ac71d85a9b4e0c2d1fa9aaf7237a17f85a6227f52fdb"
let pubkeyHex = "f7dac46aa270f7287606a22beb4d7725573afa0e028cdfac39a4cb2331537f66"
let relayUrl = "wss://relay.example.com"
let tags = [
["e", eventIdHex, relayUrl, "reply", pubkeyHex]
]
let note = NdbNote(content: "test", keypair: test_keypair, kind: 1, tags: tags)!
let noteRefs = Array(note.referenced_noterefs)
XCTAssertEqual(noteRefs.count, 1)
let noteRef = noteRefs.first!
XCTAssertEqual(noteRef.note_id.hex(), eventIdHex)
XCTAssertEqual(noteRef.relay, relayUrl)
XCTAssertEqual(noteRef.marker, .reply)
XCTAssertEqual(noteRef.pubkey?.hex(), pubkeyHex)
}
/// Tests that NoteRef handles e-tags without pubkey (backwards compatibility).
func test_noteref_parses_without_pubkey() {
let eventIdHex = "a32d70d331f4bea7a859ac71d85a9b4e0c2d1fa9aaf7237a17f85a6227f52fdb"
let relayUrl = "wss://relay.example.com"
let tags = [
["e", eventIdHex, relayUrl, "root"]
]
let note = NdbNote(content: "test", keypair: test_keypair, kind: 1, tags: tags)!
let noteRefs = Array(note.referenced_noterefs)
XCTAssertEqual(noteRefs.count, 1)
let noteRef = noteRefs.first!
XCTAssertEqual(noteRef.note_id.hex(), eventIdHex)
XCTAssertEqual(noteRef.relay, relayUrl)
XCTAssertEqual(noteRef.marker, .root)
XCTAssertNil(noteRef.pubkey)
}
/// Tests that NoteRef.tag includes pubkey when present.
func test_noteref_tag_includes_pubkey() {
let noteId = NoteId(hex: "a32d70d331f4bea7a859ac71d85a9b4e0c2d1fa9aaf7237a17f85a6227f52fdb")!
let pubkey = Pubkey(hex: "f7dac46aa270f7287606a22beb4d7725573afa0e028cdfac39a4cb2331537f66")!
let relayUrl = "wss://relay.example.com"
let noteRef = NoteRef(note_id: noteId, relay: relayUrl, marker: .reply, pubkey: pubkey)
let tag = noteRef.tag
XCTAssertEqual(tag.count, 5)
XCTAssertEqual(tag[0], "e")
XCTAssertEqual(tag[1], noteId.hex())
XCTAssertEqual(tag[2], relayUrl)
XCTAssertEqual(tag[3], "reply")
XCTAssertEqual(tag[4], pubkey.hex())
}
/// Tests that NoteRef.tag omits pubkey when nil.
func test_noteref_tag_omits_pubkey_when_nil() {
let noteId = NoteId(hex: "a32d70d331f4bea7a859ac71d85a9b4e0c2d1fa9aaf7237a17f85a6227f52fdb")!
let relayUrl = "wss://relay.example.com"
let noteRef = NoteRef(note_id: noteId, relay: relayUrl, marker: .root, pubkey: nil)
let tag = noteRef.tag
XCTAssertEqual(tag.count, 4)
XCTAssertEqual(tag[0], "e")
XCTAssertEqual(tag[1], noteId.hex())
XCTAssertEqual(tag[2], relayUrl)
XCTAssertEqual(tag[3], "root")
}
/// Tests nip10_reply_tags includes pubkey when replying directly to a note.
func test_nip10_reply_tags_direct_reply_includes_pubkey() {
let parentNote = NostrEvent(
content: "parent note",
keypair: test_keypair,
kind: 1,
tags: []
)!
let relayUrl = RelayURL("wss://relay.example.com")
let tags = nip10_reply_tags(replying_to: parentNote, keypair: test_keypair, relayURL: relayUrl)
XCTAssertEqual(tags.count, 1)
let rootTag = tags[0]
// Should be: ["e", <id>, <relay>, "root", <pubkey>]
XCTAssertEqual(rootTag.count, 5)
XCTAssertEqual(rootTag[0], "e")
XCTAssertEqual(rootTag[1], parentNote.id.hex())
XCTAssertEqual(rootTag[2], "wss://relay.example.com")
XCTAssertEqual(rootTag[3], "root")
XCTAssertEqual(rootTag[4], parentNote.pubkey.hex())
}
/// Tests nip10_reply_tags preserves root pubkey from parent's e-tag.
func test_nip10_reply_tags_preserves_root_pubkey() {
let rootId = "a32d70d331f4bea7a859ac71d85a9b4e0c2d1fa9aaf7237a17f85a6227f52fdb"
let rootPubkey = "f7dac46aa270f7287606a22beb4d7725573afa0e028cdfac39a4cb2331537f66"
// Parent note is a reply to some root
let parentNote = NdbNote(
content: "reply to root",
keypair: test_keypair,
kind: 1,
tags: [
["e", rootId, "wss://root-relay.com", "root", rootPubkey]
]
)!
let relayUrl = RelayURL("wss://reply-relay.com")
let tags = nip10_reply_tags(replying_to: parentNote, keypair: test_keypair, relayURL: relayUrl)
XCTAssertEqual(tags.count, 2)
// Root tag should preserve pubkey from parent's e-tag
let rootTag = tags[0]
XCTAssertEqual(rootTag.count, 5)
XCTAssertEqual(rootTag[0], "e")
XCTAssertEqual(rootTag[1], rootId)
XCTAssertEqual(rootTag[3], "root")
XCTAssertEqual(rootTag[4], rootPubkey)
// Reply tag should include parent's pubkey
let replyTag = tags[1]
XCTAssertEqual(replyTag.count, 5)
XCTAssertEqual(replyTag[0], "e")
XCTAssertEqual(replyTag[1], parentNote.id.hex())
XCTAssertEqual(replyTag[3], "reply")
XCTAssertEqual(replyTag[4], parentNote.pubkey.hex())
}
/// Tests relay hint extraction from e-tags.
func test_relay_hint_extraction_from_etag() {
let eventIdHex = "a32d70d331f4bea7a859ac71d85a9b4e0c2d1fa9aaf7237a17f85a6227f52fdb"
let relayUrl = "wss://relay.example.com"
let tags = [
["e", eventIdHex, relayUrl, "reply"]
]
let note = NdbNote(content: "test", keypair: test_keypair, kind: 1, tags: tags)!
let noteRefs = Array(note.referenced_noterefs)
XCTAssertEqual(noteRefs.count, 1)
XCTAssertEqual(noteRefs.first?.relay, relayUrl)
}
/// Tests that empty relay hint is preserved as empty string.
func test_empty_relay_hint_preserved() {
let eventIdHex = "a32d70d331f4bea7a859ac71d85a9b4e0c2d1fa9aaf7237a17f85a6227f52fdb"
let tags = [
["e", eventIdHex, "", "reply"]
]
let note = NdbNote(content: "test", keypair: test_keypair, kind: 1, tags: tags)!
let noteRefs = Array(note.referenced_noterefs)
XCTAssertEqual(noteRefs.count, 1)
XCTAssertEqual(noteRefs.first?.relay, "")
}
}

View File

@@ -0,0 +1,549 @@
//
// RelayHintsTests.swift
// damus
//
// Created by Daniel DAquino on 2026-02-02.
//
import XCTest
import NostrSDK
@testable import damus
/// Tests for relay hints functionality, ensuring relay hints are correctly extracted and used
/// for ephemeral relay connections per NIP-01 and NIP-10.
///
/// These tests verify that:
/// - Relay hints are correctly extracted from tags
/// - Ephemeral relays can be added and managed by RelayPool
/// - Relay hint lease management prevents premature cleanup
final class RelayHintsTests: XCTestCase {
// MARK: - Helper Functions
/// Creates and runs a local relay on a random available port.
/// - Returns: The running LocalRelay instance
private func setupRelay() async throws -> LocalRelay {
let builder = RelayBuilder()
let relay = LocalRelay(builder: builder)
try await relay.run()
print("Relay url: \(await relay.url())")
return relay
}
// MARK: - Test Cases
/// Test that TagSequence correctly extracts relay hints from e-tags per NIP-10.
/// This verifies the basic relay hint extraction functionality.
func testTagSequenceExtractsRelayHints() {
// Given: An e-tag with a relay hint at position 2
let eventIdHex = "a32d70d331f4bea7a859ac71d85a9b4e0c2d1fa9aaf7237a17f85a6227f52fdb"
let relayUrl = "wss://relay.example.com"
let tags = [
["e", eventIdHex, relayUrl, "reply"]
]
let note = NdbNote(content: "test", keypair: test_keypair, kind: 1, tags: tags)!
// When: Accessing the first tag
let firstTag = note.tags[0]
// Then: Relay hint should be extracted correctly
XCTAssertEqual(firstTag.relayHint?.absoluteString, relayUrl)
XCTAssertEqual(firstTag.relayHints.count, 1)
XCTAssertEqual(firstTag.relayHints.first?.absoluteString, relayUrl)
}
/// Test that TagSequence handles tags without relay hints gracefully.
func testTagSequenceHandlesEmptyRelayHint() {
// Given: An e-tag with an empty relay hint
let eventIdHex = "a32d70d331f4bea7a859ac71d85a9b4e0c2d1fa9aaf7237a17f85a6227f52fdb"
let tags = [
["e", eventIdHex, "", "reply"]
]
let note = NdbNote(content: "test", keypair: test_keypair, kind: 1, tags: tags)!
// When: Accessing the first tag
let firstTag = note.tags[0]
// Then: Relay hint should be nil for empty string
XCTAssertNil(firstTag.relayHint)
XCTAssertEqual(firstTag.relayHints.count, 0)
}
/// Test that TagSequence handles tags with fewer than 3 elements (no relay hint position).
func testTagSequenceHandlesShortTags() {
// Given: An e-tag with only 2 elements (no relay hint position)
let eventIdHex = "a32d70d331f4bea7a859ac71d85a9b4e0c2d1fa9aaf7237a17f85a6227f52fdb"
let tags = [
["e", eventIdHex]
]
let note = NdbNote(content: "test", keypair: test_keypair, kind: 1, tags: tags)!
// When: Accessing the first tag
let firstTag = note.tags[0]
// Then: Relay hint should be nil
XCTAssertNil(firstTag.relayHint)
XCTAssertEqual(firstTag.relayHints.count, 0)
}
/// Test that RelayPool can add ephemeral relays.
/// This verifies the basic ephemeral relay management functionality.
func testRelayPoolAddsEphemeralRelay() async throws {
// Given: A relay pool and a relay descriptor marked as ephemeral
let ndb = await test_damus_state.ndb
let pool = RelayPool(ndb: ndb, keypair: test_keypair)
let testRelay = try await setupRelay()
let testRelayUrl = RelayURL(await testRelay.url().description)!
let descriptor = RelayPool.RelayDescriptor(url: testRelayUrl, info: .readWrite, variant: .ephemeral)
// When: Adding the ephemeral relay
try await pool.add_relay(descriptor)
// Then: The relay should be in the pool and marked as ephemeral
let descriptors = await pool.all_descriptors
let ephemeralRelays = descriptors.filter { $0.ephemeral }
XCTAssertEqual(ephemeralRelays.count, 1, "Should have exactly one ephemeral relay")
XCTAssertEqual(ephemeralRelays.first?.url, testRelayUrl)
XCTAssertTrue(ephemeralRelays.first?.ephemeral ?? false)
// Cleanup
await pool.close()
}
/// Test that ephemeral relay lease management works correctly.
/// This ensures ephemeral relays track leases and can be released.
func testEphemeralRelayLeaseManagement() async throws {
// Given: A relay pool with an ephemeral relay
let ndb = await test_damus_state.ndb
let pool = RelayPool(ndb: ndb, keypair: test_keypair)
let testRelay = try await setupRelay()
let testRelayUrl = RelayURL(await testRelay.url().description)!
let descriptor = RelayPool.RelayDescriptor(url: testRelayUrl, info: .readWrite, variant: .ephemeral)
try await pool.add_relay(descriptor)
// When: Acquiring a lease on the ephemeral relay
await pool.acquireEphemeralRelays([testRelayUrl])
// Then: The relay should still be in the pool
var descriptors = await pool.all_descriptors
var ephemeralRelays = descriptors.filter { $0.ephemeral }
XCTAssertEqual(ephemeralRelays.count, 1, "Should have ephemeral relay after acquiring lease")
// When: Releasing the lease
await pool.releaseEphemeralRelays([testRelayUrl])
// Give some time for cleanup
try await Task.sleep(for: .seconds(1))
// Then: The relay should be removed after releasing the lease
descriptors = await pool.all_descriptors
ephemeralRelays = descriptors.filter { $0.ephemeral && $0.url == testRelayUrl }
XCTAssertEqual(ephemeralRelays.count, 0, "Ephemeral relay should be removed after releasing lease")
// Cleanup
await pool.close()
}
/// Test that multiple leases prevent premature cleanup of ephemeral relays.
/// This ensures the reference counting mechanism works correctly.
func testMultipleLeasesPreventsCleanup() async throws {
// Given: A relay pool with an ephemeral relay
let ndb = await test_damus_state.ndb
let pool = RelayPool(ndb: ndb, keypair: test_keypair)
let testRelay = try await setupRelay()
let testRelayUrl = RelayURL(await testRelay.url().description)!
let descriptor = RelayPool.RelayDescriptor(url: testRelayUrl, info: .readWrite, variant: .ephemeral)
try await pool.add_relay(descriptor)
// When: Acquiring two leases on the same ephemeral relay
await pool.acquireEphemeralRelays([testRelayUrl])
await pool.acquireEphemeralRelays([testRelayUrl])
// Then: Releasing one lease should not remove the relay
await pool.releaseEphemeralRelays([testRelayUrl])
try await Task.sleep(for: .milliseconds(500))
var descriptors = await pool.all_descriptors
var ephemeralRelays = descriptors.filter { $0.ephemeral && $0.url == testRelayUrl }
XCTAssertEqual(ephemeralRelays.count, 1, "Should still have ephemeral relay after releasing one of two leases")
// When: Releasing the second lease
await pool.releaseEphemeralRelays([testRelayUrl])
try await Task.sleep(for: .seconds(1))
// Then: The relay should be removed after all leases are released
descriptors = await pool.all_descriptors
ephemeralRelays = descriptors.filter { $0.ephemeral && $0.url == testRelayUrl }
XCTAssertEqual(ephemeralRelays.count, 0, "Ephemeral relay should be removed after releasing all leases")
// Cleanup
await pool.close()
}
/// Test that ensureConnected adds missing relays as ephemeral.
/// This verifies the automatic ephemeral relay addition when using relay hints.
func testEnsureConnectedAddsEphemeralRelays() async throws {
// Given: A relay pool without any relays
let ndb = await test_damus_state.ndb
let pool = RelayPool(ndb: ndb, keypair: test_keypair)
let testRelay = try await setupRelay()
let testRelayUrl = RelayURL(await testRelay.url().description)!
// Initially no relays
var descriptors = await pool.all_descriptors
XCTAssertEqual(descriptors.count, 0, "Should have no relays initially")
// When: Ensuring connection to a relay not in the pool
let connectedRelays = await pool.ensureConnected(to: [testRelayUrl], timeout: .seconds(3))
// Then: The relay should be added as ephemeral
descriptors = await pool.all_descriptors
let ephemeralRelays = descriptors.filter { $0.ephemeral }
XCTAssertGreaterThan(descriptors.count, 0, "Should have added the relay")
XCTAssertEqual(ephemeralRelays.count, 1, "Should have one ephemeral relay")
XCTAssertEqual(ephemeralRelays.first?.url, testRelayUrl)
print("Connected relays: \(connectedRelays.map { $0.absoluteString })")
// Cleanup
await pool.close()
}
/// Test that relay hints enable fetching events from relays not in the user's pool.
/// This is an end-to-end integration test that verifies:
/// - A note exists on relayA (with the note)
/// - User is connected to relayB (empty, no notes)
/// - Using a relay hint to relayA allows fetching the note successfully
func testRelayHintFetchesEventFromCorrectRelay() async throws {
// Given: Two relays - one with a note, one empty
let relayWithNote = try await setupRelay()
let emptyRelay = try await setupRelay()
let relayWithNoteUrl = RelayURL(await relayWithNote.url().description)!
let emptyRelayUrl = RelayURL(await emptyRelay.url().description)!
// Create a test note
let testNote = NostrEvent(content: "Test note on specific relay", keypair: test_keypair)!
// Send the note to relayWithNote only
let connectionToRelayWithNote = await connectToRelay(url: relayWithNoteUrl, label: "RelayWithNote")
sendEvents([testNote], to: connectionToRelayWithNote)
// Wait for the event to be received by the relay
try await Task.sleep(for: .milliseconds(500))
// When: Network manager is connected ONLY to the empty relay (not relayWithNote)
let ndb = await test_damus_state.ndb
let networkManager = try await setupNetworkManager(with: [emptyRelayUrl], ndb: ndb)
// Verify the note is NOT in local NDB yet
let localNote = try? ndb.lookup_note_and_copy(testNote.id)
XCTAssertNil(localNote, "Note should not be in local NDB yet")
// Try to fetch WITHOUT relay hint (should fail since note is not on emptyRelay)
let lenderWithoutHint = try? await networkManager.reader.lookup(noteId: testNote.id, to: nil, timeout: .seconds(2))
XCTAssertNil(lenderWithoutHint, "Should not find note without relay hint (note is not on emptyRelay)")
// Then: Fetch WITH relay hint to relayWithNote (should succeed)
let lenderWithHint = try? await networkManager.reader.lookup(noteId: testNote.id, to: [relayWithNoteUrl], timeout: .seconds(5))
XCTAssertNotNil(lenderWithHint, "Should find note using relay hint")
// Verify the found note matches the original
var foundNote: NostrEvent?
lenderWithHint?.justUseACopy({ foundNote = $0 })
XCTAssertNotNil(foundNote, "Should be able to extract note from lender")
XCTAssertEqual(foundNote?.id, testNote.id, "Found note should match original")
XCTAssertEqual(foundNote?.content, testNote.content, "Note content should match")
// Cleanup
await networkManager.close()
}
/// Test that relay hints fall back to broadcasting when hinted relays don't respond.
/// This verifies the critical fallback mechanism that ensures notes can still be fetched
/// even when relay hints point to slow or unavailable relays.
func testRelayHintFallsBackToBroadcastWhenHintsDontRespond() async throws {
// Given: User has a relay with the note, but relay hint points to a relay WITHOUT the note
let userRelay = try await setupRelay()
let slowHintRelay = try await setupRelay()
let userRelayUrl = RelayURL(await userRelay.url().description)!
let slowHintRelayUrl = RelayURL(await slowHintRelay.url().description)!
// Create a test note
let testNote = NostrEvent(content: "Note for fallback test", keypair: test_keypair)!
// Send the note ONLY to user's relay (not to the hinted relay)
let userConnection = await connectToRelay(url: userRelayUrl, label: "UserRelay")
sendEvents([testNote], to: userConnection)
// Wait for the event to be received by the relay
try await Task.sleep(for: .milliseconds(500))
// When: Network manager is connected to user's relay
let ndb = await test_damus_state.ndb
let networkManager = try await setupNetworkManager(with: [userRelayUrl], ndb: ndb)
// Try to fetch WITH relay hint to slowHintRelay (which doesn't have the note)
// This should:
// 1. Try slowHintRelay first (will timeout/fail)
// 2. Fall back to broadcasting to userRelay
// 3. Successfully find the note
let lender = try? await networkManager.reader.lookup(noteId: testNote.id, to: [slowHintRelayUrl], timeout: .seconds(5))
// Then: Note should be found via fallback broadcast to user's relay
XCTAssertNotNil(lender, "Should find note via fallback broadcast despite bad relay hint")
var foundNote: NostrEvent?
lender?.justUseACopy({ foundNote = $0 })
XCTAssertNotNil(foundNote, "Should be able to extract note from lender")
XCTAssertEqual(foundNote?.id, testNote.id, "Found note should match original")
XCTAssertEqual(foundNote?.content, testNote.content, "Note content should match")
// Cleanup
await networkManager.close()
}
/// Test that relay hints from NIP-19 nevent entities are correctly used for lookups.
/// This verifies that nevent-style relay hints (common in nostr: URLs) work correctly.
func testRelayHintsFromNEventEntity() async throws {
// Given: A relay with a note, and nevent with relay hints
let hintedRelay = try await setupRelay()
let emptyRelay = try await setupRelay()
let hintedRelayUrl = RelayURL(await hintedRelay.url().description)!
let emptyRelayUrl = RelayURL(await emptyRelay.url().description)!
// Create a test note and send it to the hinted relay
let testNote = NostrEvent(content: "Note for nevent test", keypair: test_keypair)!
let hintedConnection = await connectToRelay(url: hintedRelayUrl, label: "HintedRelay")
sendEvents([testNote], to: hintedConnection)
try await Task.sleep(for: .milliseconds(500))
// When: Network manager is connected ONLY to empty relay
let ndb = await test_damus_state.ndb
let networkManager = try await setupNetworkManager(with: [emptyRelayUrl], ndb: ndb)
// Create an NEvent with relay hints (simulating NIP-19 parsing)
let nevent = NEvent(noteid: testNote.id, relays: [hintedRelayUrl])
// Verify nevent has relay hints
XCTAssertEqual(nevent.relays.count, 1, "NEvent should have one relay hint")
XCTAssertEqual(nevent.relays.first, hintedRelayUrl, "NEvent relay hint should match")
// Then: Use findEvent with nevent's relay hints (as it would be used in real code)
let targetRelays = nevent.relays.isEmpty ? nil : nevent.relays
let result = await networkManager.reader.findEvent(query: .event(evid: nevent.noteid, find_from: targetRelays))
// Verify we got the event back
guard case .event(let foundNote) = result else {
XCTFail("Should find note using nevent relay hints via findEvent")
return
}
XCTAssertEqual(foundNote.id, testNote.id, "Found note should match nevent note ID")
XCTAssertEqual(foundNote.content, testNote.content, "Found note content should match")
// Cleanup
await networkManager.close()
}
/// Test that relay hints work correctly when some events are cached in NDB.
/// This verifies that cached events are returned from NDB and relay hints are only
/// used for non-cached events, avoiding unnecessary network calls.
func testRelayHintsWithNDBCachedEvents() async throws {
// Given: Some notes cached in NDB, one note on a relay
let relay = try await setupRelay()
let relayUrl = RelayURL(await relay.url().description)!
// Create three notes
let cachedNoteA = NostrEvent(content: "Cached note A", keypair: test_keypair)!
let cachedNoteB = NostrEvent(content: "Cached note B", keypair: test_keypair)!
let uncachedNoteC = NostrEvent(content: "Uncached note C", keypair: test_keypair)!
// Store A and B in NDB (cached)
let ndb = await test_damus_state.ndb
storeEventsInNdb([cachedNoteA, cachedNoteB], ndb: ndb)
// Send only C to the relay
let connection = await connectToRelay(url: relayUrl, label: "Relay")
sendEvents([uncachedNoteC], to: connection)
try await Task.sleep(for: .milliseconds(500))
// When: Network manager is set up
let networkManager = try await setupNetworkManager(with: [relayUrl], ndb: ndb)
// Then: Fetch all three notes with relay hints
// Fetch cached note A (should come from NDB, not network)
let lenderA = try? await networkManager.reader.lookup(noteId: cachedNoteA.id, to: [relayUrl], timeout: .seconds(3))
XCTAssertNotNil(lenderA, "Should find cached note A")
var foundA: NostrEvent?
lenderA?.justUseACopy({ foundA = $0 })
XCTAssertEqual(foundA?.id, cachedNoteA.id, "Cached note A should match")
XCTAssertEqual(foundA?.content, "Cached note A", "Cached note A content should match")
// Fetch cached note B (should come from NDB, not network)
let lenderB = try? await networkManager.reader.lookup(noteId: cachedNoteB.id, to: [relayUrl], timeout: .seconds(3))
XCTAssertNotNil(lenderB, "Should find cached note B")
var foundB: NostrEvent?
lenderB?.justUseACopy({ foundB = $0 })
XCTAssertEqual(foundB?.id, cachedNoteB.id, "Cached note B should match")
XCTAssertEqual(foundB?.content, "Cached note B", "Cached note B content should match")
// Fetch uncached note C (should use relay hints to fetch from network)
let lenderC = try? await networkManager.reader.lookup(noteId: uncachedNoteC.id, to: [relayUrl], timeout: .seconds(3))
XCTAssertNotNil(lenderC, "Should find uncached note C via relay hints")
var foundC: NostrEvent?
lenderC?.justUseACopy({ foundC = $0 })
XCTAssertEqual(foundC?.id, uncachedNoteC.id, "Uncached note C should match")
XCTAssertEqual(foundC?.content, "Uncached note C", "Uncached note C content should match")
// Verify all notes were found correctly
XCTAssertNotNil(foundA, "Note A should be found from cache")
XCTAssertNotNil(foundB, "Note B should be found from cache")
XCTAssertNotNil(foundC, "Note C should be found from network")
// Cleanup
await networkManager.close()
}
// MARK: - Helper Functions for Integration Test
/// Connects to a relay and waits for the connection to be established.
/// - Parameters:
/// - url: The relay URL to connect to
/// - label: Optional label for logging
/// - Returns: The connected RelayConnection instance
private func connectToRelay(url: RelayURL, label: String = "") async -> RelayConnection {
var connectionContinuation: CheckedContinuation<Void, Never>?
let relayConnection = RelayConnection(url: url, handleEvent: { _ in }, processUnverifiedWSEvent: { wsEvent in
let prefix = label.isEmpty ? "" : "(\(label)) "
switch wsEvent {
case .connected:
connectionContinuation?.resume()
case .message(let message):
print("RELAY_HINTS_TEST \(prefix): Received: \(message)")
case .disconnected(let closeCode, let string):
print("RELAY_HINTS_TEST \(prefix): Disconnected: \(closeCode); \(String(describing: string))")
case .error(let error):
print("RELAY_HINTS_TEST \(prefix): Received error: \(error)")
}
})
relayConnection.connect()
// Wait for connection to be established
await withCheckedContinuation { continuation in
connectionContinuation = continuation
}
return relayConnection
}
/// Sends events to a relay connection.
/// - Parameters:
/// - events: Array of NostrEvent to send
/// - connection: The RelayConnection to send events through
private func sendEvents(_ events: [NostrEvent], to connection: RelayConnection) {
for event in events {
connection.send(.typical(.event(event)))
}
}
/// Stores events in NostrDB for testing purposes.
/// - Parameters:
/// - events: Array of NostrEvent to store in NDB
/// - ndb: The Ndb instance to store events in
private func storeEventsInNdb(_ events: [NostrEvent], ndb: Ndb) {
for event in events {
do {
try ndb.add(event: event)
} catch {
XCTFail("Failed to store event in NDB: \(error)")
}
}
}
/// Sets up a NostrNetworkManager with the specified relay URLs.
/// - Parameters:
/// - urls: Array of RelayURL to add to the manager
/// - ndb: The Ndb instance to use
/// - Returns: Configured and connected NostrNetworkManager
private func setupNetworkManager(with urls: [RelayURL], ndb: Ndb) async throws -> NostrNetworkManager {
let delegate = TestNetworkDelegate(ndb: ndb, keypair: test_keypair, bootstrapRelays: urls)
let networkManager = NostrNetworkManager(delegate: delegate, addNdbToRelayPool: true)
// Manually add relays to the pool
for url in urls {
do {
try await networkManager.userRelayList.insert(relay: .init(url: url, rwConfiguration: .readWrite), force: true)
}
catch {
switch error {
case .relayAlreadyExists: continue
default: throw error
}
}
}
// Only connect and wait if we have relays to connect to
if !urls.isEmpty {
await networkManager.userRelayList.connect()
// Wait for relay pool to be ready
try await Task.sleep(for: .seconds(2))
}
return networkManager
}
}
// MARK: - Test Doubles
/// Test delegate for NostrNetworkManager that provides minimal configuration for testing
private final class TestNetworkDelegate: NostrNetworkManager.Delegate {
var ndb: Ndb
var keypair: Keypair
var latestRelayListEventIdHex: String?
var latestContactListEvent: NostrEvent?
var bootstrapRelays: [RelayURL]
var developerMode: Bool = false
var experimentalLocalRelayModelSupport: Bool = false
var relayModelCache: RelayModelCache
var relayFilters: RelayFilters
var nwcWallet: WalletConnectURL?
init(ndb: Ndb, keypair: Keypair, bootstrapRelays: [RelayURL]) {
self.ndb = ndb
self.keypair = keypair
self.bootstrapRelays = bootstrapRelays
self.relayModelCache = RelayModelCache()
self.relayFilters = RelayFilters(our_pubkey: keypair.pubkey)
}
}

View File

@@ -32,4 +32,26 @@ extension NdbNote {
}
return self.parse_inner_event()
}
/// Returns the target event ID and relay hints for a repost (kind 6) event.
///
/// Per NIP-18, reposts MUST include an `e` tag with the reposted event's ID,
/// and the tag MUST include a relay URL as its third entry.
///
/// - Returns: A tuple of (noteId, relayHints) if this is a repost with a valid e tag, nil otherwise.
func repostTarget() -> (noteId: NoteId, relayHints: [RelayURL])? {
guard self.known_kind == .boost else { return nil }
for tag in self.tags {
guard tag.count >= 2 else { continue }
guard tag[0].matches_char("e") else { continue }
guard let noteIdData = tag[1].id() else { continue }
let noteId = NoteId(noteIdData)
let relayHints = tag.relayHints
return (noteId, relayHints)
}
return nil
}
}

View File

@@ -435,6 +435,10 @@ extension NdbNote {
References<QuoteId>(tags: self.tags)
}
public var referenced_quote_refs: References<QuoteRef> {
References<QuoteRef>(tags: self.tags)
}
public var referenced_noterefs: References<NoteRef> {
References<NoteRef>(tags: self.tags)
}
@@ -539,6 +543,14 @@ extension NdbNote {
return thread_reply()?.reply.note_id
}
/// Returns the direct reply reference with relay hint if available.
///
/// Per NIP-10, the reply `e` tag may include a relay URL at position 2 where
/// the replied-to event can be found.
public func direct_reply_ref() -> NoteRef? {
return thread_reply()?.reply
}
// NDBTODO: just use Id
public func thread_id() -> NoteId {
guard let root = self.thread_reply()?.root else {

View File

@@ -42,6 +42,36 @@ struct TagSequence: Sequence {
}
}
// MARK: - Relay Hint Extraction
extension TagSequence {
/// Extracts a relay URL hint from position 2 of the tag, if present and valid.
///
/// Per NIP-01 and NIP-10, position 2 in `e`, `p`, `a`, and `q` tags contains an optional
/// relay URL where the referenced entity may be found.
///
/// Example tag: `["e", "<event-id>", "wss://relay.example.com"]`
///
/// - Returns: A valid `RelayURL` if position 2 contains a non-empty, valid relay URL; `nil` otherwise.
var relayHint: RelayURL? {
guard count >= 3 else { return nil }
let urlString = self[2].string()
guard !urlString.isEmpty else { return nil }
return RelayURL(urlString)
}
/// Extracts relay hints from the tag as an array.
///
/// Currently tags only support a single relay hint at position 2, but this method
/// returns an array for consistency with `NEvent.relays` and future extensibility.
///
/// - Returns: An array containing the relay hint if present, or an empty array.
var relayHints: [RelayURL] {
guard let hint = relayHint else { return [] }
return [hint]
}
}
struct TagIterator: IteratorProtocol {
typealias Element = NdbTagElem