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:
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, "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);
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,
+ ¬e_rd.note_rd,
+ ¬e_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() {