notecrumbs

a nostr opengraph server build on nostrdb and egui
git clone git://jb55.com/notecrumbs
Log | Files | Refs | README | LICENSE

commit 2ef85fbc1dd57e67a159698cc3ad5f1fafa85473
parent 804ccc2831d241f3f6b203718d04b67744147d56
Author: William Casarin <jb55@jb55.com>
Date:   Wed,  4 Feb 2026 22:35:28 -0800

feat: fetch quoted events from relays with relay provenance

Add an UnknownIds pattern (adapted from notedeck) to fetch quoted events
referenced in q tags and inline mentions. Events are fetched using relay
hints from nevent/naddr bech32 and q tag relay fields.

- Add src/unknowns.rs with UnknownId enum and UnknownIds collection
- Update QuoteRef to include relay hints (Vec<RelayUrl>)
- Extract relay hints from nevent/naddr bech32 and q tag third element
- Add collect_quote_unknowns() and fetch_unknowns() to render.rs
- Fetch quote unknowns in main.rs after primary note is loaded

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

Diffstat:
Msrc/html.rs | 140+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------------
Msrc/main.rs | 15+++++++++++++++
Msrc/render.rs | 89++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Asrc/unknowns.rs | 142+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
4 files changed, 358 insertions(+), 28 deletions(-)

