notecrumbs

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

commit c62bc99b048c6f1ecf5597345855dd4ae7952436
parent 092bf2aff0b268c8504a387570283dfca750419a
Author: William Casarin <jb55@jb55.com>
Date:   Mon, 16 Feb 2026 18:50:09 -0800

feat: display reaction counts on notes using nostrdb metadata

Fetch kind:7 reactions from relays and display aggregated counts
from nostrdb metadata table. Shows heart + like count and top
custom emojis with their counts in a footer below the note body.

Also fixes convert_filter to handle ID elements in tag filters
(previously only string elements were supported, causing e-tag
filters to be silently dropped).

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

Diffstat:
Massets/damus.css | 23+++++++++++++++++++++++
Msrc/html.rs | 73+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----
Msrc/main.rs | 12++++++++++++
Msrc/render.rs | 63++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------
4 files changed, 160 insertions(+), 11 deletions(-)

diff --git a/assets/damus.css b/assets/damus.css @@ -782,3 +782,26 @@ a:hover { height: 56px; } } + +/* Reaction counts footer */ +.damus-note-reactions { + display: flex; + align-items: center; + gap: 0.75rem; + margin-top: 0.75rem; + padding-top: 0.6rem; + border-top: 1px solid rgba(255, 255, 255, 0.08); + flex-wrap: wrap; +} + +.damus-reaction { + display: inline-flex; + align-items: center; + gap: 0.3rem; + color: var(--damus-muted); + font-size: 0.85rem; +} + +.damus-reaction-count { + font-weight: 500; +} diff --git a/src/html.rs b/src/html.rs @@ -9,6 +9,7 @@ use http_body_util::Full; use hyper::{body::Bytes, header, Request, Response, StatusCode}; use nostr::nips::nip19::Nip19Event; use nostr_sdk::prelude::{EventId, FromBech32, Nip19, PublicKey, RelayUrl, ToBech32}; +use nostrdb::NoteMetadataEntryVariant; use nostrdb::{ BlockType, Blocks, Filter, Mention, Ndb, NdbProfile, Note, NoteKey, ProfileRecord, Transaction, }; @@ -1128,6 +1129,65 @@ fn get_parent_note_info( } } +fn build_reactions_html(ndb: &Ndb, txn: &Transaction, note: &Note) -> String { + let meta = match ndb.get_note_metadata(txn, note.id()) { + Ok(m) => m, + Err(_) => return String::new(), + }; + + let mut total_reactions: u32 = 0; + let mut emojis: Vec<(String, u32)> = Vec::new(); + + for entry in meta { + match entry { + NoteMetadataEntryVariant::Counts(counts) => { + total_reactions = counts.reactions(); + } + NoteMetadataEntryVariant::Reaction(reaction) => { + let mut buf = [0i8; 128]; + let s = reaction.as_str(&mut buf); + let count = reaction.count(); + if count > 0 && s != "+" && !s.is_empty() { + emojis.push((s.to_string(), count)); + } + } + NoteMetadataEntryVariant::Unknown(_) => {} + } + } + + if total_reactions == 0 { + return String::new(); + } + + // Sort emojis by count descending + emojis.sort_by(|a, b| b.1.cmp(&a.1)); + + let mut html = String::from(r#"<div class="damus-note-reactions">"#); + + // Heart icon + total like count (total_reactions minus custom emoji counts = likes) + let custom_total: u32 = emojis.iter().map(|(_, c)| c).sum(); + let likes = total_reactions.saturating_sub(custom_total); + + if likes > 0 { + html.push_str(&format!( + r#"<span class="damus-reaction"><span class="damus-reaction-emoji">{}</span><span class="damus-reaction-count">{}</span></span>"#, + "❤️", likes + )); + } + + // Top custom emojis (limit to 5) + for (emoji, count) in emojis.iter().take(5) { + html.push_str(&format!( + r#"<span class="damus-reaction"><span class="damus-reaction-emoji">{}</span><span class="damus-reaction-count">{}</span></span>"#, + html_escape::encode_text(emoji), + count + )); + } + + html.push_str("</div>"); + html +} + fn build_note_content_html( app: &Notecrumbs, note: &Note, @@ -1180,6 +1240,7 @@ fn build_note_content_html( } } let quotes_html = build_embedded_quotes_html(&app.ndb, txn, &quote_refs); + let reactions_html = build_reactions_html(&app.ndb, txn, note); let parent_info = get_parent_note_info(&app.ndb, txn, note, base_url); match parent_info { @@ -1231,6 +1292,7 @@ fn build_note_content_html( </div> <div class="damus-note-body">{body}</div> {quotes} + {reactions} </div> </div> </article>"#, @@ -1248,6 +1310,7 @@ fn build_note_content_html( ts = timestamp_attr, body = note_body, quotes = quotes_html, + reactions = reactions_html, ) } None => { @@ -1270,6 +1333,7 @@ fn build_note_content_html( </header> <div class="damus-note-body">{body}</div> {quotes} + {reactions} </article>"#, base = base_url, pfp = pfp_attr, @@ -1277,7 +1341,8 @@ fn build_note_content_html( handle = author_handle, ts = timestamp_attr, body = note_body, - quotes = quotes_html + quotes = quotes_html, + reactions = reactions_html ) } } @@ -2031,7 +2096,7 @@ pub fn serve_profile_html( let page = format!( "<!DOCTYPE html>\n\ -<html lang=\"en\">\n <head>\n <meta charset=\"UTF-8\" />\n <title>{page_title}</title>\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />\n <meta name=\"description\" content=\"{og_description}\" />\n <link rel=\"preload\" href=\"/fonts/PoetsenOne-Regular.ttf\" as=\"font\" type=\"font/ttf\" crossorigin />\n <link rel=\"stylesheet\" href=\"/damus.css?v=4\" type=\"text/css\" />\n <meta property=\"og:title\" content=\"{og_title}\" />\n <meta property=\"og:description\" content=\"{og_description}\" />\n <meta property=\"og:type\" content=\"profile\" />\n <meta property=\"og:url\" content=\"{canonical_url}\" />\n <meta property=\"og:image\" content=\"{og_image}\" />\n <meta property=\"og:image:alt\" content=\"{og_image_alt}\" />\n <meta property=\"og:image:height\" content=\"600\" />\n <meta property=\"og:image:width\" content=\"1200\" />\n <meta property=\"og:image:type\" content=\"image/png\" />\n <meta property=\"og:site_name\" content=\"Damus\" />\n <meta name=\"twitter:card\" content=\"summary_large_image\" />\n <meta name=\"twitter:title\" content=\"{og_title}\" />\n <meta name=\"twitter:description\" content=\"{og_description}\" />\n <meta name=\"twitter:image\" content=\"{og_image}\" />\n <meta name=\"theme-color\" content=\"#bd66ff\" />\n </head>\n <body>\n <div class=\"damus-app\">\n <header class=\"damus-header\">\n <a class=\"damus-logo-link\" href=\"https://damus.io\" target=\"_blank\" rel=\"noopener noreferrer\"><img class=\"damus-logo-image\" src=\"/assets/logo_icon.png?v=2\" alt=\"Damus\" width=\"40\" height=\"40\" /></a>\n <div class=\"damus-header-actions\">\n <a class=\"damus-cta\" data-damus-cta data-default-url=\"nostr:{bech32}\" href=\"nostr:{bech32}\">Open in Damus</a>\n </div>\n </header>\n <main class=\"damus-main\">\n{main_content}\n </main>\n <footer class=\"damus-footer\">\n <a href=\"https://github.com/damus-io/notecrumbs\" target=\"_blank\" rel=\"noopener noreferrer\">Rendered by notecrumbs</a>\n </footer>\n </div>\n{scripts}\n </body>\n</html>\n", +<html lang=\"en\">\n <head>\n <meta charset=\"UTF-8\" />\n <title>{page_title}</title>\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />\n <meta name=\"description\" content=\"{og_description}\" />\n <link rel=\"preload\" href=\"/fonts/PoetsenOne-Regular.ttf\" as=\"font\" type=\"font/ttf\" crossorigin />\n <link rel=\"stylesheet\" href=\"/damus.css?v=5\" type=\"text/css\" />\n <meta property=\"og:title\" content=\"{og_title}\" />\n <meta property=\"og:description\" content=\"{og_description}\" />\n <meta property=\"og:type\" content=\"profile\" />\n <meta property=\"og:url\" content=\"{canonical_url}\" />\n <meta property=\"og:image\" content=\"{og_image}\" />\n <meta property=\"og:image:alt\" content=\"{og_image_alt}\" />\n <meta property=\"og:image:height\" content=\"600\" />\n <meta property=\"og:image:width\" content=\"1200\" />\n <meta property=\"og:image:type\" content=\"image/png\" />\n <meta property=\"og:site_name\" content=\"Damus\" />\n <meta name=\"twitter:card\" content=\"summary_large_image\" />\n <meta name=\"twitter:title\" content=\"{og_title}\" />\n <meta name=\"twitter:description\" content=\"{og_description}\" />\n <meta name=\"twitter:image\" content=\"{og_image}\" />\n <meta name=\"theme-color\" content=\"#bd66ff\" />\n </head>\n <body>\n <div class=\"damus-app\">\n <header class=\"damus-header\">\n <a class=\"damus-logo-link\" href=\"https://damus.io\" target=\"_blank\" rel=\"noopener noreferrer\"><img class=\"damus-logo-image\" src=\"/assets/logo_icon.png?v=2\" alt=\"Damus\" width=\"40\" height=\"40\" /></a>\n <div class=\"damus-header-actions\">\n <a class=\"damus-cta\" data-damus-cta data-default-url=\"nostr:{bech32}\" href=\"nostr:{bech32}\">Open in Damus</a>\n </div>\n </header>\n <main class=\"damus-main\">\n{main_content}\n </main>\n <footer class=\"damus-footer\">\n <a href=\"https://github.com/damus-io/notecrumbs\" target=\"_blank\" rel=\"noopener noreferrer\">Rendered by notecrumbs</a>\n </footer>\n </div>\n{scripts}\n </body>\n</html>\n", page_title = page_title_html, og_description = og_description_attr, og_image = og_image_attr, @@ -2084,7 +2149,7 @@ pub fn serve_homepage(_r: Request<hyper::body::Incoming>) -> Result<Response<Ful <meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="description" content="{description}" /> <link rel="preload" href="/fonts/PoetsenOne-Regular.ttf" as="font" type="font/ttf" crossorigin /> - <link rel="stylesheet" href="/damus.css?v=4" type="text/css" /> + <link rel="stylesheet" href="/damus.css?v=5" type="text/css" /> <meta property="og:title" content="{og_title}" /> <meta property="og:description" content="{description}" /> <meta property="og:type" content="website" /> @@ -2359,7 +2424,7 @@ pub fn serve_note_html( <meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="description" content="{og_description}" /> <link rel="preload" href="/fonts/PoetsenOne-Regular.ttf" as="font" type="font/ttf" crossorigin /> - <link rel="stylesheet" href="/damus.css?v=4" type="text/css" /> + <link rel="stylesheet" href="/damus.css?v=5" type="text/css" /> <meta property="og:title" content="{og_title}" /> <meta property="og:description" content="{og_description}" /> <meta property="og:type" content="{og_type}" /> diff --git a/src/main.rs b/src/main.rs @@ -222,6 +222,18 @@ async fn serve( tracing::warn!("failed to fetch unknowns: {err}"); } } + + // Fetch reactions (kind:7) for the note + if let Err(err) = render::fetch_reactions( + &app.relay_pool, + &app.ndb, + &note_rd.note_rd, + &note_rd.source_relays, + ) + .await + { + tracing::warn!("failed to fetch reactions: {err}"); + } } if let RenderData::Profile(profile_opt) = &render_data { diff --git a/src/render.rs b/src/render.rs @@ -245,13 +245,19 @@ pub(crate) fn convert_filter(ndb_filter: &nostrdb::Filter) -> nostr::Filter { let mut elems: BTreeSet<String> = BTreeSet::new(); for elem in tag_elems { - if let FilterElement::Str(s) = elem { - elems.insert(s.to_string()); - } else { - warn!( - "not adding non-string element from filter tag '{}", - single_letter - ); + match elem { + FilterElement::Str(s) => { + elems.insert(s.to_string()); + } + FilterElement::Id(id) => { + elems.insert(hex::encode(id)); + } + _ => { + warn!( + "not adding non-string element from filter tag '{}", + single_letter + ); + } } } @@ -729,6 +735,49 @@ pub async fn fetch_unknowns( Ok(()) } +/// Fetch kind:7 reactions for a note from relays and ingest into ndb. +pub async fn fetch_reactions( + relay_pool: &Arc<RelayPool>, + ndb: &Ndb, + note_rd: &NoteRenderData, + source_relays: &[RelayUrl], +) -> Result<()> { + use nostr_sdk::JsonUtil; + + // Build the reaction filter (nostrdb::Filter is not Send, so convert before await) + let reaction_filter = { + let txn = Transaction::new(ndb)?; + let note = note_rd.lookup(&txn, ndb)?; + convert_filter(&nostrdb::Filter::new().kinds([7]).event(note.id()).build()) + }; + + let relay_targets: Vec<RelayUrl> = if source_relays.is_empty() { + relay_pool.default_relays().to_vec() + } else { + source_relays.to_vec() + }; + + relay_pool.ensure_relays(relay_targets.clone()).await?; + + debug!("fetching reactions from {:?}", relay_targets); + + let mut stream = relay_pool + .stream_events(reaction_filter, &relay_targets, Duration::from_millis(1500)) + .await?; + + while let Some(relay_event) = stream.next().await { + let ingest_meta = relay_event + .relay_url() + .map(|url| IngestMetadata::new().relay(url.as_str())) + .unwrap_or_else(IngestMetadata::new); + if let Err(err) = ndb.process_event_with(&relay_event.event.as_json(), ingest_meta) { + warn!("error processing reaction event: {err}"); + } + } + + Ok(()) +} + fn collect_relay_hints(event: &Event) -> Vec<RelayUrl> { let mut relays = Vec::new(); for tag in event.tags.iter() {