Consume NIP-19 relay hints for event fetching

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

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

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

Closes: https://github.com/damus-io/damus/issues/1147
Changelog-Added: Added relay hint support for nevent, nprofile, naddr links and event tag references (reposts, quotes, replies)
Signed-off-by: alltheseas
Signed-off-by: Daniel D'Aquino <daniel@daquino.me>
Co-authored-by: alltheseas <alltheseas@users.noreply.github.com>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Co-authored-by: Daniel D'Aquino <daniel@daquino.me
This commit is contained in:
alltheseas
2026-02-02 18:52:41 -08:00
committed by GitHub
co-authored by alltheseas Claude Opus 4.5 Daniel D'Aquino <daniel@daquino.me
parent 6f8e2d3064
commit 9a1ae6f9b5
27 changed files with 1522 additions and 128 deletions
+7 -5
View File
@@ -1112,16 +1112,18 @@ func handle_post_notification(keypair: FullKeypair, postbox: PostBox, events: Ev
extension LossyLocalNotification {
/// Computes a view open action from a mention reference.
/// Use this when opening a user-presentable interface to a specific mention reference.
/// Converts this mention's NIP-19 reference into a UI action for the app.
///
/// Maps NPUB and NPROFILE references to profile routes, NOTE/NEVENT/NADDR references to loadable note routes, NSCRIPT to a script view, and returns an error sheet for deprecated or unsafe references (`nrelay`, `nsec`).
/// - Returns: A `ContentView.ViewOpenAction` that represents the route or sheet to present for this mention.
func toViewOpenAction() -> ContentView.ViewOpenAction {
switch self.mention.nip19 {
case .npub(let pubkey):
return .route(.ProfileByKey(pubkey: pubkey))
case .note(let noteId):
return .route(.LoadableNostrEvent(note_reference: .note_id(noteId)))
return .route(.LoadableNostrEvent(note_reference: .note_id(noteId, relays: [])))
case .nevent(let nEvent):
// TODO: Improve this by implementing a route that handles nevents with their relay hints.
return .route(.LoadableNostrEvent(note_reference: .note_id(nEvent.noteid)))
return .route(.LoadableNostrEvent(note_reference: .note_id(nEvent.noteid, relays: nEvent.relays)))
case .nprofile(let nProfile):
// TODO: Improve this by implementing a profile route that handles nprofiles with their relay hints.
return .route(.ProfileByKey(pubkey: nProfile.author))
@@ -1152,4 +1154,4 @@ func logout(_ state: DamusState?)
{
state?.close()
notify(.logout)
}
}
@@ -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
}
}
}
+50 -4
View File
@@ -49,22 +49,22 @@ protocol TagItemConvertible {
struct QuoteId: IdType, TagKey, TagConvertible {
let id: Data
init(_ data: Data) {
self.id = data
}
/// The note id being quoted
var note_id: NoteId {
NoteId(self.id)
}
var keychar: AsciiCharacter { "q" }
var tag: [String] {
["q", self.hex()]
}
static func from_tag(tag: TagSequence) -> QuoteId? {
var i = tag.makeIterator()
@@ -80,6 +80,52 @@ struct QuoteId: IdType, TagKey, TagConvertible {
}
}
/// A quote reference with optional relay hints for fetching.
///
/// Per NIP-10/NIP-18, `q` tags include a relay URL at position 2 where the quoted
/// event can be found.
///
/// Note: The NIPs allow `q` tags to contain either event IDs (hex) or event addresses
/// (`<kind>:<pubkey>:<d>` for replaceable events). This implementation currently only
/// supports hex event IDs; quotes of addressable events are not yet handled.
struct QuoteRef: TagConvertible {
let quote_id: QuoteId
let relayHints: [RelayURL]
/// The note ID being quoted
var note_id: NoteId {
quote_id.note_id
}
var tag: [String] {
var tagBuilder = ["q", quote_id.hex()]
if let relay = relayHints.first {
tagBuilder.append(relay.absoluteString)
}
return tagBuilder
}
/// Parses a `q` tag into a QuoteRef, preserving relay hints from position 2.
///
/// Only parses `q` tags containing hex event IDs. Tags with event addresses
/// (`<kind>:<pubkey>:<d>`) are not currently supported and will return nil.
static func from_tag(tag: TagSequence) -> QuoteRef? {
var i = tag.makeIterator()
guard tag.count >= 2,
let t0 = i.next(),
let key = t0.single_char,
key == "q",
let t1 = i.next(),
let data = t1.id()
else { return nil }
let quoteId = QuoteId(data)
let relayHints = tag.relayHints
return QuoteRef(quote_id: quoteId, relayHints: relayHints)
}
}
struct Privkey: IdType {
let id: Data
+24 -7
View File
@@ -122,6 +122,11 @@ struct MentionRef: TagKeys, TagConvertible, Equatable, Hashable {
}
}
/// Parses a tag sequence into a MentionRef, preserving relay hints.
///
/// Per NIP-01/NIP-10, position 2 in `e`, `p`, and `a` tags contains an optional relay URL.
/// When present, this method creates `nevent`/`nprofile`/`naddr` variants that preserve
/// the relay hint for later use in event fetching.
static func from_tag(tag: TagSequence) -> MentionRef? {
guard tag.count >= 2 else { return nil }
@@ -135,23 +140,35 @@ struct MentionRef: TagKeys, TagConvertible, Equatable, Hashable {
return nil
}
let relayHints = tag.relayHints
switch mention_type {
case .p:
guard let data = element.id() else { return nil }
return .init(nip19: .npub(Pubkey(data)))
let pubkey = Pubkey(data)
if relayHints.isEmpty {
return .init(nip19: .npub(pubkey))
}
return .init(nip19: .nprofile(NProfile(author: pubkey, relays: relayHints)))
case .e:
guard let data = element.id() else { return nil }
return .init(nip19: .note(NoteId(data)))
let noteId = NoteId(data)
if relayHints.isEmpty {
return .init(nip19: .note(noteId))
}
#if DEBUG
print("[relay-hints] e tag: Found \(relayHints.count) hint(s) for \(noteId.hex().prefix(8))...: \(relayHints.map { $0.absoluteString })")
#endif
return .init(nip19: .nevent(NEvent(noteid: noteId, relays: relayHints)))
case .a:
let str = element.string()
let data = str.split(separator: ":")
if(data.count != 3) { return nil }
guard data.count == 3 else { return nil }
guard let pubkey = Pubkey(hex: String(data[1])) else { return nil }
guard let kind = UInt32(data[0]) else { return nil }
return .init(nip19: .naddr(NAddr(identifier: String(data[2]), author: pubkey, relays: [], kind: kind)))
case .r: return .init(nip19: .nrelay(element.string()))
return .init(nip19: .naddr(NAddr(identifier: String(data[2]), author: pubkey, relays: relayHints, kind: kind)))
case .r:
return .init(nip19: .nrelay(element.string()))
}
}
+62
View File
@@ -834,6 +834,68 @@ func first_eref_mention(ndb: Ndb, ev: NostrEvent, keypair: Keypair) -> Mention<N
})
}
/// Represents a note mention with optional relay hints for fetching.
struct NoteMentionWithHints {
let noteId: NoteId
let relayHints: [RelayURL]
let index: Int?
}
/// Finds the first event reference mention in a note's content, preserving relay hints.
///
/// Per NIP-19, `nevent` bech32 entities may include relay hints. This function extracts
/// those hints so they can be used when fetching the referenced event.
///
/// If no inline mention is found in the content, falls back to checking `q` tags (NIP-10/NIP-18)
/// to support quote reposts that don't embed the quoted note inline.
///
/// - Parameters:
/// - ndb: The nostrdb instance.
/// - ev: The event to search.
/// - keypair: The keypair for decryption if needed.
/// - Returns: A `NoteMentionWithHints` containing the note ID and relay hints, or nil if not found.
func first_eref_mention_with_hints(ndb: Ndb, ev: NostrEvent, keypair: Keypair) -> NoteMentionWithHints? {
// First check content blocks for inline mentions
let inlineMention: NoteMentionWithHints? = try? NdbBlockGroup.borrowBlockGroup(event: ev, using: ndb, and: keypair, borrow: { blockGroup in
return blockGroup.forEachBlock({ index, block in
switch block {
case .mention(let mention):
guard let mentionRef = MentionRef(block: mention) else { return .loopContinue }
switch mentionRef.nip19 {
case .note(let noteId):
return .loopReturn(NoteMentionWithHints(noteId: noteId, relayHints: [], index: index))
case .nevent(let nEvent):
#if DEBUG
if !nEvent.relays.isEmpty {
print("[relay-hints] Inline nevent: Found \(nEvent.relays.count) hint(s) for \(nEvent.noteid.hex().prefix(8))...: \(nEvent.relays.map { $0.absoluteString })")
}
#endif
return .loopReturn(NoteMentionWithHints(noteId: nEvent.noteid, relayHints: nEvent.relays, index: index))
default:
return .loopContinue
}
default:
return .loopContinue
}
})
})
if let inlineMention {
return inlineMention
}
// Fall back to q tags (NIP-10/NIP-18 quote reposts)
guard let quoteRef = ev.referenced_quote_refs.first else {
return nil
}
#if DEBUG
if !quoteRef.relayHints.isEmpty {
print("[relay-hints] Quote: Found q tag with \(quoteRef.relayHints.count) hint(s) for \(quoteRef.note_id.hex().prefix(8))...: \(quoteRef.relayHints.map { $0.absoluteString })")
}
#endif
return NoteMentionWithHints(noteId: quoteRef.note_id, relayHints: quoteRef.relayHints, index: nil)
}
func separate_invoices(ndb: Ndb, ev: NostrEvent, keypair: Keypair) -> [Invoice]? {
return try? NdbBlockGroup.borrowBlockGroup(event: ev, using: ndb, and: keypair, borrow: { blockGroup in
let invoiceBlocks: [Invoice] = (try? blockGroup.reduce(initialResult: [Invoice](), { index, invoices, block in
+215 -5
View File
@@ -52,6 +52,11 @@ class RelayPool {
var delegate: Delegate?
private(set) var signal: SignalModel = SignalModel()
/// Tracks active leases on ephemeral relays to prevent premature cleanup.
/// Each lookup that uses an ephemeral relay acquires a lease; cleanup only
/// happens when the last lease is released.
private var ephemeralLeases: [RelayURL: Int] = [:]
let network_monitor = NWPathMonitor()
private let network_monitor_queue = DispatchQueue(label: "io.damus.network_monitor")
private var last_network_status: NWPath.Status = .unsatisfied
@@ -159,23 +164,77 @@ class RelayPool {
Log.debug("Registering %s handler, current: %d", for: .networking, sub_id, self.handlers.count)
}
/// Removes the relay with the given URL from the pool, permanently disables its connection, and ensures it is disconnected.
/// - Parameters:
/// - relay_id: The RelayURL identifying the relay to disable and remove.
@MainActor
func remove_relay(_ relay_id: RelayURL) async {
var i: Int = 0
await self.disconnect(to: [relay_id])
for relay in relays {
if relay.id == relay_id {
relay.connection.disablePermanently()
relays.remove(at: i)
break
}
i += 1
}
}
/// Acquires a lease on ephemeral relays to prevent them from being cleaned up
/// Increment lease counts for the given ephemeral relay URLs to prevent their removal while leased.
/// - Parameters:
/// - relayURLs: The relay URLs whose ephemeral lease counts will be incremented; each URL's lease count is increased by one.
func acquireEphemeralRelays(_ relayURLs: [RelayURL]) {
for url in relayURLs {
ephemeralLeases[url, default: 0] += 1
#if DEBUG
print("[RelayPool] Acquired lease on ephemeral relay \(url.absoluteString), count: \(ephemeralLeases[url] ?? 0)")
#endif
}
}
/// Releases leases on ephemeral relays. When the last lease is released,
/// Releases one lease for each specified relay and removes any ephemeral relay when its last lease is released.
/// - Parameters:
/// - relayURLs: Relay URLs whose leases should be decremented. If a relay's lease count reaches zero and the relay is marked ephemeral, the relay will be removed. Relays not present in the lease table are ignored.
func releaseEphemeralRelays(_ relayURLs: [RelayURL]) async {
for url in relayURLs {
guard let count = ephemeralLeases[url], count > 0 else { continue }
// Decrement immediately (atomic with respect to this actor, before any suspension)
let newCount = count - 1
ephemeralLeases[url] = newCount == 0 ? nil : newCount
#if DEBUG
print("[RelayPool] Released lease on ephemeral relay \(url.absoluteString), count: \(newCount)")
#endif
if newCount == 0 {
// Check if relay exists and is ephemeral
if let relay = await get_relay(url), relay.descriptor.ephemeral {
// Re-check: only remove if lease is still nil (not re-acquired during await)
guard ephemeralLeases[url] == nil else {
#if DEBUG
print("[RelayPool] Lease re-acquired during check, skipping removal: \(url.absoluteString)")
#endif
continue
}
#if DEBUG
print("[RelayPool] Removing ephemeral relay: \(url.absoluteString)")
#endif
await remove_relay(url)
}
}
}
}
/// Adds and registers a new relay in the pool using the provided descriptor.
/// - Parameter desc: Descriptor for the relay to add (includes its URL, metadata, and whether it is ephemeral).
/// - Throws: `RelayError.RelayAlreadyExists` if a relay with the same URL is already present in the pool.
func add_relay(_ desc: RelayDescriptor) async throws(RelayError) {
let relay_id = desc.url
if await get_relay(relay_id) != nil {
@@ -188,6 +247,16 @@ class RelayPool {
case .string(let str) = msg
else { return }
#if DEBUG
if desc.ephemeral {
if str.hasPrefix("[\"EVENT\"") {
print("[RelayPool] Received EVENT from ephemeral relay \(relay_id.absoluteString): \(str.prefix(200))...")
} else if str.hasPrefix("[\"EOSE\"") {
print("[RelayPool] Received EOSE from ephemeral relay \(relay_id.absoluteString)")
}
}
#endif
let _ = self.ndb?.processEvent(str, originRelayURL: relay_id)
self.message_received_function?((str, desc))
})
@@ -195,11 +264,116 @@ class RelayPool {
await self.appendRelayToList(relay: relay)
}
/// Appends the given Relay to the pool's internal list of relays.
@MainActor
private func appendRelayToList(relay: Relay) {
self.relays.append(relay)
}
/// Ensures the given relay URLs are connected, adding them as ephemeral relays if not already in the pool.
/// Returns the list of relay URLs that are actually connected (ready for subscriptions).
///
/// Callers should use `acquireEphemeralRelays` before the lookup and `releaseEphemeralRelays` after.
///
/// - Parameters:
/// - relayURLs: The relay URLs to ensure are connected
/// - timeout: Maximum time to wait for pending connections (default 2s). Returns early when first relay connects.
/// Ensure the given relays are present in the pool and return those that are connected.
///
/// This will add missing URLs as ephemeral relays, initiate connections for relays that are not connected, and wait up to `timeout` for connections to establish. Once any relay connects, the method allows a short grace period for additional relays to connect before returning.
/// - Parameters:
/// - relayURLs: The relay URLs to ensure connectivity for. Missing URLs will be added as ephemeral relays.
/// - timeout: Maximum time to wait for connections (default: 2 seconds). A short grace period (300 ms) is applied after the first relay connects.
/// - Returns: The subset of `relayURLs` that are currently connected (includes relays that were already connected and those that became connected during the wait).
func ensureConnected(to relayURLs: [RelayURL], timeout: Duration = .seconds(2)) async -> [RelayURL] {
var toConnect: [RelayURL] = []
var alreadyConnected: [RelayURL] = []
for url in relayURLs {
if let existing = await get_relay(url) {
if existing.connection.isConnected {
alreadyConnected.append(url)
#if DEBUG
print("[RelayPool] Relay \(url.absoluteString) already connected")
#endif
} else {
toConnect.append(url)
}
continue
}
let descriptor = RelayDescriptor(url: url, info: .readWrite, variant: .ephemeral)
do {
try await add_relay(descriptor)
toConnect.append(url)
#if DEBUG
print("[RelayPool] Added ephemeral relay: \(url.absoluteString)")
#endif
} catch {
#if DEBUG
print("[RelayPool] Failed to add relay \(url.absoluteString): \(error)")
#endif
}
}
guard !toConnect.isEmpty else { return alreadyConnected }
await connect(to: toConnect)
let checkInterval: Duration = .milliseconds(50)
let overallDeadline = ContinuousClock.now + timeout
var graceDeadline: ContinuousClock.Instant? = alreadyConnected.isEmpty ? nil : ContinuousClock.now + .milliseconds(300)
// Wait for relays to connect. Once the first connects, start a grace period for others.
waitLoop: while ContinuousClock.now < overallDeadline {
do {
try await Task.sleep(for: checkInterval)
} catch {
break
}
// Check if any relay has connected
var anyConnected = false
for url in toConnect {
if let relay = await get_relay(url), relay.connection.isConnected {
anyConnected = true
break
}
}
if anyConnected && graceDeadline == nil {
// Start grace period on first connection
graceDeadline = ContinuousClock.now + .milliseconds(300)
}
// Exit once grace period expires (check every iteration if deadline is set)
if let deadline = graceDeadline, ContinuousClock.now >= deadline {
break waitLoop
}
}
// Collect all connected relays
var connected = alreadyConnected
for url in toConnect {
if let relay = await get_relay(url), relay.connection.isConnected {
connected.append(url)
#if DEBUG
print("[RelayPool] Relay \(url.absoluteString) connected: true")
#endif
} else {
#if DEBUG
print("[RelayPool] Relay \(url.absoluteString) connected: false (excluded)")
#endif
}
}
return connected
}
/// Attaches a `RelayLog` to the connection for the specified relay and records the current network status in the log.
/// - Parameters:
/// - log: The `RelayLog` instance to attach to the relay's connection.
/// - relay_id: The `RelayURL` identifying the relay whose connection will receive the log.
func setLog(_ log: RelayLog, for relay_id: RelayURL) async {
// add the current network state to the log
log.add("Network state: \(network_monitor.currentPath.status)")
@@ -252,9 +426,22 @@ class RelayPool {
}
}
/// Gets relays matching the provided relay URLs, or all relays when no targets are specified.
/// - Parameter targetRelays: Optional list of relay URLs to filter by. If `nil`, the pool's full relay list is returned.
/// - Returns: An array of `Relay` instances corresponding to the requested URLs; any requested URL not present in the pool is omitted from the result.
@MainActor
func getRelays(targetRelays: [RelayURL]? = nil) -> [Relay] {
targetRelays.map{ get_relays($0) } ?? self.relays
let result = targetRelays.map{ get_relays($0) } ?? self.relays
#if DEBUG
if let targets = targetRelays {
let found = result.map { $0.descriptor.url.absoluteString }
let requested = targets.map { $0.absoluteString }
if found.count != targets.count {
print("[RelayPool] getRelays: MISMATCH! requested=\(requested) but found=\(found)")
}
}
#endif
return result
}
/// Deletes queued up requests that should not persist between app sessions (i.e. when the app goes to background then back to foreground)
@@ -305,10 +492,22 @@ class RelayPool {
/// - filters: The filters specifying the desired content.
/// - desiredRelays: The desired relays which to subsctibe to. If `nil`, it defaults to the `RelayPool`'s default list
/// - eoseTimeout: The maximum timeout which to give up waiting for the eoseSignal
/// - Returns: Returns an async stream that callers can easily consume via a for-loop
/// Open a subscription for the given filters and provide a stream of matching items and EOSE notifications.
/// - Parameters:
/// - filters: The list of NostrFilter objects that define which events to receive.
/// - desiredRelays: Optional list of RelayURL to subscribe to; when `nil` the pool's relays are used.
/// - eoseTimeout: Optional timeout to wait before emitting an EOSE if not all relays have reported EOSE; defaults to 5 seconds.
/// - id: Optional UUID to use as the subscription identifier; a new UUID is generated when `nil`.
/// - Returns: An AsyncStream that yields StreamItem values representing matched events and end-of-stream (EOSE) notifications for this subscription. The stream deduplicates events by their NoteId. When the stream terminates it will unsubscribe from the chosen relays and remove the internal handler.
func subscribe(filters: [NostrFilter], to desiredRelays: [RelayURL]? = nil, eoseTimeout: Duration? = nil, id: UUID? = nil) async -> AsyncStream<StreamItem> {
let eoseTimeout = eoseTimeout ?? .seconds(5)
let desiredRelays = await getRelays(targetRelays: desiredRelays)
#if DEBUG
print("[RelayPool] subscribe: requested=\(desiredRelays.map { $0.descriptor.url.absoluteString }), pool has \(await relays.count) relays")
if let ids = filters.first?.ids {
print("[RelayPool] subscribe: filter ids=\(ids.map { $0.hex() })")
}
#endif
let startTime = CFAbsoluteTimeGetCurrent()
return AsyncStream<StreamItem> { continuation in
let id = id ?? UUID()
@@ -439,6 +638,13 @@ class RelayPool {
}
}
/// Dispatches a Nostr request to the pool's matching relays, writing a local copy to the NostrDB and queuing the request for any relay that is not currently connected.
///
/// Filters target relays by their read/write capabilities and, optionally, by ephemeral status; connected relays receive the request immediately and disconnected relays have the request queued for later delivery. Sent messages are reported via `message_sent_function` when available.
/// - Parameters:
/// - req: The Nostr request to send.
/// - to: Optional list of relay URLs to restrict delivery to; `nil` targets the pool's default set of relays.
/// - skip_ephemeral: If `true`, skip ephemeral relays when sending the request.
func send_raw(_ req: NostrRequestType, to: [RelayURL]? = nil, skip_ephemeral: Bool = true) async {
let relays = await getRelays(targetRelays: to)
@@ -463,6 +669,11 @@ class RelayPool {
}
relay.connection.send(req, callback: { str in
#if DEBUG
if relay.descriptor.ephemeral && str.hasPrefix("[\"REQ\"") {
print("[RelayPool] Sending REQ to ephemeral relay \(relay.id.absoluteString): \(str)")
}
#endif
self.message_sent_function?((str, relay))
})
}
@@ -699,4 +910,3 @@ extension RelayPool {
}
}
+18 -2
View File
@@ -27,19 +27,23 @@ enum Marker: String {
}
}
/// A reference to a note event, with optional relay hint, marker, and author pubkey.
/// Per NIP-10: `["e", <event-id>, <relay-url>, <marker>, <pubkey>]`
struct NoteRef: IdType, TagConvertible, Equatable {
let note_id: NoteId
let relay: String?
let marker: Marker?
let pubkey: Pubkey?
var id: Data {
self.note_id.id
}
init(note_id: NoteId, relay: String? = nil, marker: Marker? = nil) {
init(note_id: NoteId, relay: String? = nil, marker: Marker? = nil, pubkey: Pubkey? = nil) {
self.note_id = note_id
self.relay = relay
self.marker = marker
self.pubkey = pubkey
}
static func note_id(_ note_id: NoteId) -> NoteRef {
@@ -50,19 +54,26 @@ struct NoteRef: IdType, TagConvertible, Equatable {
self.note_id = NoteId(data)
self.relay = nil
self.marker = nil
self.pubkey = nil
}
/// Generates a tag array per NIP-10: `["e", <event-id>, <relay-url>, <marker>, <pubkey>]`
var tag: [String] {
var t = ["e", self.hex()]
if let marker {
t.append(relay ?? "")
t.append(marker.rawValue)
if let pubkey {
t.append(pubkey.hex())
}
} else if let relay {
t.append(relay)
}
return t
}
/// Parses a NoteRef from a tag per NIP-10: `["e", <event-id>, <relay-url>, <marker>, <pubkey>]`
/// Only parses pubkey from position 4 when a valid marker is present in position 3.
static func from_tag(tag: TagSequence) -> NoteRef? {
guard tag.count >= 2 else { return nil }
@@ -78,14 +89,19 @@ struct NoteRef: IdType, TagConvertible, Equatable {
var relay: String? = nil
var marker: Marker? = nil
var pubkey: Pubkey? = nil
if tag.count >= 3, let r = i.next() {
relay = r.string()
if tag.count >= 4, let m = i.next() {
marker = Marker(m)
// Only parse pubkey when marker is recognized per NIP-10
if marker != nil, tag.count >= 5, let pk = i.next(), let pubkeyData = pk.id() {
pubkey = Pubkey(pubkeyData)
}
}
}
return NoteRef(note_id: note_id, relay: relay, marker: marker)
return NoteRef(note_id: note_id, relay: relay, marker: marker, pubkey: pubkey)
}
}
+6 -6
View File
@@ -143,22 +143,22 @@ struct ChatEventView: View {
}
}
if let replying_to = event.direct_replies(),
replying_to != selected_event.id {
ReplyQuoteView(keypair: damus_state.keypair, quoter: event, event_id: replying_to, state: damus_state, thread: thread, options: reply_quote_options)
if let reply_ref = event.direct_reply_ref(),
reply_ref.note_id != selected_event.id {
ReplyQuoteView(keypair: damus_state.keypair, quoter: event, event_id: reply_ref.note_id, state: damus_state, thread: thread, options: reply_quote_options, relayHint: reply_ref.relay)
.background(is_ours ? DamusColors.adaptablePurpleBackground2 : DamusColors.adaptableGrey2)
.foregroundColor(is_ours ? Color.damusAdaptablePurpleForeground : Color.damusAdaptableBlack)
.cornerRadius(5)
.onTapGesture {
self.scroll_to_event?(replying_to)
self.scroll_to_event?(reply_ref.note_id)
}
}
let blur_images = should_blur_images(settings: damus_state.settings, contacts: damus_state.contacts, ev: event, our_pubkey: damus_state.pubkey)
NoteContentView(damus_state: damus_state, event: event, blur_images: blur_images, size: .normal, options: [.truncate_content])
.padding(2)
if let mention = first_eref_mention(ndb: damus_state.ndb, ev: event, keypair: damus_state.keypair) {
MentionView(damus_state: damus_state, mention: mention)
if let mention = first_eref_mention_with_hints(ndb: damus_state.ndb, ev: event, keypair: damus_state.keypair) {
MentionView(damus_state: damus_state, mention: .note(mention.noteId, index: mention.index), relayHints: mention.relayHints)
.background(DamusColors.adaptableWhite)
.clipShape(RoundedRectangle(cornerSize: CGSize(width: 10, height: 10)))
}
+25 -3
View File
@@ -7,6 +7,9 @@
import SwiftUI
/// Displays a compact preview of the event being replied to.
///
/// Supports NIP-10 relay hints to fetch events from relays not in the user's pool.
struct ReplyQuoteView: View {
let keypair: Keypair
let quoter: NostrEvent
@@ -14,13 +17,24 @@ struct ReplyQuoteView: View {
let state: DamusState
@ObservedObject var thread: ThreadModel
let options: EventViewOptions
let relayHint: String?
init(keypair: Keypair, quoter: NostrEvent, event_id: NoteId, state: DamusState, thread: ThreadModel, options: EventViewOptions, relayHint: String? = nil) {
self.keypair = keypair
self.quoter = quoter
self.event_id = event_id
self.state = state
self.thread = thread
self.options = options
self.relayHint = relayHint
}
@State var can_show_event = true
func update_should_show_event(event: NdbNote) async {
self.can_show_event = await should_show_event(event: event, damus_state: state)
}
func content(event: NdbNote) -> some View {
ZStack(alignment: .leading) {
VStack(alignment: .leading) {
@@ -65,6 +79,14 @@ struct ReplyQuoteView: View {
.onAppear {
Task { await self.update_should_show_event(event: event) }
}
} else if let relayHint, let relayURL = RelayURL(relayHint) {
// Event not in cache - try to fetch using relay hint
EventLoaderView(damus_state: state, event_id: event_id, relayHints: [relayURL]) { loaded_event in
self.content(event: loaded_event)
.onAppear {
Task { await self.update_should_show_event(event: loaded_event) }
}
}
}
}
}
+2 -2
View File
@@ -17,8 +17,8 @@ struct DMView: View {
var Mention: some View {
Group {
if let mention = first_eref_mention(ndb: damus_state.ndb, ev: event, keypair: damus_state.keypair) {
BuilderEventView(damus: damus_state, event_id: mention.ref)
if let mention = first_eref_mention_with_hints(ndb: damus_state.ndb, ev: event, keypair: damus_state.keypair) {
BuilderEventView(damus: damus_state, event_id: mention.noteId, relayHints: mention.relayHints)
} else {
EmptyView()
}
+17 -4
View File
@@ -7,21 +7,34 @@
import SwiftUI
/// A view that displays an embedded/quoted Nostr event.
///
/// Supports NIP-01/NIP-10 relay hints to fetch events from relays not in the user's pool.
struct BuilderEventView: View {
let damus: DamusState
let event_id: NoteId
let event: NostrEvent?
let relayHints: [RelayURL]
/// Creates a builder event view with a pre-loaded event.
init(damus: DamusState, event: NostrEvent) {
self.event = event
self.damus = damus
self.event_id = event.id
self.relayHints = []
}
init(damus: DamusState, event_id: NoteId) {
/// Creates a builder event view that will load the event by ID.
///
/// - Parameters:
/// - damus: The app's shared state.
/// - event_id: The ID of the event to load.
/// - relayHints: Optional relay URLs where the event may be found (per NIP-01/NIP-10).
init(damus: DamusState, event_id: NoteId, relayHints: [RelayURL] = []) {
self.event_id = event_id
self.damus = damus
self.event = nil
self.relayHints = relayHints
}
func Event(event: NostrEvent) -> some View {
@@ -39,7 +52,7 @@ struct BuilderEventView: View {
if let event {
self.Event(event: event)
} else {
EventLoaderView(damus_state: damus, event_id: self.event_id) { loaded_event in
EventLoaderView(damus_state: damus, event_id: self.event_id, relayHints: relayHints) { loaded_event in
self.Event(event: loaded_event)
}
}
+26 -4
View File
@@ -7,18 +7,29 @@
import SwiftUI
/// This view handles the loading logic for Nostr events, so that you can easily use views that require `NostrEvent`, even if you only have a `NoteId`
/// This view handles the loading logic for Nostr events, so that you can easily use views that require `NostrEvent`, even if you only have a `NoteId`.
///
/// Supports NIP-01/NIP-10 relay hints to fetch events from relays not in the user's pool.
struct EventLoaderView<Content: View>: View {
let damus_state: DamusState
let event_id: NoteId
let relayHints: [RelayURL]
@State var event: NostrEvent?
@State var subscription_uuid: String = UUID().description
@State var loadingTask: Task<Void, Never>? = nil
let content: (NostrEvent) -> Content
init(damus_state: DamusState, event_id: NoteId, @ViewBuilder content: @escaping (NostrEvent) -> Content) {
/// Creates an event loader view.
///
/// - Parameters:
/// - damus_state: The app's shared state.
/// - event_id: The ID of the event to load.
/// - relayHints: Optional relay URLs where the event may be found (per NIP-01/NIP-10).
/// - content: A view builder that receives the loaded event.
init(damus_state: DamusState, event_id: NoteId, relayHints: [RelayURL] = [], @ViewBuilder content: @escaping (NostrEvent) -> Content) {
self.damus_state = damus_state
self.event_id = event_id
self.relayHints = relayHints
self.content = content
let event = damus_state.events.lookup(event_id)
_event = State(initialValue: event)
@@ -31,8 +42,19 @@ struct EventLoaderView<Content: View>: View {
func subscribe() {
self.loadingTask?.cancel()
self.loadingTask = Task {
let lender = try? await damus_state.nostrNetwork.reader.lookup(noteId: self.event_id)
let targetRelays = relayHints.isEmpty ? nil : relayHints
#if DEBUG
if let targetRelays, !targetRelays.isEmpty {
print("[relay-hints] EventLoaderView: Loading event \(event_id.hex().prefix(8))... with \(targetRelays.count) relay hint(s): \(targetRelays.map { $0.absoluteString })")
}
#endif
let lender = try? await damus_state.nostrNetwork.reader.lookup(noteId: self.event_id, to: targetRelays)
lender?.justUseACopy({ event = $0 })
#if DEBUG
if let targetRelays, !targetRelays.isEmpty {
print("[relay-hints] EventLoaderView: Event \(event_id.hex().prefix(8))... loaded: \(event != nil)")
}
#endif
}
}
+5 -5
View File
@@ -35,12 +35,12 @@ struct EventShell<Content: View>: View {
!options.contains(.no_action_bar)
}
func get_mention(ndb: Ndb) -> Mention<NoteId>? {
func get_mention(ndb: Ndb) -> NoteMentionWithHints? {
if self.options.contains(.nested) || self.options.contains(.no_mentions) {
return nil
}
return first_eref_mention(ndb: ndb, ev: event, keypair: state.keypair)
return first_eref_mention_with_hints(ndb: ndb, ev: event, keypair: state.keypair)
}
var ActionBar: some View {
@@ -76,7 +76,7 @@ struct EventShell<Content: View>: View {
content
if let mention = get_mention(ndb: state.ndb) {
MentionView(damus_state: state, mention: mention)
MentionView(damus_state: state, mention: .note(mention.noteId, index: mention.index), relayHints: mention.relayHints)
}
if has_action_bar {
@@ -108,7 +108,7 @@ struct EventShell<Content: View>: View {
if !options.contains(.no_mentions),
let mention = get_mention(ndb: state.ndb)
{
MentionView(damus_state: state, mention: mention)
MentionView(damus_state: state, mention: .note(mention.noteId, index: mention.index), relayHints: mention.relayHints)
.padding(.horizontal)
}
+5
View File
@@ -36,6 +36,11 @@ struct EventView: View {
if event.known_kind == .boost {
if let inner_ev = event.get_inner_event(cache: damus.events) {
RepostedEvent(damus: damus, event: event, inner_ev: inner_ev, options: options)
} else if let target = event.repostTarget() {
// Inner event not in cache - load using relay hints from e tag (NIP-18)
EventLoaderView(damus_state: damus, event_id: target.noteId, relayHints: target.relayHints) { loaded_event in
RepostedEvent(damus: damus, event: event, inner_ev: loaded_event, options: options)
}
} else {
EmptyView()
}
+16 -5
View File
@@ -7,19 +7,30 @@
import SwiftUI
/// A view that renders an inline mention of a Nostr event.
///
/// Supports NIP-01/NIP-10 relay hints to fetch events from relays not in the user's pool.
struct MentionView: View {
let damus_state: DamusState
let mention: Mention<NoteId>
init(damus_state: DamusState, mention: Mention<NoteId>) {
let relayHints: [RelayURL]
/// Creates a mention view.
///
/// - Parameters:
/// - damus_state: The app's shared state.
/// - mention: The mention containing the note ID.
/// - relayHints: Optional relay URLs where the event may be found (per NIP-01/NIP-10).
init(damus_state: DamusState, mention: Mention<NoteId>, relayHints: [RelayURL] = []) {
self.damus_state = damus_state
self.mention = mention
self.relayHints = relayHints
}
var body: some View {
EventLoaderView(damus_state: damus_state, event_id: mention.ref) { event in
EventLoaderView(damus_state: damus_state, event_id: mention.ref, relayHints: relayHints) { event in
EventMutingContainerView(damus_state: damus_state, event: event) {
BuilderEventView(damus: damus_state, event_id: mention.ref)
BuilderEventView(damus: damus_state, event_id: mention.ref, relayHints: relayHints)
}
}
}
@@ -31,6 +31,9 @@ class LoadableNostrEventViewModel: ObservableObject {
Task { await self.load() }
}
/// Starts loading the referenced Nostr event and updates the view model's `state` with the result or a timeout outcome.
///
/// This launches a dedicated task that runs the loading logic and a separate timeout task that cancels the loader after `TIMEOUT`. If the timeout fires, `state` is set to `.not_found`. If the load finishes first, the timeout task is cancelled.
func load() async {
// Start the loading process in a separate task to manage the timeout independently.
let loadTask = Task { @MainActor in
@@ -48,18 +51,34 @@ class LoadableNostrEventViewModel: ObservableObject {
timeoutTask.cancel() // Cancel the timeout task if loading finishes earlier.
}
/// Asynchronously find an event from NostrDB or from the network (if not available on NostrDB)
private func loadEvent(noteId: NoteId) async -> NostrEvent? {
let res = await damus_state.nostrNetwork.reader.findEvent(query: .event(evid: noteId))
/// Loads the Nostr event identified by `noteId`, optionally restricting the lookup to specific relays.
/// - Parameters:
/// - relays: An array of relay URLs to restrict the lookup to. If empty, the lookup is not restricted to any relays.
/// - Returns: The `NostrEvent` matching `noteId` if found, `nil` otherwise.
private func loadEvent(noteId: NoteId, relays: [RelayURL]) async -> NostrEvent? {
let targetRelays = relays.isEmpty ? nil : relays
let res = await damus_state.nostrNetwork.reader.findEvent(query: .event(evid: noteId, find_from: targetRelays))
guard let res, case .event(let ev) = res else { return nil }
return ev
}
/// Gets the note reference and tries to load it, outputting a new state for this view model.
/// Resolve a NoteReference into a ThreadModelLoadingState describing how the referenced note should be presented.
///
/// For a `.note_id` reference this attempts to load the event (honoring optional relay hints) and maps event kinds as follows:
/// - `.text` or `.highlight` `.loaded` with a `Route.Thread`.
/// - `.dm` `.loaded` with a `Route.DMChat` for the corresponding DM model.
/// - `.like` follows the first referenced note ID (propagating the same relay hints) and resolves it recursively.
/// - `.zap` or `.zap_request` resolves a zap and, if found, returns `.loaded` with a `Route.Zaps`.
/// - any other known kind `.unknown_or_unsupported_kind`.
/// If the event cannot be retrieved or a required referenced note/zap is missing, returns `.not_found`.
///
/// For an `.naddr` reference this looks up the event (using relays from the NAddr if provided) and returns `.loaded` with a `Route.Thread` when found or `.not_found` when not found.
/// - Parameter note_reference: The note identifier to resolve; may include relay hints for relay-aware lookup.
/// - Returns: A `ThreadModelLoadingState` indicating the resolved presentation route (`.loaded(route: Route)`), `.not_found` when the note or required referenced data is missing, or `.unknown_or_unsupported_kind` when the event kind cannot be presented.
private func executeLoadingLogic(note_reference: NoteReference) async -> ThreadModelLoadingState {
switch note_reference {
case .note_id(let note_id):
guard let ev = await self.loadEvent(noteId: note_id) else { return .not_found }
case .note_id(let note_id, let relays):
guard let ev = await self.loadEvent(noteId: note_id, relays: relays) else { return .not_found }
guard let known_kind = ev.known_kind else { return .unknown_or_unsupported_kind }
switch known_kind {
case .text, .highlight, .longform:
@@ -68,9 +87,9 @@ class LoadableNostrEventViewModel: ObservableObject {
let dm_model = damus_state.dms.lookup_or_create(ev.pubkey)
return .loaded(route: Route.DMChat(dms: dm_model))
case .like:
// Load the event that this reaction refers to.
guard let first_referenced_note_id = ev.referenced_ids.first else { return .not_found }
return await self.executeLoadingLogic(note_reference: .note_id(first_referenced_note_id))
// Pass the same relay hints - the referenced note is likely on the same relay as the reaction
return await self.executeLoadingLogic(note_reference: .note_id(first_referenced_note_id, relays: relays))
case .zap, .zap_request:
guard let zap = await get_zap(from: ev, state: damus_state) else { return .not_found }
return .loaded(route: Route.Zaps(target: zap.target))
@@ -78,9 +97,8 @@ class LoadableNostrEventViewModel: ObservableObject {
return .unknown_or_unsupported_kind
}
case .naddr(let naddr):
guard let event = await damus_state.nostrNetwork.reader.lookup(naddr: naddr) else {
return .not_found
}
let targetRelays = naddr.relays.isEmpty ? nil : naddr.relays
guard let event = await damus_state.nostrNetwork.reader.lookup(naddr: naddr, to: targetRelays) else { return .not_found }
return .loaded(route: Route.Thread(thread: ThreadModel(event: event, damus_state: damus_state)))
}
}
@@ -93,7 +111,7 @@ class LoadableNostrEventViewModel: ObservableObject {
}
enum NoteReference: Hashable {
case note_id(NoteId)
case note_id(NoteId, relays: [RelayURL])
case naddr(NAddr)
}
}
@@ -273,5 +291,5 @@ extension LoadableNostrEventView {
}
#Preview("Loadable") {
LoadableNostrEventView(state: test_damus_state, note_reference: .note_id(test_thread_note_1.id))
}
LoadableNostrEventView(state: test_damus_state, note_reference: .note_id(test_thread_note_1.id, relays: []))
}
@@ -93,8 +93,8 @@ struct SelectedEventView: View {
var Mention: some View {
Group {
if let mention = first_eref_mention(ndb: damus.ndb, ev: event, keypair: damus.keypair) {
MentionView(damus_state: damus, mention: mention)
if let mention = first_eref_mention_with_hints(ndb: damus.ndb, ev: event, keypair: damus.keypair) {
MentionView(damus_state: damus, mention: .note(mention.noteId, index: mention.index), relayHints: mention.relayHints)
.padding(.horizontal)
}
}
+10 -6
View File
@@ -921,21 +921,25 @@ private func isAlphanumeric(_ char: Character) -> Bool {
return char.isLetter || char.isNumber
}
/// Generates NIP-10 compliant e-tags for replies.
/// Format: `["e", <event-id>, <relay-url>, <marker>, <pubkey>]`
func nip10_reply_tags(replying_to: NostrEvent, keypair: Keypair, relayURL: RelayURL?) -> [[String]] {
guard let nip10 = replying_to.thread_reply() else {
// we're replying to a post that isn't in a thread,
// just add a single reply-to-root tag
return [["e", replying_to.id.hex(), relayURL?.absoluteString ?? "", "root"]]
return [["e", replying_to.id.hex(), relayURL?.absoluteString ?? "", "root", replying_to.pubkey.hex()]]
}
// otherwise use the root tag from the parent's nip10 reply and include the note
// that we are replying to's note id.
let tags = [
["e", nip10.root.note_id.hex(), nip10.root.relay ?? "", "root"],
["e", replying_to.id.hex(), relayURL?.absoluteString ?? "", "reply"]
]
var rootTag = ["e", nip10.root.note_id.hex(), nip10.root.relay ?? "", "root"]
if let rootPubkey = nip10.root.pubkey {
rootTag.append(rootPubkey.hex())
}
return tags
let replyTag = ["e", replying_to.id.hex(), relayURL?.absoluteString ?? "", "reply", replying_to.pubkey.hex()]
return [rootTag, replyTag]
}
func build_post(state: DamusState, action: PostAction, draft: DraftArtifacts) async -> NostrPost {
@@ -101,18 +101,18 @@ struct InnerSearchResults: View {
case .nip05(let addr):
SearchingEventView(state: damus_state, search_type: .nip05(addr))
case .profile(let pubkey):
SearchingEventView(state: damus_state, search_type: .profile(pubkey))
SearchingEventView(state: damus_state, search_type: .profile(pubkey, relays: []))
case .hex(let h):
VStack(spacing: 10) {
SearchingEventView(state: damus_state, search_type: .event(NoteId(h)))
SearchingEventView(state: damus_state, search_type: .profile(Pubkey(h)))
}
SearchingEventView(state: damus_state, search_type: .event(NoteId(h), relays: []))
SearchingEventView(state: damus_state, search_type: .profile(Pubkey(h), relays: []))
}
case .note(let nid):
SearchingEventView(state: damus_state, search_type: .event(nid))
SearchingEventView(state: damus_state, search_type: .event(nid, relays: []))
case .nevent(let nevent):
SearchingEventView(state: damus_state, search_type: .event(nevent.noteid))
SearchingEventView(state: damus_state, search_type: .event(nevent.noteid, relays: nevent.relays))
case .nprofile(let nprofile):
SearchingEventView(state: damus_state, search_type: .profile(nprofile.author))
SearchingEventView(state: damus_state, search_type: .profile(nprofile.author, relays: nprofile.relays))
case .naddr(let naddr):
SearchingEventView(state: damus_state, search_type: .naddr(naddr))
case .multi(let multi):
@@ -199,13 +199,12 @@ struct SearchResultsView: View {
}
}
/*
struct SearchResultsView_Previews: PreviewProvider {
static var previews: some View {
SearchResultsView(damus_state: test_damus_state(), s)
}
}
*/
/// Interprets a raw search string and maps it to an appropriate `Search` case.
/// - Parameters:
/// - profiles: Profile index used when resolving profile-lookups from the query.
/// - contacts: Contact list used to prioritize or resolve profile-lookups.
/// - search new: The raw user-provided search string to interpret.
/// - Returns: A `Search` value representing the parsed query (e.g., `.nip05`, `.hashtag`, `.hex`, `.profile`, `.note`, `.nevent`, `.nprofile`, `.naddr`, or `.multi`), or `nil` if the input string is empty.
@MainActor
func search_for_string(profiles: Profiles, contacts: Contacts, search new: String) -> Search? {
@@ -240,6 +239,10 @@ func search_for_string(profiles: Profiles, contacts: Contacts, search new: Strin
}
if searchQuery.starts(with: "nevent"), case let .nevent(nevent) = Bech32Object.parse(searchQuery) {
#if DEBUG
print("[nevent] Parsed note ID: \(nevent.noteid.hex())")
print("[nevent] Parsed \(nevent.relays.count) relay hints: \(nevent.relays.map { $0.absoluteString })")
#endif
return .nevent(nevent)
}
@@ -15,8 +15,8 @@ enum SearchState {
}
enum SearchType: Equatable {
case event(NoteId)
case profile(Pubkey)
case event(NoteId, relays: [RelayURL])
case profile(Pubkey, relays: [RelayURL])
case nip05(String)
case naddr(NAddr)
}
@@ -32,15 +32,23 @@ struct SearchingEventView: View {
switch search_type {
case .nip05:
return "Nostr Address"
case .profile:
case .profile(_, _):
return "Profile"
case .event:
case .event(_, _):
return "Note"
case .naddr:
return "Naddr"
}
}
/// Performs the search described by `search` and updates `search_state` with the outcome.
///
/// Starts the lookup for the provided SearchType and transitions `search_state` from `.searching` to one of:
/// - `.found(event)` when the target event is located,
/// - `.found_profile(pubkey)` when a profile pubkey is located (including nip05 resolution),
/// - `.not_found` when the lookup fails or yields no result.
/// - Parameters:
/// - search: The SearchType to perform (nip05, event with optional relays, profile with optional relays, or naddr).
func handle_search(search: SearchType) {
self.search_state = .searching
@@ -76,18 +84,20 @@ struct SearchingEventView: View {
}
}
case .event(let note_id):
case .event(let note_id, let relays):
Task {
let res = await state.nostrNetwork.reader.findEvent(query: .event(evid: note_id))
let targetRelays = relays.isEmpty ? nil : relays
let res = await state.nostrNetwork.reader.findEvent(query: .event(evid: note_id, find_from: targetRelays))
guard case .event(let ev) = res else {
self.search_state = .not_found
return
}
self.search_state = .found(ev)
}
case .profile(let pubkey):
case .profile(let pubkey, let relays):
Task {
let res = await state.nostrNetwork.reader.findEvent(query: .profile(pubkey: pubkey))
let targetRelays = relays.isEmpty ? nil : relays
let res = await state.nostrNetwork.reader.findEvent(query: .profile(pubkey: pubkey, find_from: targetRelays))
guard case .profile(let pubkey) = res else {
self.search_state = .not_found
return
@@ -96,7 +106,8 @@ struct SearchingEventView: View {
}
case .naddr(let naddr):
Task {
let res = await state.nostrNetwork.reader.lookup(naddr: naddr)
let targetRelays = naddr.relays.isEmpty ? nil : naddr.relays
let res = await state.nostrNetwork.reader.lookup(naddr: naddr, to: targetRelays)
guard let res = res else {
self.search_state = .not_found
return
@@ -141,6 +152,6 @@ struct SearchingEventView: View {
struct SearchingEventView_Previews: PreviewProvider {
static var previews: some View {
let state = test_damus_state
SearchingEventView(state: state, search_type: .event(test_note.id))
SearchingEventView(state: state, search_type: .event(test_note.id, relays: []))
}
}
}
+39 -5
View File
@@ -20,13 +20,23 @@ struct DamusURLHandler {
/// - Parameters:
/// - damus_state: The Damus state. May be mutated as part of this function
/// - url: The URL to be opened
/// - Returns: A view to be shown to the user
/// Computes the UI action to perform for an incoming URL and returns the corresponding view or sheet action.
/// - Parameters:
/// - damus_state: The app state used for constructing models, performing network lookups, opening wallet connections, and handling Purple URLs. This state may be mutated (for example, when initiating a wallet connection or delegating to the Purple handler).
/// - url: The incoming URL to parse and handle.
/// - Returns: A `ContentView.ViewOpenAction` that represents the action to take for the URL typically a route to a specific view (profile, thread, search, wallet, script, loadable event), a sheet (error or wallet selection), or an external URL action if applicable. If the URL cannot be parsed, the returned action is an error sheet describing the failure.
static func handle_opening_url_and_compute_view_action(damus_state: DamusState, url: URL) async -> ContentView.ViewOpenAction {
let parsed_url_info = parse_url(url: url)
switch parsed_url_info {
case .profile(let pubkey):
return .route(.ProfileByKey(pubkey: pubkey))
case .profile_reference(let pubkey, let relays):
guard !relays.isEmpty else { return .route(.ProfileByKey(pubkey: pubkey)) }
Task {
let _ = await damus_state.nostrNetwork.reader.findEvent(query: .profile(pubkey: pubkey, find_from: relays))
}
return .route(.ProfileByKey(pubkey: pubkey))
case .filter(let nostrFilter):
let search = SearchModel(state: damus_state, search: nostrFilter)
return .route(.Search(search: search))
@@ -67,7 +77,11 @@ struct DamusURLHandler {
/// This function does not cause any mutations on the app, or any side-effects.
///
/// - Parameter url: The URL to be parsed
/// - Returns: Structured information about the contents inside the URL. Returns `nil` if URL is not compatible, invalid, or could not be parsed for some reason.
/// Interprets a URL as a Damus/nostr resource and produces the corresponding ParsedURLInfo.
///
/// Recognizes Damus purple links, WalletConnect URLs, Bech32 nevent/nprofile forms (including relay hints),
/// and decoded nostr URIs for refs (pubkey, event, naddr, hashtag), filters, scripts, and invoices.
/// - Returns: A `ParsedURLInfo` describing the interpreted resource, or `nil` if the URL cannot be interpreted.
static func parse_url(url: URL) -> ParsedURLInfo? {
if let purple_url = DamusPurpleURL(url: url) {
return .purple(purple_url)
@@ -76,7 +90,26 @@ struct DamusURLHandler {
if let nwc = WalletConnectURL(str: url.absoluteString) {
return .wallet_connect(nwc)
}
// Parse nevent/nprofile directly since decode_nostr_uri discards relay hints
let uri = remove_nostr_uri_prefix(url.absoluteString)
if uri.hasPrefix("nevent"), case .nevent(let nevent) = Bech32Object.parse(uri) {
#if DEBUG
if !nevent.relays.isEmpty {
print("[relay-hints] URL nevent: Found \(nevent.relays.count) hint(s) for \(nevent.noteid.hex().prefix(8))...: \(nevent.relays.map { $0.absoluteString })")
}
#endif
return .event_reference(.note_id(nevent.noteid, relays: nevent.relays))
}
if uri.hasPrefix("nprofile"), case .nprofile(let nprofile) = Bech32Object.parse(uri) {
#if DEBUG
if !nprofile.relays.isEmpty {
print("[relay-hints] URL nprofile: Found \(nprofile.relays.count) hint(s) for \(nprofile.author.hex().prefix(8))...: \(nprofile.relays.map { $0.absoluteString })")
}
#endif
return .profile_reference(nprofile.author, relays: nprofile.relays)
}
guard let link = decode_nostr_uri(url.absoluteString) else {
return nil
}
@@ -87,7 +120,7 @@ struct DamusURLHandler {
case .pubkey(let pk):
return .profile(pk)
case .event(let noteid):
return .event_reference(.note_id(noteid))
return .event_reference(.note_id(noteid, relays: []))
case .hashtag(let ht):
return .filter(.filter_hashtag([ht.hashtag]))
case .param, .quote, .reference:
@@ -111,6 +144,7 @@ struct DamusURLHandler {
enum ParsedURLInfo {
case profile(Pubkey)
case profile_reference(Pubkey, relays: [RelayURL])
case filter(NostrFilter)
case event(NostrEvent)
case event_reference(LoadableNostrEventViewModel.NoteReference)
@@ -119,4 +153,4 @@ struct DamusURLHandler {
case purple(DamusPurpleURL)
case invoice(Invoice)
}
}
}