commit 092bf2aff0b268c8504a387570283dfca750419a
parent ba27cac3e28214b64ad355dd2771cc0531e0d925
Author: William Casarin <jb55@jb55.com>
Date: Mon, 16 Feb 2026 17:47:13 -0800
feat: thread layout with vertical line connecting parent and reply avatars
Replace the reply-context card with a Twitter/X-style thread layout using
CSS grid. Parent note and reply are displayed in two rows sharing an avatar
column, with a vertical line connecting the bottom of the parent avatar to
the top of the reply avatar.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Diffstat:
| M | assets/damus.css | | | 102 | +++++++++++++++++++++++++++++++++++++++++++++++++++---------------------------- |
| M | src/html.rs | | | 226 | ++++++++++++++++++++++++++++++++++++++++++++++++++----------------------------- |
2 files changed, 210 insertions(+), 118 deletions(-)
diff --git a/assets/damus.css b/assets/damus.css
@@ -578,75 +578,105 @@ a:hover {
margin-bottom: 0.5rem;
}
-/* Reply context for main note view */
-.damus-reply-context {
+/* Thread layout: CSS grid with avatar + content columns */
+.damus-thread-grid {
+ display: grid;
+ grid-template-columns: 42px 1fr;
+ grid-template-rows: auto auto;
+ column-gap: 0.75rem;
+ row-gap: 0.75rem;
+}
+
+/* Line sits in row 1 of the avatar column, stretches full row height
+ plus the row-gap. margin-top: 42px starts it below the parent avatar.
+ margin-bottom: -0.5rem extends it through the row-gap so it reaches
+ the reply avatar. */
+.damus-thread-line {
+ grid-column: 1;
+ grid-row: 1;
+ width: 2px;
+ background: rgba(255, 255, 255, 0.25);
+ margin: 42px auto -0.75rem;
+ align-self: stretch;
+}
+
+.damus-thread-pfp {
display: flex;
- flex-direction: column;
- gap: 0.5rem;
+ align-items: start;
+ justify-content: center;
}
-.damus-reply-indicator {
- color: var(--damus-muted);
- font-size: 0.9rem;
+.damus-thread-pfp-parent {
+ grid-column: 1;
+ grid-row: 1;
}
-.damus-reply-indicator a {
- color: var(--damus-accent);
- font-weight: 600;
- text-decoration: none;
+.damus-thread-pfp-reply {
+ grid-column: 1;
+ grid-row: 2;
}
-.damus-reply-indicator a:hover {
- text-decoration: underline;
+.damus-thread-avatar {
+ width: 42px;
+ height: 42px;
+ border-radius: 50%;
+ object-fit: cover;
+ display: block;
}
-.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;
+.damus-thread-parent-content {
+ grid-column: 2;
+ grid-row: 1;
text-decoration: none;
- display: block;
+ padding-bottom: 0.75rem;
+ min-width: 0;
}
-.damus-reply-parent:hover {
- border-color: rgba(189, 102, 255, 0.3);
- text-decoration: none;
+.damus-thread-parent-content:hover .damus-thread-parent-text {
+ color: var(--damus-text);
}
-.damus-reply-parent-header {
+.damus-thread-parent-meta {
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 {
+.damus-thread-parent-author {
font-weight: 600;
color: #ffffff;
- font-size: 0.9rem;
+ font-size: 0.95rem;
}
-.damus-reply-parent-time {
+.damus-thread-parent-time {
color: var(--damus-muted);
font-size: 0.85rem;
}
-.damus-reply-parent-content {
+.damus-thread-parent-text {
color: var(--damus-muted);
font-size: 0.9rem;
line-height: 1.5;
}
+.damus-thread-reply-content {
+ grid-column: 2;
+ grid-row: 2;
+ min-width: 0;
+}
+
+.damus-thread-reply-meta {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ margin-bottom: 0.25rem;
+}
+
+.damus-thread-reply-meta a {
+ text-decoration: none;
+}
+
.damus-embedded-quote-content {
color: var(--damus-text);
font-size: 0.9rem;
diff --git a/src/html.rs b/src/html.rs
@@ -1057,81 +1057,73 @@ 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 {
+/// Extracts parent note info for thread layout.
+/// Returns None if the note is not a reply.
+struct ParentNoteInfo {
+ link: String,
+ pfp: String,
+ name_html: String,
+ time_html: String,
+ content_html: String,
+}
+
+fn get_parent_note_info(
+ ndb: &Ndb,
+ txn: &Transaction,
+ note: &Note,
+ base_url: &str,
+) -> Option<ParentNoteInfo> {
use nostrdb::NoteReply;
let reply_info = NoteReply::new(note.tags());
+ let parent_ref = reply_info.reply().or_else(|| reply_info.root())?;
- // 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();
- };
+ let link = EventId::from_byte_array(*parent_ref.id)
+ .to_bech32()
+ .map(|b| format!("{}/{}", base_url, b))
+ .unwrap_or_else(|_| "#".to_string());
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 name = get_profile_display_name(parent_profile.as_ref()).unwrap_or("nostrich");
- 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() {
+ let content = abbreviate(parent_note.content(), 200);
+ let ellipsis = if content.len() < parent_note.content().len() {
"..."
} else {
""
};
- let parent_pfp = pfp_url_attr(
- parent_profile
- .as_ref()
- .and_then(|r| r.record().profile()),
+ let 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,
- )
+ Some(ParentNoteInfo {
+ link,
+ pfp,
+ name_html: html_escape::encode_text(name).into_owned(),
+ time_html: html_escape::encode_text(&format_relative_time(
+ parent_note.created_at(),
+ ))
+ .into_owned(),
+ content_html: format!("{}{}", html_escape::encode_text(content), ellipsis),
+ })
}
Err(_) => {
- // Parent note not found - show minimal indicator
- let parent_id_display = EventId::from_byte_array(*parent_ref.id)
+ let 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),
- )
+ Some(ParentNoteInfo {
+ link,
+ pfp: format!("{}/img/no-profile.svg", base_url),
+ name_html: html_escape::encode_text(&id_display).into_owned(),
+ time_html: String::new(),
+ content_html: String::new(),
+ })
}
}
}
@@ -1188,37 +1180,107 @@ 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);
+ let parent_info = get_parent_note_info(&app.ndb, txn, note, base_url);
- format!(
- r#"<article class="damus-card damus-note">
- <header class="damus-note-header">
- <a href="{base}/{npub}">
- <img src="{pfp}" class="damus-note-avatar" alt="{author} profile picture" />
- </a>
- <div>
- <a href="{base}/{npub}">
- <div class="damus-note-author">{author}</div>
- {handle}
- </a>
- <a href="{base}/{note_id}">
- <time class="damus-note-time" data-timestamp="{ts}" datetime="{ts}" title="{ts}">{ts}</time>
- </a>
- </div>
- </header>
- {reply_context}
- <div class="damus-note-body">{body}</div>
- {quotes}
- </article>"#,
- base = base_url,
- pfp = pfp_attr,
- author = author_display,
- handle = author_handle,
- ts = timestamp_attr,
- reply_context = reply_context,
- body = note_body,
- quotes = quotes_html
- )
+ match parent_info {
+ Some(parent) => {
+ // Thread layout: one avatar column spanning both notes with a line between
+ let time_html = if parent.time_html.is_empty() {
+ String::new()
+ } else {
+ format!(
+ r#"<span class="damus-thread-parent-time">· {}</span>"#,
+ parent.time_html
+ )
+ };
+ let content_html = if parent.content_html.is_empty() {
+ String::new()
+ } else {
+ format!(
+ r#"<div class="damus-thread-parent-text">{}</div>"#,
+ parent.content_html
+ )
+ };
+
+ format!(
+ r#"<article class="damus-card damus-note damus-thread">
+ <div class="damus-thread-grid">
+ <div class="damus-thread-line"></div>
+ <a href="{parent_link}" class="damus-thread-pfp damus-thread-pfp-parent">
+ <img src="{parent_pfp}" class="damus-thread-avatar" alt="" />
+ </a>
+ <a href="{parent_link}" class="damus-thread-parent-content">
+ <div class="damus-thread-parent-meta">
+ <span class="damus-thread-parent-author">{parent_name}</span>
+ {time}
+ </div>
+ {content}
+ </a>
+ <a href="{base}/{npub}" class="damus-thread-pfp damus-thread-pfp-reply">
+ <img src="{pfp}" class="damus-thread-avatar" alt="{author} profile picture" />
+ </a>
+ <div class="damus-thread-reply-content">
+ <div class="damus-thread-reply-meta">
+ <a href="{base}/{npub}">
+ <span class="damus-note-author">{author}</span>
+ {handle}
+ </a>
+ <a href="{base}/{note_id}">
+ <time class="damus-note-time" data-timestamp="{ts}" datetime="{ts}" title="{ts}">{ts}</time>
+ </a>
+ </div>
+ <div class="damus-note-body">{body}</div>
+ {quotes}
+ </div>
+ </div>
+ </article>"#,
+ parent_link = parent.link,
+ parent_pfp = parent.pfp,
+ parent_name = parent.name_html,
+ time = time_html,
+ content = content_html,
+ base = base_url,
+ npub = npub,
+ pfp = pfp_attr,
+ author = author_display,
+ handle = author_handle,
+ note_id = note_id,
+ ts = timestamp_attr,
+ body = note_body,
+ quotes = quotes_html,
+ )
+ }
+ None => {
+ // Standard layout: no thread context
+ format!(
+ r#"<article class="damus-card damus-note">
+ <header class="damus-note-header">
+ <a href="{base}/{npub}">
+ <img src="{pfp}" class="damus-note-avatar" alt="{author} profile picture" />
+ </a>
+ <div>
+ <a href="{base}/{npub}">
+ <div class="damus-note-author">{author}</div>
+ {handle}
+ </a>
+ <a href="{base}/{note_id}">
+ <time class="damus-note-time" data-timestamp="{ts}" datetime="{ts}" title="{ts}">{ts}</time>
+ </a>
+ </div>
+ </header>
+ <div class="damus-note-body">{body}</div>
+ {quotes}
+ </article>"#,
+ base = base_url,
+ pfp = pfp_attr,
+ author = author_display,
+ handle = author_handle,
+ ts = timestamp_attr,
+ body = note_body,
+ quotes = quotes_html
+ )
+ }
+ }
}
#[allow(clippy::too_many_arguments)]