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:
| M | src/html.rs | | | 140 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------------- |
| M | src/main.rs | | | 15 | +++++++++++++++ |
| M | src/render.rs | | | 89 | ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++- |
| A | src/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, ¬e_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, ¬e_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, ¬e);
+
+ 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, "e_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
+ }
+ }
+ }
+ }
+}