notecrumbs

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

commit ba27cac3e28214b64ad355dd2771cc0531e0d925
parent f773217d21b6dbd265fa30b4c9247a8e888b3e31
Author: William Casarin <jb55@jb55.com>
Date:   Mon, 16 Feb 2026 16:54:18 -0800

Merge feat/reply-context: display reply context with parent note preview

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

Diffstat:
M.gitignore | 1+
Massets/damus.css | 69+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/html.rs | 88++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
Msrc/unknowns.rs | 8++++++++
4 files changed, 163 insertions(+), 3 deletions(-)

diff --git a/.gitignore b/.gitignore @@ -7,3 +7,4 @@ lock.mdb TODO.bak tags perf.data +.beads/ diff --git a/assets/damus.css b/assets/damus.css @@ -578,6 +578,75 @@ a:hover { margin-bottom: 0.5rem; } +/* Reply context for main note view */ +.damus-reply-context { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.damus-reply-indicator { + color: var(--damus-muted); + font-size: 0.9rem; +} + +.damus-reply-indicator a { + color: var(--damus-accent); + font-weight: 600; + text-decoration: none; +} + +.damus-reply-indicator a:hover { + text-decoration: underline; +} + +.damus-reply-parent { + background: rgba(0, 0, 0, 0.25); + border: 1px solid var(--damus-card-border); + border-radius: 16px; + padding: 0.75rem 1rem; + transition: border-color 160ms ease; + text-decoration: none; + display: block; +} + +.damus-reply-parent:hover { + border-color: rgba(189, 102, 255, 0.3); + text-decoration: none; +} + +.damus-reply-parent-header { + display: flex; + align-items: center; + gap: 0.35rem; + margin-bottom: 0.25rem; +} + +.damus-reply-parent-avatar { + width: 20px; + height: 20px; + border-radius: 50%; + object-fit: cover; + flex-shrink: 0; +} + +.damus-reply-parent-author { + font-weight: 600; + color: #ffffff; + font-size: 0.9rem; +} + +.damus-reply-parent-time { + color: var(--damus-muted); + font-size: 0.85rem; +} + +.damus-reply-parent-content { + color: var(--damus-muted); + font-size: 0.9rem; + line-height: 1.5; +} + .damus-embedded-quote-content { color: var(--damus-text); font-size: 0.9rem; diff --git a/src/html.rs b/src/html.rs @@ -1057,6 +1057,85 @@ fn author_handle_html(profile: Option<&ProfileRecord<'_>>) -> String { .unwrap_or_default() } +/// Builds reply context HTML for the main note view. +/// Returns empty string if the note is not a reply. +fn build_reply_context_html(ndb: &Ndb, txn: &Transaction, note: &Note, base_url: &str) -> String { + use nostrdb::NoteReply; + + let reply_info = NoteReply::new(note.tags()); + + // Prefer .reply() (direct parent), fall back to .root() for single-level replies + let parent_ref = reply_info.reply().or_else(|| reply_info.root()); + let Some(parent_ref) = parent_ref else { + return String::new(); + }; + + match ndb.get_note_by_id(txn, parent_ref.id) { + Ok(parent_note) => { + let parent_profile = ndb.get_profile_by_pubkey(txn, parent_note.pubkey()).ok(); + let parent_name = get_profile_display_name(parent_profile.as_ref()) + .unwrap_or("nostrich"); + let parent_name_html = html_escape::encode_text(parent_name); + + let parent_content = abbreviate(parent_note.content(), 120); + let parent_content_html = html_escape::encode_text(parent_content); + let ellipsis = if parent_content.len() < parent_note.content().len() { + "..." + } else { + "" + }; + + let parent_pfp = pfp_url_attr( + parent_profile + .as_ref() + .and_then(|r| r.record().profile()), + base_url, + ); + + let parent_link = EventId::from_byte_array(*parent_ref.id) + .to_bech32() + .map(|b| format!("/{}", b)) + .unwrap_or_else(|_| "#".to_string()); + + let relative_time = format_relative_time(parent_note.created_at()); + + format!( + r#"<div class="damus-reply-context"> + <div class="damus-reply-indicator">Replying to <a href="{link}">@{name}</a></div> + <a href="{link}" class="damus-reply-parent"> + <div class="damus-reply-parent-header"> + <img src="{pfp}" class="damus-reply-parent-avatar" alt="" /> + <span class="damus-reply-parent-author">{name}</span> + <span class="damus-reply-parent-time">&middot; {time}</span> + </div> + <div class="damus-reply-parent-content">{content}{ellipsis}</div> + </a> + </div>"#, + link = parent_link, + name = parent_name_html, + pfp = parent_pfp, + time = html_escape::encode_text(&relative_time), + content = parent_content_html, + ellipsis = ellipsis, + ) + } + Err(_) => { + // Parent note not found - show minimal indicator + let parent_id_display = EventId::from_byte_array(*parent_ref.id) + .to_bech32() + .map(|b| abbrev_str(&b)) + .unwrap_or_else(|_| "a note".to_string()); + + format!( + r#"<div class="damus-reply-context"> + <div class="damus-reply-indicator">Replying to {}</div> + </div>"#, + html_escape::encode_text(&parent_id_display), + ) + } + } +} + fn build_note_content_html( app: &Notecrumbs, note: &Note, @@ -1109,6 +1188,7 @@ fn build_note_content_html( } } let quotes_html = build_embedded_quotes_html(&app.ndb, txn, &quote_refs); + let reply_context = build_reply_context_html(&app.ndb, txn, note, base_url); format!( r#"<article class="damus-card damus-note"> @@ -1126,6 +1206,7 @@ fn build_note_content_html( </a> </div> </header> + {reply_context} <div class="damus-note-body">{body}</div> {quotes} </article>"#, @@ -1134,6 +1215,7 @@ fn build_note_content_html( author = author_display, handle = author_handle, ts = timestamp_attr, + reply_context = reply_context, body = note_body, quotes = quotes_html ) @@ -1887,7 +1969,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=3\" 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=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", page_title = page_title_html, og_description = og_description_attr, og_image = og_image_attr, @@ -1940,7 +2022,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=3" type="text/css" /> + <link rel="stylesheet" href="/damus.css?v=4" type="text/css" /> <meta property="og:title" content="{og_title}" /> <meta property="og:description" content="{description}" /> <meta property="og:type" content="website" /> @@ -2215,7 +2297,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=3" type="text/css" /> + <link rel="stylesheet" href="/damus.css?v=4" 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/unknowns.rs b/src/unknowns.rs @@ -175,6 +175,10 @@ impl UnknownIds { .into_iter() .collect(); self.add_note_if_missing(ndb, txn, root_ref.id, relay_hint); + // Also fetch root author profile if root note is already available + if let Ok(root_note) = ndb.get_note_by_id(txn, root_ref.id) { + self.add_profile_if_missing(ndb, txn, root_note.pubkey()); + } } // Add reply note if missing (and different from root) @@ -185,6 +189,10 @@ impl UnknownIds { .into_iter() .collect(); self.add_note_if_missing(ndb, txn, reply_ref.id, relay_hint); + // Also fetch reply parent author profile if note is already available + if let Ok(reply_note) = ndb.get_note_by_id(txn, reply_ref.id) { + self.add_profile_if_missing(ndb, txn, reply_note.pubkey()); + } } }