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