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:
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, "e_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,
¬e_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}");
+ }
}
}