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:
@@ -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 */,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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()))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)))
|
||||
}
|
||||
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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: []))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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: []))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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, "")
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
549
damusTests/RelayHintsTests.swift
Normal file
549
damusTests/RelayHintsTests.swift
Normal file
@@ -0,0 +1,549 @@
|
||||
//
|
||||
// RelayHintsTests.swift
|
||||
// damus
|
||||
//
|
||||
// Created by Daniel D’Aquino 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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user