notecrumbs

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

commit 45d7d069992ee6f02187ff81e235f900bc238260
parent c62bc99b048c6f1ecf5597345855dd4ae7952436
Author: William Casarin <jb55@jb55.com>
Date:   Tue, 17 Feb 2026 10:28:30 -0800

feat: display reply, repost, and reaction counts in note footer

Extend the note stats footer to show reply and repost counts alongside
reactions using nostrdb metadata. Root notes show thread_replies() for
total thread count, while reply notes show direct_replies(). Fetch
kind:1 replies and kind:6 reposts from relays in addition to kind:7
reactions to populate the metadata table.

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

Diffstat:
Massets/damus.css | 15++++++++++-----
Msrc/html.rs | 76++++++++++++++++++++++++++++++++++++++++++++++++++--------------------------
Msrc/main.rs | 6+++---
Msrc/render.rs | 39++++++++++++++++++++++++---------------
4 files changed, 87 insertions(+), 49 deletions(-)

diff --git a/assets/damus.css b/assets/damus.css @@ -783,18 +783,18 @@ a:hover { } } -/* Reaction counts footer */ -.damus-note-reactions { +/* Note stats footer (replies, reposts, reactions) */ +.damus-note-stats { display: flex; align-items: center; - gap: 0.75rem; + gap: 1rem; margin-top: 0.75rem; padding-top: 0.6rem; border-top: 1px solid rgba(255, 255, 255, 0.08); flex-wrap: wrap; } -.damus-reaction { +.damus-stat { display: inline-flex; align-items: center; gap: 0.3rem; @@ -802,6 +802,11 @@ a:hover { font-size: 0.85rem; } -.damus-reaction-count { +.damus-stat-icon { + width: 1rem; + height: 1rem; +} + +.damus-stat-count { font-weight: 500; } diff --git a/src/html.rs b/src/html.rs @@ -1129,19 +1129,27 @@ fn get_parent_note_info( } } -fn build_reactions_html(ndb: &Ndb, txn: &Transaction, note: &Note) -> String { +fn build_note_stats_html(ndb: &Ndb, txn: &Transaction, note: &Note, is_root: bool) -> 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 reply_count: u32 = 0; + let mut repost_count: u16 = 0; let mut emojis: Vec<(String, u32)> = Vec::new(); for entry in meta { match entry { NoteMetadataEntryVariant::Counts(counts) => { total_reactions = counts.reactions(); + reply_count = if is_root { + counts.thread_replies() + } else { + counts.direct_replies() as u32 + }; + repost_count = counts.reposts(); } NoteMetadataEntryVariant::Reaction(reaction) => { let mut buf = [0i8; 128]; @@ -1155,35 +1163,50 @@ fn build_reactions_html(ndb: &Ndb, txn: &Transaction, note: &Note) -> String { } } - if total_reactions == 0 { + if total_reactions == 0 && reply_count == 0 && repost_count == 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">"#); + let mut html = String::from(r#"<div class="damus-note-stats">"#); - // 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 { + // Reply count + if reply_count > 0 { html.push_str(&format!( - r#"<span class="damus-reaction"><span class="damus-reaction-emoji">{}</span><span class="damus-reaction-count">{}</span></span>"#, - "❤️", likes + r#"<span class="damus-stat"><svg class="damus-stat-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"></path></svg><span class="damus-stat-count">{}</span></span>"#, + reply_count )); } - // Top custom emojis (limit to 5) - for (emoji, count) in emojis.iter().take(5) { + // Repost count + if repost_count > 0 { 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 + r#"<span class="damus-stat"><svg class="damus-stat-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17 1l4 4-4 4"></path><path d="M3 11V9a4 4 0 0 1 4-4h14"></path><path d="M7 23l-4-4 4-4"></path><path d="M21 13v2a4 4 0 0 1-4 4H3"></path></svg><span class="damus-stat-count">{}</span></span>"#, + repost_count )); } + // Reactions + if total_reactions > 0 { + emojis.sort_by(|a, b| b.1.cmp(&a.1)); + 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-stat"><span class="damus-reaction-emoji">❤️</span><span class="damus-stat-count">{}</span></span>"#, + likes + )); + } + + for (emoji, count) in emojis.iter().take(5) { + html.push_str(&format!( + r#"<span class="damus-stat"><span class="damus-reaction-emoji">{}</span><span class="damus-stat-count">{}</span></span>"#, + html_escape::encode_text(emoji), + count + )); + } + } + html.push_str("</div>"); html } @@ -1240,8 +1263,9 @@ 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); + let is_root = parent_info.is_none(); + let stats_html = build_note_stats_html(&app.ndb, txn, note, is_root); match parent_info { Some(parent) => { @@ -1292,7 +1316,7 @@ fn build_note_content_html( </div> <div class="damus-note-body">{body}</div> {quotes} - {reactions} + {stats} </div> </div> </article>"#, @@ -1310,7 +1334,7 @@ fn build_note_content_html( ts = timestamp_attr, body = note_body, quotes = quotes_html, - reactions = reactions_html, + stats = stats_html, ) } None => { @@ -1333,7 +1357,7 @@ fn build_note_content_html( </header> <div class="damus-note-body">{body}</div> {quotes} - {reactions} + {stats} </article>"#, base = base_url, pfp = pfp_attr, @@ -1342,7 +1366,7 @@ fn build_note_content_html( ts = timestamp_attr, body = note_body, quotes = quotes_html, - reactions = reactions_html + stats = stats_html ) } } @@ -2096,7 +2120,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=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", +<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=6\" 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, @@ -2149,7 +2173,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=5" type="text/css" /> + <link rel="stylesheet" href="/damus.css?v=6" type="text/css" /> <meta property="og:title" content="{og_title}" /> <meta property="og:description" content="{description}" /> <meta property="og:type" content="website" /> @@ -2424,7 +2448,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=5" type="text/css" /> + <link rel="stylesheet" href="/damus.css?v=6" 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 @@ -223,8 +223,8 @@ async fn serve( } } - // Fetch reactions (kind:7) for the note - if let Err(err) = render::fetch_reactions( + // Fetch note stats (reactions, replies, reposts) for the note + if let Err(err) = render::fetch_note_stats( &app.relay_pool, &app.ndb, &note_rd.note_rd, @@ -232,7 +232,7 @@ async fn serve( ) .await { - tracing::warn!("failed to fetch reactions: {err}"); + tracing::warn!("failed to fetch note stats: {err}"); } } diff --git a/src/render.rs b/src/render.rs @@ -736,7 +736,8 @@ pub async fn fetch_unknowns( } /// Fetch kind:7 reactions for a note from relays and ingest into ndb. -pub async fn fetch_reactions( +/// Fetch note stats (reactions, replies, reposts) from relays and ingest into ndb. +pub async fn fetch_note_stats( relay_pool: &Arc<RelayPool>, ndb: &Ndb, note_rd: &NoteRenderData, @@ -744,11 +745,17 @@ pub async fn fetch_reactions( ) -> Result<()> { use nostr_sdk::JsonUtil; - // Build the reaction filter (nostrdb::Filter is not Send, so convert before await) - let reaction_filter = { + // Build filters for reactions (kind:7), replies (kind:1), and reposts (kind:6) + // nostrdb::Filter is not Send, so convert before await + let filters: Vec<nostr::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 id = note.id(); + vec![ + convert_filter(&nostrdb::Filter::new().kinds([7]).event(id).build()), + convert_filter(&nostrdb::Filter::new().kinds([1]).event(id).build()), + convert_filter(&nostrdb::Filter::new().kinds([6]).event(id).build()), + ] }; let relay_targets: Vec<RelayUrl> = if source_relays.is_empty() { @@ -759,19 +766,21 @@ pub async fn fetch_reactions( relay_pool.ensure_relays(relay_targets.clone()).await?; - debug!("fetching reactions from {:?}", relay_targets); + debug!("fetching note stats from {:?}", relay_targets); - let mut stream = relay_pool - .stream_events(reaction_filter, &relay_targets, Duration::from_millis(1500)) - .await?; + for filter in filters { + let mut stream = relay_pool + .stream_events(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}"); + 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 event: {err}"); + } } }