diff --git a/src/html.rs b/src/html.rs @@ -469,9 +469,18 @@ pub fn render_note_content( } /// Represents a quoted event reference from a q tag (NIP-18) or inline mention. -enum QuoteRef { - Event { id: [u8; 32], bech32: Option<String> }, - Article { addr: String, bech32: Option<String> }, +#[derive(Clone, PartialEq)] +pub enum QuoteRef { + Event { + id: [u8; 32], + bech32: Option<String>, + relays: Vec<RelayUrl>, + }, + Article { + addr: String, + bech32: Option<String>, + relays: Vec<RelayUrl>, + }, } /// Extracts quote references from inline nevent/note mentions in content. @@ -490,17 +499,39 @@ fn extract_quote_refs_from_content(note: &Note, blocks: &Blocks) -> Vec<QuoteRef }; match mention { - Mention::Event(ev) => { - quotes.push(QuoteRef::Event { - id: *ev.id(), - bech32: Some(block.as_str().to_string()), - }); + Mention::Event(_ev) => { + let bech32_str = block.as_str(); + // Parse to get relay hints from nevent + if let Ok(Nip19::Event(ev)) = Nip19::from_bech32(bech32_str) { + let relays: Vec<RelayUrl> = ev + .relays + .iter() + .filter_map(|s| RelayUrl::parse(s).ok()) + .collect(); + quotes.push(QuoteRef::Event { + id: *ev.event_id.as_bytes(), + bech32: Some(bech32_str.to_string()), + relays, + }); + } else if let Ok(Nip19::EventId(id)) = Nip19::from_bech32(bech32_str) { + // note1 format has no relay hints + quotes.push(QuoteRef::Event { + id: *id.as_bytes(), + bech32: Some(bech32_str.to_string()), + relays: vec![], + }); + } } - Mention::Note(note_ref) => { - quotes.push(QuoteRef::Event { - id: *note_ref.id(), - bech32: Some(block.as_str().to_string()), - }); + Mention::Note(_note_ref) => { + let bech32_str = block.as_str(); + // note1 format has no relay hints + if let Ok(Nip19::EventId(id)) = Nip19::from_bech32(bech32_str) { + quotes.push(QuoteRef::Event { + id: *id.as_bytes(), + bech32: Some(bech32_str.to_string()), + relays: vec![], + }); + } } // naddr mentions - articles (30023/30024) and highlights (9802) Mention::Addr(_) => { @@ -517,6 +548,7 @@ fn extract_quote_refs_from_content(note: &Note, blocks: &Blocks) -> Vec<QuoteRef quotes.push(QuoteRef::Article { addr, bech32: Some(bech32_str.to_string()), + relays: coord.relays, }); } } @@ -544,20 +576,44 @@ fn extract_quote_refs_from_tags(note: &Note) -> Vec<QuoteRef> { }; let trimmed = value.trim(); + // Optional relay hint in third element of q tag + let tag_relay_hint: Option<RelayUrl> = tag + .get_str(2) + .filter(|s| !s.is_empty()) + .and_then(|s| RelayUrl::parse(s).ok()); + // Try nevent/note bech32 if trimmed.starts_with("nevent1") || trimmed.starts_with("note1") { if let Ok(nip19) = Nip19::from_bech32(trimmed) { - let id = match &nip19 { - Nip19::Event(ev) => Some(*ev.event_id.as_bytes()), - Nip19::EventId(id) => Some(*id.as_bytes()), - _ => None, - }; - if let Some(id) = id { - quotes.push(QuoteRef::Event { - id, - bech32: Some(trimmed.to_owned()), - }); - continue; + match nip19 { + Nip19::Event(ev) => { + // Combine relays from nevent with q tag relay hint + let mut relays: Vec<RelayUrl> = ev + .relays + .iter() + .filter_map(|s| RelayUrl::parse(s).ok()) + .collect(); + if let Some(hint) = &tag_relay_hint { + if !relays.contains(hint) { + relays.push(hint.clone()); + } + } + quotes.push(QuoteRef::Event { + id: *ev.event_id.as_bytes(), + bech32: Some(trimmed.to_owned()), + relays, + }); + continue; + } + Nip19::EventId(id) => { + quotes.push(QuoteRef::Event { + id: *id.as_bytes(), + bech32: Some(trimmed.to_owned()), + relays: tag_relay_hint.clone().into_iter().collect(), + }); + continue; + } + _ => {} } } } @@ -571,9 +627,17 @@ fn extract_quote_refs_from_tags(note: &Note) -> Vec<QuoteRef> { coord.public_key.to_hex(), coord.identifier ); + // Combine relays from naddr with q tag relay hint + let mut relays = coord.relays; + if let Some(hint) = &tag_relay_hint { + if !relays.contains(hint) { + relays.push(hint.clone()); + } + } quotes.push(QuoteRef::Article { addr, bech32: Some(trimmed.to_owned()), + relays, }); continue; } @@ -584,6 +648,7 @@ fn extract_quote_refs_from_tags(note: &Note) -> Vec<QuoteRef> { quotes.push(QuoteRef::Article { addr: trimmed.to_owned(), bech32: None, + relays: tag_relay_hint.into_iter().collect(), }); continue; } @@ -591,7 +656,11 @@ fn extract_quote_refs_from_tags(note: &Note) -> Vec<QuoteRef> { // Try hex event ID if let Ok(bytes) = hex::decode(trimmed) { if let Ok(id) = bytes.try_into() { - quotes.push(QuoteRef::Event { id, bech32: None }); + quotes.push(QuoteRef::Event { + id, + bech32: None, + relays: tag_relay_hint.into_iter().collect(), + }); } } } @@ -599,6 +668,23 @@ fn extract_quote_refs_from_tags(note: &Note) -> Vec<QuoteRef> { quotes } +/// Collects all quote refs from a note (q tags + inline mentions). +pub fn collect_all_quote_refs(ndb: &Ndb, txn: &Transaction, note: &Note) -> Vec<QuoteRef> { + let mut refs = extract_quote_refs_from_tags(note); + + if let Some(blocks) = note.key().and_then(|k| ndb.get_blocks_by_key(txn, k).ok()) { + let inline = extract_quote_refs_from_content(note, &blocks); + // Deduplicate - only add inline refs not already in q tags + for r in inline { + if !refs.contains(&r) { + refs.push(r); + } + } + } + + refs +} + /// Looks up an article by address (kind:pubkey:d-tag) and returns the note key + optional title. fn lookup_article_by_addr(ndb: &Ndb, txn: &Transaction, addr: &str) -> Option<(NoteKey, Option<String>)> { let parts: Vec<&str> = addr.splitn(3, ':').collect(); @@ -650,7 +736,7 @@ fn build_quote_link(quote_ref: &QuoteRef) -> String { use nostr_sdk::prelude::{Coordinate, EventId, Kind}; match quote_ref { - QuoteRef::Event { id, bech32 } => { + QuoteRef::Event { id, bech32, .. } => { if let Some(b) = bech32 { return format!("/{}", b); } @@ -660,7 +746,7 @@ fn build_quote_link(quote_ref: &QuoteRef) -> String { } } } - QuoteRef::Article { addr, bech32 } => { + QuoteRef::Article { addr, bech32, .. } => { if let Some(b) = bech32 { return format!("/{}", b); } diff --git a/src/main.rs b/src/main.rs @@ -33,6 +33,7 @@ mod nip19; mod pfp; mod relay_pool; mod render; +mod unknowns; use relay_pool::RelayPool; @@ -187,6 +188,20 @@ async fn serve( } } + // Always check for quote unknowns when we have a note + if let RenderData::Note(note_rd) = &render_data { + if let Some(unknowns) = + render::collect_quote_unknowns(&app.ndb, &note_rd.note_rd) + { + tracing::debug!("fetching {} quote unknowns", unknowns.relay_hints().len()); + if let Err(err) = + render::fetch_unknowns(&app.relay_pool, &app.ndb, unknowns).await + { + tracing::warn!("failed to fetch quote unknowns: {err}"); + } + } + } + if let RenderData::Profile(profile_opt) = &render_data { let maybe_pubkey = { let txn = Transaction::new(&app.ndb)?; diff --git a/src/render.rs b/src/render.rs @@ -526,17 +526,104 @@ impl RenderData { } } - match fetch_handle.await { + // Wait for primary fetch to complete + let primary_result = match fetch_handle.await { Ok(Ok(())) => Ok(()), Ok(Err(err)) => Err(err), Err(join_err) => Err(Error::Generic(format!( "relay fetch task failed: {}", join_err ))), + }; + + // After primary note is fetched, collect and fetch quote unknowns + if let RenderData::Note(note_rd) = self { + debug!("checking for quote unknowns"); + if let Some(unknowns) = collect_quote_unknowns(&ndb, &note_rd.note_rd) { + debug!("fetching {} quote unknowns", unknowns.relay_hints().len()); + if let Err(err) = fetch_unknowns(&relay_pool, &ndb, unknowns).await { + warn!("failed to fetch quote unknowns: {err}"); + } + } else { + debug!("no quote unknowns to fetch"); + } } + + primary_result } } +/// Collect unknown IDs from a note's quote references. +pub fn collect_quote_unknowns( + ndb: &Ndb, + note_rd: &NoteRenderData, +) -> Option<crate::unknowns::UnknownIds> { + let txn = Transaction::new(ndb).ok()?; + let note = note_rd.lookup(&txn, ndb).ok()?; + let quote_refs = crate::html::collect_all_quote_refs(ndb, &txn, &note); + + debug!("found {} quote refs in note", quote_refs.len()); + + if quote_refs.is_empty() { + return None; + } + + let mut unknowns = crate::unknowns::UnknownIds::new(); + unknowns.collect_from_quote_refs(ndb, &txn, &quote_refs); + + debug!("collected {} unknowns from quote refs", unknowns.ids_len()); + + if unknowns.is_empty() { + None + } else { + Some(unknowns) + } +} + +/// Fetch unknown IDs (quoted events, profiles) from relays using relay hints. +pub async fn fetch_unknowns( + relay_pool: &Arc<RelayPool>, + ndb: &Ndb, + unknowns: crate::unknowns::UnknownIds, +) -> Result<()> { + use nostr_sdk::JsonUtil; + + // Collect relay hints before consuming unknowns + let relay_hints = unknowns.relay_hints(); + let relay_targets: Vec<RelayUrl> = if relay_hints.is_empty() { + relay_pool.default_relays().to_vec() + } else { + relay_hints.into_iter().collect() + }; + + // Build and convert filters in one go (nostrdb::Filter is not Send) + let nostr_filters: Vec<nostr::Filter> = { + let filters = unknowns.to_filters(); + if filters.is_empty() { + return Ok(()); + } + filters.iter().map(convert_filter).collect() + }; + + // Now we can await - nostrdb::Filter has been dropped + relay_pool.ensure_relays(relay_targets.clone()).await?; + + debug!("fetching {} unknowns from {:?}", nostr_filters.len(), relay_targets); + + // Stream with shorter timeout since these are secondary fetches + let mut stream = relay_pool + .stream_events(nostr_filters, &relay_targets, Duration::from_millis(1500)) + .await?; + + while let Some(event) = stream.next().await { + if let Err(err) = ndb.process_event(&event.as_json()) { + warn!("error processing quoted event: {err}"); + } + } + + Ok(()) +} + fn collect_relay_hints(event: &Event) -> Vec<RelayUrl> { let mut relays = Vec::new(); for tag in event.tags.iter() { diff --git a/src/unknowns.rs b/src/unknowns.rs @@ -0,0 +1,142 @@ +//! Unknown ID collection with relay provenance for fetching quoted events. +//! +//! Adapted from notedeck's unknowns pattern for notecrumbs' one-shot HTTP context. + +use crate::html::QuoteRef; +use nostr::RelayUrl; +use nostrdb::{Ndb, Transaction}; +use std::collections::{HashMap, HashSet}; + +/// An unknown ID that needs to be fetched from relays. +#[derive(Hash, Eq, PartialEq, Clone, Debug)] +pub enum UnknownId { + /// A note ID (event) + NoteId([u8; 32]), + /// A profile pubkey + Profile([u8; 32]), +} + +/// Collection of unknown IDs with their associated relay hints. +#[derive(Default, Debug)] +pub struct UnknownIds { + ids: HashMap<UnknownId, HashSet<RelayUrl>>, +} + +impl UnknownIds { + pub fn new() -> Self { + Self::default() + } + + pub fn is_empty(&self) -> bool { + self.ids.is_empty() + } + + pub fn ids_len(&self) -> usize { + self.ids.len() + } + + /// Add a note ID if it's not already in ndb. + pub fn add_note_if_missing( + &mut self, + ndb: &Ndb, + txn: &Transaction, + id: &[u8; 32], + relays: impl IntoIterator<Item = RelayUrl>, + ) { + // Check if we already have this note + if ndb.get_note_by_id(txn, id).is_ok() { + return; + } + + let unknown_id = UnknownId::NoteId(*id); + self.ids + .entry(unknown_id) + .or_default() + .extend(relays); + } + + /// Add a profile pubkey if it's not already in ndb. + pub fn add_profile_if_missing(&mut self, ndb: &Ndb, txn: &Transaction, pk: &[u8; 32]) { + // Check if we already have this profile + if ndb.get_profile_by_pubkey(txn, pk).is_ok() { + return; + } + + let unknown_id = UnknownId::Profile(*pk); + self.ids.entry(unknown_id).or_default(); + } + + /// Collect all relay hints from unknowns. + pub fn relay_hints(&self) -> HashSet<RelayUrl> { + self.ids + .values() + .flat_map(|relays| relays.iter().cloned()) + .collect() + } + + /// Build nostrdb filters for fetching unknown IDs. + pub fn to_filters(&self) -> Vec<nostrdb::Filter> { + if self.ids.is_empty() { + return vec![]; + } + + let mut filters = Vec::new(); + + // Collect note IDs + let note_ids: Vec<&[u8; 32]> = self + .ids + .keys() + .filter_map(|id| match id { + UnknownId::NoteId(id) => Some(id), + _ => None, + }) + .collect(); + + if !note_ids.is_empty() { + filters.push(nostrdb::Filter::new().ids(note_ids).build()); + } + + // Collect profile pubkeys + let pubkeys: Vec<&[u8; 32]> = self + .ids + .keys() + .filter_map(|id| match id { + UnknownId::Profile(pk) => Some(pk), + _ => None, + }) + .collect(); + + if !pubkeys.is_empty() { + filters.push(nostrdb::Filter::new().authors(pubkeys).kinds([0]).build()); + } + + filters + } + + /// Collect unknown IDs from quote refs. + pub fn collect_from_quote_refs(&mut self, ndb: &Ndb, txn: &Transaction, quote_refs: &[QuoteRef]) { + for quote_ref in quote_refs { + match quote_ref { + QuoteRef::Event { id, relays, .. } => { + self.add_note_if_missing(ndb, txn, id, relays.iter().cloned()); + } + QuoteRef::Article { addr, relays, .. } => { + // For articles, we need to parse the address to get the author pubkey + // and check if we have the article. For now, just try to look it up. + let parts: Vec<&str> = addr.splitn(3, ':').collect(); + if parts.len() >= 2 { + if let Ok(pk_bytes) = hex::decode(parts[1]) { + if let Ok(pk) = pk_bytes.try_into() { + // Add author profile if missing + self.add_profile_if_missing(ndb, txn, &pk); + } + } + } + // Note: For articles we'd ideally build an address filter, + // but for now we rely on the profile fetch to help + let _ = relays; // TODO: use for article fetching + } + } + } + } +}