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