diff --git a/damus.xcodeproj/project.pbxproj b/damus.xcodeproj/project.pbxproj index bca5336a..a38119ac 100644 --- a/damus.xcodeproj/project.pbxproj +++ b/damus.xcodeproj/project.pbxproj @@ -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 = ""; }; D776BE3F2F232B17002DA1C9 /* EntityPreloader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EntityPreloader.swift; sourceTree = ""; }; D776BE432F233012002DA1C9 /* EntityPreloaderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EntityPreloaderTests.swift; sourceTree = ""; }; + D77A96BE2F3131BE00CC3246 /* RelayHintsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RelayHintsTests.swift; sourceTree = ""; }; D77BFA0A2AE3051200621634 /* ProfileActionSheetView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileActionSheetView.swift; sourceTree = ""; }; D77DA2C32F19CA40000B7093 /* AsyncStreamUtilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AsyncStreamUtilities.swift; sourceTree = ""; }; D77DA2C72F19D452000B7093 /* NegentropyUtilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NegentropyUtilities.swift; sourceTree = ""; }; @@ -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 */, diff --git a/damus/ContentView.swift b/damus/ContentView.swift index 24b694e8..d2abcb73 100644 --- a/damus/ContentView.swift +++ b/damus/ContentView.swift @@ -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) -} +} \ No newline at end of file diff --git a/damus/Core/Networking/NostrNetworkManager/SubscriptionManager.swift b/damus/Core/Networking/NostrNetworkManager/SubscriptionManager.swift index 55494b63..2c486b19 100644 --- a/damus/Core/Networking/NostrNetworkManager/SubscriptionManager.swift +++ b/damus/Core/Networking/NostrNetworkManager/SubscriptionManager.swift @@ -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 } -} +} \ No newline at end of file diff --git a/damus/Core/Nostr/Id.swift b/damus/Core/Nostr/Id.swift index 7c352d00..00c829c5 100644 --- a/damus/Core/Nostr/Id.swift +++ b/damus/Core/Nostr/Id.swift @@ -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 +/// (`::` 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 + /// (`::`) 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 diff --git a/damus/Core/Nostr/Mentions.swift b/damus/Core/Nostr/Mentions.swift index c44c4bf6..3b196042 100644 --- a/damus/Core/Nostr/Mentions.swift +++ b/damus/Core/Nostr/Mentions.swift @@ -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())) } } diff --git a/damus/Core/Nostr/NostrEvent.swift b/damus/Core/Nostr/NostrEvent.swift index 889c82ed..68eb253b 100644 --- a/damus/Core/Nostr/NostrEvent.swift +++ b/damus/Core/Nostr/NostrEvent.swift @@ -834,6 +834,68 @@ func first_eref_mention(ndb: Ndb, ev: NostrEvent, keypair: Keypair) -> Mention 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 diff --git a/damus/Core/Nostr/RelayPool.swift b/damus/Core/Nostr/RelayPool.swift index 9dcfc362..40dcdefe 100644 --- a/damus/Core/Nostr/RelayPool.swift +++ b/damus/Core/Nostr/RelayPool.swift @@ -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 { 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 { 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 { } } - diff --git a/damus/Core/Types/Ids/Referenced.swift b/damus/Core/Types/Ids/Referenced.swift index 93a7271e..3cfe7722 100644 --- a/damus/Core/Types/Ids/Referenced.swift +++ b/damus/Core/Types/Ids/Referenced.swift @@ -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", , , , ]` 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", , , , ]` 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", , , , ]` + /// 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) } } diff --git a/damus/Features/Chat/ChatEventView.swift b/damus/Features/Chat/ChatEventView.swift index bf86a00a..467d89e1 100644 --- a/damus/Features/Chat/ChatEventView.swift +++ b/damus/Features/Chat/ChatEventView.swift @@ -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))) } diff --git a/damus/Features/Chat/ReplyQuoteView.swift b/damus/Features/Chat/ReplyQuoteView.swift index 62b60a96..cb767a79 100644 --- a/damus/Features/Chat/ReplyQuoteView.swift +++ b/damus/Features/Chat/ReplyQuoteView.swift @@ -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) } + } + } } } } diff --git a/damus/Features/DMs/Views/DMView.swift b/damus/Features/DMs/Views/DMView.swift index 28e9b61f..bebea4be 100644 --- a/damus/Features/DMs/Views/DMView.swift +++ b/damus/Features/DMs/Views/DMView.swift @@ -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() } diff --git a/damus/Features/Events/BuilderEventView.swift b/damus/Features/Events/BuilderEventView.swift index 96adb55d..442c2e83 100644 --- a/damus/Features/Events/BuilderEventView.swift +++ b/damus/Features/Events/BuilderEventView.swift @@ -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) } } diff --git a/damus/Features/Events/EventLoaderView.swift b/damus/Features/Events/EventLoaderView.swift index 82b02c16..111f07a8 100644 --- a/damus/Features/Events/EventLoaderView.swift +++ b/damus/Features/Events/EventLoaderView.swift @@ -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: 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? = 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: 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 } } diff --git a/damus/Features/Events/EventShell.swift b/damus/Features/Events/EventShell.swift index 526cee2a..e57376e0 100644 --- a/damus/Features/Events/EventShell.swift +++ b/damus/Features/Events/EventShell.swift @@ -35,12 +35,12 @@ struct EventShell: View { !options.contains(.no_action_bar) } - func get_mention(ndb: Ndb) -> Mention? { + 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: 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: 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) } diff --git a/damus/Features/Events/EventView.swift b/damus/Features/Events/EventView.swift index d4bb91e5..f60b7395 100644 --- a/damus/Features/Events/EventView.swift +++ b/damus/Features/Events/EventView.swift @@ -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() } diff --git a/damus/Features/Events/MentionView.swift b/damus/Features/Events/MentionView.swift index a0f43109..d629cf2b 100644 --- a/damus/Features/Events/MentionView.swift +++ b/damus/Features/Events/MentionView.swift @@ -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 - - init(damus_state: DamusState, mention: Mention) { + 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, 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) } } } diff --git a/damus/Features/Events/Models/LoadableNostrEventView.swift b/damus/Features/Events/Models/LoadableNostrEventView.swift index 9486a78b..632c4aad 100644 --- a/damus/Features/Events/Models/LoadableNostrEventView.swift +++ b/damus/Features/Events/Models/LoadableNostrEventView.swift @@ -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: [])) +} \ No newline at end of file diff --git a/damus/Features/Events/SelectedEventView.swift b/damus/Features/Events/SelectedEventView.swift index 505362ba..5aed1922 100644 --- a/damus/Features/Events/SelectedEventView.swift +++ b/damus/Features/Events/SelectedEventView.swift @@ -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) } } diff --git a/damus/Features/Posting/Views/PostView.swift b/damus/Features/Posting/Views/PostView.swift index de92e1df..d7ad7e64 100644 --- a/damus/Features/Posting/Views/PostView.swift +++ b/damus/Features/Posting/Views/PostView.swift @@ -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", , , , ]` 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 { diff --git a/damus/Features/Search/Views/SearchResultsView.swift b/damus/Features/Search/Views/SearchResultsView.swift index 1bee985e..80d29756 100644 --- a/damus/Features/Search/Views/SearchResultsView.swift +++ b/damus/Features/Search/Views/SearchResultsView.swift @@ -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) } diff --git a/damus/Features/Search/Views/SearchingEventView.swift b/damus/Features/Search/Views/SearchingEventView.swift index e5895200..5728954a 100644 --- a/damus/Features/Search/Views/SearchingEventView.swift +++ b/damus/Features/Search/Views/SearchingEventView.swift @@ -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: [])) } -} +} \ No newline at end of file diff --git a/damus/Shared/Utilities/URLHandler.swift b/damus/Shared/Utilities/URLHandler.swift index 90d8ca62..7e84eb71 100644 --- a/damus/Shared/Utilities/URLHandler.swift +++ b/damus/Shared/Utilities/URLHandler.swift @@ -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) } -} +} \ No newline at end of file diff --git a/damusTests/NIP10Tests.swift b/damusTests/NIP10Tests.swift index cef67491..45caf9c8 100644 --- a/damusTests/NIP10Tests.swift +++ b/damusTests/NIP10Tests.swift @@ -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", , , , ] + // 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", , , , ] + 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", , , "root", ] + 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, "") + } + } diff --git a/damusTests/RelayHintsTests.swift b/damusTests/RelayHintsTests.swift new file mode 100644 index 00000000..1652f9b9 --- /dev/null +++ b/damusTests/RelayHintsTests.swift @@ -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? + + 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) + } +} diff --git a/nostrdb/NdbNote+.swift b/nostrdb/NdbNote+.swift index 79301f69..4a10786b 100644 --- a/nostrdb/NdbNote+.swift +++ b/nostrdb/NdbNote+.swift @@ -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 + } } diff --git a/nostrdb/NdbNote.swift b/nostrdb/NdbNote.swift index 52c4169c..a8fb31a9 100644 --- a/nostrdb/NdbNote.swift +++ b/nostrdb/NdbNote.swift @@ -435,6 +435,10 @@ extension NdbNote { References(tags: self.tags) } + public var referenced_quote_refs: References { + References(tags: self.tags) + } + public var referenced_noterefs: References { References(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 { diff --git a/nostrdb/NdbTagIterator.swift b/nostrdb/NdbTagIterator.swift index 7768d22d..a6e85323 100644 --- a/nostrdb/NdbTagIterator.swift +++ b/nostrdb/NdbTagIterator.swift @@ -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", "", "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