notecrumbs

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

commit 0da0d4c7e41ccd05a89137da6d2fc9874d54b24c
parent f7d0cb9ed9571f384e4f1fd54c93f61aa1b685a1
Author: William Casarin <jb55@jb55.com>
Date:   Mon, 16 Feb 2026 16:00:54 -0800

feat: display reply context with parent note preview

When rendering a note that is a reply, show "Replying to @name" with a
compact preview card of the parent note (avatar, name, timestamp, and
truncated content). Falls back to abbreviated bech32 when parent note
is not available in nostrdb.

Also fetches parent author profiles during unknown collection so
display names are available for the reply context.

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

Diffstat:
A.beads/issues.jsonl | 20++++++++++++++++++++
Massets/damus.css | 69+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/html.rs | 88++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
Msrc/unknowns.rs | 8++++++++
4 files changed, 182 insertions(+), 3 deletions(-)

diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl @@ -0,0 +1,20 @@ +{"id":"notecrumbs-1jl","title":"Missing Arabic font","description":"Arabic text not rendering correctly due to missing font support. GitHub issue #12.","status":"open","priority":2,"issue_type":"task","owner":"jb55@jb55.com","created_at":"2026-02-16T15:40:51.579483-08:00","created_by":"William Casarin","updated_at":"2026-02-16T15:40:51.579483-08:00","labels":["bug"]} +{"id":"notecrumbs-1xx","title":"Display reposts \u0026 quotes counts","description":"Show repost and quote counts on rendered notes. GitHub issue #34.","status":"open","priority":2,"issue_type":"task","owner":"jb55@jb55.com","created_at":"2026-02-16T15:40:41.07224-08:00","created_by":"William Casarin","updated_at":"2026-02-16T15:40:41.07224-08:00","labels":["feature"]} +{"id":"notecrumbs-2ac","title":"Fix deadlock","description":"Deadlock occurring in the application. GitHub issue #24.","status":"open","priority":1,"issue_type":"task","owner":"jb55@jb55.com","created_at":"2026-02-16T15:40:56.330585-08:00","created_by":"William Casarin","updated_at":"2026-02-16T15:40:56.330585-08:00","labels":["bug"]} +{"id":"notecrumbs-3an","title":"Display zap tally","description":"Show total zap amount on rendered notes. GitHub issue #8.","status":"open","priority":2,"issue_type":"task","owner":"jb55@jb55.com","created_at":"2026-02-16T15:40:41.794335-08:00","created_by":"William Casarin","updated_at":"2026-02-16T15:40:41.794335-08:00","labels":["feature"]} +{"id":"notecrumbs-4dm","title":"Display reply details/context","description":"Show who a note is replying to with context from the parent note. GitHub issue #42.","status":"open","priority":2,"issue_type":"task","owner":"jb55@jb55.com","created_at":"2026-02-16T15:40:35.840292-08:00","created_by":"William Casarin","updated_at":"2026-02-16T15:40:35.840292-08:00","labels":["feature"],"dependencies":[{"issue_id":"notecrumbs-4dm","depends_on_id":"notecrumbs-ecd","type":"blocks","created_at":"2026-02-16T15:41:16.715065-08:00","created_by":"William Casarin"}]} +{"id":"notecrumbs-4ox","title":"Create profile page","description":"Full profile page rendering with user info, recent notes, etc. GitHub issue #1.","status":"open","priority":2,"issue_type":"task","owner":"jb55@jb55.com","created_at":"2026-02-16T15:41:01.706262-08:00","created_by":"William Casarin","updated_at":"2026-02-16T15:41:01.706262-08:00","labels":["feature"]} +{"id":"notecrumbs-7a3","title":"Display reactions","description":"Show reaction counts (likes/custom reactions) on rendered notes. GitHub issue #33.","status":"open","priority":2,"issue_type":"task","owner":"jb55@jb55.com","created_at":"2026-02-16T15:40:40.329235-08:00","created_by":"William Casarin","updated_at":"2026-02-16T15:40:40.329235-08:00","labels":["feature"]} +{"id":"notecrumbs-7bz","title":"Add 'note seen on relays' display","description":"Show which relays a note was seen on. GitHub issue #9.","status":"open","priority":2,"issue_type":"task","owner":"jb55@jb55.com","created_at":"2026-02-16T15:40:58.478603-08:00","created_by":"William Casarin","updated_at":"2026-02-16T15:40:58.478603-08:00","labels":["feature"]} +{"id":"notecrumbs-7gu","title":"Share hashtag feed externally","description":"Render a feed of notes for a given hashtag. GitHub issue #15.","status":"open","priority":2,"issue_type":"task","owner":"jb55@jb55.com","created_at":"2026-02-16T15:41:05.217897-08:00","created_by":"William Casarin","updated_at":"2026-02-16T15:41:05.217897-08:00","labels":["feature"]} +{"id":"notecrumbs-ecd","title":"On-demand fetching for parent events","description":"Fetch parent events from relays when rendering a reply, so thread context can be displayed even if the parent isn't cached. GitHub issue #48.","status":"in_progress","priority":2,"issue_type":"task","owner":"jb55@jb55.com","created_at":"2026-02-16T15:40:37.21408-08:00","created_by":"William Casarin","updated_at":"2026-02-16T15:43:17.299152-08:00","labels":["feature"]} +{"id":"notecrumbs-fn5","title":"Zap a notecrumb","description":"Allow users to zap directly from the notecrumbs page. GitHub issue #7.","status":"open","priority":2,"issue_type":"task","owner":"jb55@jb55.com","created_at":"2026-02-16T15:40:42.681311-08:00","created_by":"William Casarin","updated_at":"2026-02-16T15:40:42.681311-08:00","labels":["feature"],"dependencies":[{"issue_id":"notecrumbs-fn5","depends_on_id":"notecrumbs-3an","type":"blocks","created_at":"2026-02-16T15:41:17.457596-08:00","created_by":"William Casarin"}]} +{"id":"notecrumbs-j5d","title":"Render entire threads","description":"Display full thread context when viewing a note. Show parent notes and replies in a threaded view. GitHub issue #14.","status":"open","priority":2,"issue_type":"task","owner":"jb55@jb55.com","created_at":"2026-02-16T15:40:34.963806-08:00","created_by":"William Casarin","updated_at":"2026-02-16T15:40:34.963806-08:00","labels":["feature"],"dependencies":[{"issue_id":"notecrumbs-j5d","depends_on_id":"notecrumbs-ecd","type":"blocks","created_at":"2026-02-16T15:41:16.786704-08:00","created_by":"William Casarin"},{"issue_id":"notecrumbs-j5d","depends_on_id":"notecrumbs-4dm","type":"blocks","created_at":"2026-02-16T15:41:16.863739-08:00","created_by":"William Casarin"}]} +{"id":"notecrumbs-kmb","title":"NIP-05 notecrumbs explorer","description":"Browse notecrumbs via NIP-05 identifiers. GitHub issue #18.","status":"open","priority":2,"issue_type":"task","owner":"jb55@jb55.com","created_at":"2026-02-16T15:41:04.04048-08:00","created_by":"William Casarin","updated_at":"2026-02-16T15:41:04.04048-08:00","labels":["feature"]} +{"id":"notecrumbs-knv","title":"Longform: show article name by author name","description":"Change naddr bech32 strings to human-readable 'Article name by author name' format. GitHub issue #47.","status":"open","priority":2,"issue_type":"task","owner":"jb55@jb55.com","created_at":"2026-02-16T15:40:49.366671-08:00","created_by":"William Casarin","updated_at":"2026-02-16T15:40:49.366671-08:00","labels":["feature"]} +{"id":"notecrumbs-lfu","title":"Return 404 instead of 502 for missing notes","description":"When a note can't be found, return 404 Not Found instead of 502 Bad Gateway. GitHub issue #25.","status":"open","priority":2,"issue_type":"task","owner":"jb55@jb55.com","created_at":"2026-02-16T15:40:55.034627-08:00","created_by":"William Casarin","updated_at":"2026-02-16T15:40:55.034627-08:00","labels":["bug"]} +{"id":"notecrumbs-qox","title":"Fix nostrich on nostr preview","description":"Nostrich avatar/icon not displaying correctly in previews. GitHub issue #56.","status":"open","priority":2,"issue_type":"task","owner":"jb55@jb55.com","created_at":"2026-02-16T15:40:46.516665-08:00","created_by":"William Casarin","updated_at":"2026-02-16T15:40:46.516665-08:00","labels":["bug"]} +{"id":"notecrumbs-rcq","title":"URL pills","description":"Render URLs as styled pill/chip elements instead of raw links. GitHub issue #50.","status":"open","priority":2,"issue_type":"task","owner":"jb55@jb55.com","created_at":"2026-02-16T15:40:47.696654-08:00","created_by":"William Casarin","updated_at":"2026-02-16T15:40:47.696654-08:00","labels":["feature"]} +{"id":"notecrumbs-sty","title":"Persistent relay pool","description":"Keep relay connections alive across requests instead of reconnecting each time. GitHub issue #21.","status":"open","priority":2,"issue_type":"task","owner":"jb55@jb55.com","created_at":"2026-02-16T15:40:57.336156-08:00","created_by":"William Casarin","updated_at":"2026-02-16T15:40:57.336156-08:00","labels":["feature"]} +{"id":"notecrumbs-sz0","title":"Add name/nip-05 of poster","description":"Display poster's name and NIP-05 identifier on note previews. GitHub issue #5.","status":"open","priority":2,"issue_type":"task","owner":"jb55@jb55.com","created_at":"2026-02-16T15:41:03.08231-08:00","created_by":"William Casarin","updated_at":"2026-02-16T15:41:03.08231-08:00","labels":["feature"]} +{"id":"notecrumbs-v8w","title":"Render video on notecrumbs","description":"Display video content in note previews. GitHub issue #23.","status":"open","priority":2,"issue_type":"task","owner":"jb55@jb55.com","created_at":"2026-02-16T15:40:50.68231-08:00","created_by":"William Casarin","updated_at":"2026-02-16T15:40:50.68231-08:00","labels":["feature"]} 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()); + } } }