commit 804ccc2831d241f3f6b203718d04b67744147d56
parent c529dcc14052b1388398422ce2cccf20876879be
Author: William Casarin <jb55@jb55.com>
Date: Wed, 4 Feb 2026 22:02:07 -0800
feat: iOS-style article card for embedded longform quotes
Embedded article quotes now display as cards matching iOS Damus:
- Hero image (if available)
- Bold article title
- Summary text (if available)
- Word count
- DRAFT badge via CSS for kind 30024
Co-Authored-By: alltheseas <alltheseas@users.noreply.github.com>
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Diffstat:
| M | assets/damus.css | | | 48 | ++++++++++++++++++++++++++++++++++++++++++++++++ |
| M | src/html.rs | | | 123 | ++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------------------- |
2 files changed, 136 insertions(+), 35 deletions(-)
diff --git a/assets/damus.css b/assets/damus.css
@@ -590,6 +590,54 @@ a:hover {
font-style: italic;
}
+/* Article card styling for embedded quotes */
+.damus-embedded-quote-article {
+ display: flex;
+ flex-direction: column;
+}
+
+.damus-embedded-article-image {
+ width: 100%;
+ max-height: 180px;
+ object-fit: cover;
+ border-radius: 12px;
+ margin-bottom: 0.75rem;
+}
+
+.damus-embedded-article-title {
+ font-weight: 700;
+ font-size: 1rem;
+ color: #ffffff;
+ line-height: 1.3;
+ margin-bottom: 0.35rem;
+}
+
+.damus-embedded-article-title.damus-embedded-article-draft::after {
+ content: "DRAFT";
+ background: linear-gradient(135deg, #ff6b35, #f7931a);
+ color: #ffffff;
+ padding: 0.15em 0.4em;
+ border-radius: 4px;
+ font-size: 0.6em;
+ font-weight: 700;
+ letter-spacing: 0.05em;
+ margin-left: 0.5em;
+ vertical-align: middle;
+}
+
+.damus-embedded-article-summary {
+ font-size: 0.85rem;
+ color: var(--damus-muted);
+ line-height: 1.4;
+ margin-bottom: 0.5rem;
+}
+
+.damus-embedded-article-wordcount {
+ font-size: 0.8rem;
+ color: var(--damus-muted);
+ font-weight: 500;
+}
+
.damus-embedded-quote-showmore {
color: var(--damus-accent);
font-weight: 500;
diff --git a/src/html.rs b/src/html.rs
@@ -692,14 +692,14 @@ fn build_embedded_quotes_html(ndb: &Ndb, txn: &Transaction, quote_refs: &[QuoteR
let mut quotes_html = String::new();
for quote_ref in quote_refs {
- let (quoted_note, article_title) = match quote_ref {
+ let quoted_note = match quote_ref {
QuoteRef::Event { id, .. } => match ndb.get_note_by_id(txn, id) {
- Ok(note) => (note, None),
+ Ok(note) => note,
Err(_) => continue,
},
QuoteRef::Article { addr, .. } => match lookup_article_by_addr(ndb, txn, addr) {
- Some((note_key, title)) => match ndb.get_note_by_key(txn, note_key) {
- Ok(note) => (note, title),
+ Some((note_key, _title)) => match ndb.get_note_by_key(txn, note_key) {
+ Ok(note) => note,
Err(_) => continue,
},
None => continue,
@@ -768,48 +768,102 @@ fn build_embedded_quotes_html(ndb: &Ndb, txn: &Transaction, quote_refs: &[QuoteR
})
.unwrap_or_default();
- // Build content preview, type indicator, and content class based on note kind
- let (content_preview, is_truncated, type_indicator, content_class) = match quoted_note.kind()
- {
+ // For articles, we use a special card layout with image, title, summary, word count
+ let (content_preview, is_truncated, type_indicator, content_class, article_card) = match quoted_note.kind() {
+ // For articles, extract metadata and build card layout
30023 | 30024 => {
- let title = article_title.unwrap_or_else(|| "Untitled article".to_string());
- let indicator = if quoted_note.kind() == 30024 {
- r#"<span class="damus-embedded-quote-type damus-embedded-quote-type-draft">Draft</span>"#
- } else {
- r#"<span class="damus-embedded-quote-type">Article</span>"#
- };
- (title, false, indicator, "")
+ let mut title: Option<&str> = None;
+ let mut image: Option<&str> = None;
+ let mut summary: Option<&str> = None;
+
+ for tag in quoted_note.tags() {
+ let mut iter = tag.into_iter();
+ let Some(tag_name) = iter.next().and_then(|n| n.variant().str()) else {
+ continue;
+ };
+ let tag_value = iter.next().and_then(|n| n.variant().str());
+ match tag_name {
+ "title" => title = tag_value,
+ "image" => image = tag_value.filter(|s| !s.is_empty()),
+ "summary" => summary = tag_value.filter(|s| !s.is_empty()),
+ _ => {}
+ }
+ }
+
+ // Calculate word count
+ let word_count = quoted_note.content().split_whitespace().count();
+ let word_count_text = format!("{} Words", word_count);
+
+ // Build article card HTML
+ let title_text = title.unwrap_or("Untitled article");
+ let title_html = html_escape::encode_text(title_text);
+
+ let image_html = image
+ .map(|url| {
+ let url_attr = html_escape::encode_double_quoted_attribute(url);
+ format!(r#"<img src="{}" class="damus-embedded-article-image" alt="" />"#, url_attr)
+ })
+ .unwrap_or_default();
+
+ let summary_html = summary
+ .map(|s| {
+ let text = html_escape::encode_text(abbreviate(s, 150));
+ format!(r#"<div class="damus-embedded-article-summary">{}</div>"#, text)
+ })
+ .unwrap_or_default();
+
+ let draft_class = if quoted_note.kind() == 30024 { " damus-embedded-article-draft" } else { "" };
+
+ let card_html = format!(
+ r#"{image}<div class="damus-embedded-article-title{draft}">{title}</div>{summary}<div class="damus-embedded-article-wordcount">{words}</div>"#,
+ image = image_html,
+ draft = draft_class,
+ title = title_html,
+ summary = summary_html,
+ words = word_count_text
+ );
+
+ (String::new(), false, "", " damus-embedded-quote-article", Some(card_html))
}
// For highlights, use left border styling (no tag needed)
9802 => {
- let full = quoted_note.content();
- let abbr = abbreviate(full, 200);
- (
- abbr.to_string(),
- abbr.len() < full.len(),
- "",
- " damus-embedded-quote-highlight",
- )
+ let full_content = quoted_note.content();
+ let content = abbreviate(full_content, 200);
+ let truncated = content.len() < full_content.len();
+ (content.to_string(), truncated, "", " damus-embedded-quote-highlight", None)
}
_ => {
- let full = quoted_note.content();
- let abbr = abbreviate(full, 280);
- (abbr.to_string(), abbr.len() < full.len(), "", "")
+ let full_content = quoted_note.content();
+ let content = abbreviate(full_content, 280);
+ let truncated = content.len() < full_content.len();
+ (content.to_string(), truncated, "", "", None)
}
};
let content_html = html_escape::encode_text(&content_preview).replace("\n", " ");
- let show_more_html = if is_truncated {
- r#"<span class="damus-embedded-quote-showmore">Show more</span>"#
+ // Build link to quoted note
+ let link = build_quote_link(quote_ref);
+
+ // For articles, use card layout; for other types, use regular content layout
+ let body_html = if let Some(card) = article_card {
+ card
} else {
- ""
+ let show_more = if is_truncated {
+ r#" <span class="damus-embedded-quote-showmore">Show more</span>"#
+ } else {
+ ""
+ };
+ format!(
+ r#"<div class="damus-embedded-quote-content{class}">{content}{showmore}</div>"#,
+ class = content_class,
+ content = content_html,
+ showmore = show_more
+ )
};
- let link = build_quote_link(quote_ref);
-
let _ = write!(
quotes_html,
- r#"<a href="{link}" class="damus-embedded-quote">
+ r#"<a href="{link}" class="damus-embedded-quote{content_class}">
<div class="damus-embedded-quote-header">
{pfp}
<span class="damus-embedded-quote-author">{name}</span>{username}
@@ -817,18 +871,17 @@ fn build_embedded_quotes_html(ndb: &Ndb, txn: &Transaction, quote_refs: &[QuoteR
{type_indicator}
</div>
{reply}
- <div class="damus-embedded-quote-content{content_class}">{content} {showmore}</div>
+ {body}
</a>"#,
link = link,
+ content_class = content_class,
pfp = pfp_html,
name = display_name_html,
username = username_html,
time = time_html,
type_indicator = type_indicator,
reply = reply_html,
- content_class = content_class,
- content = content_html,
- showmore = show_more_html
+ body = body_html
);
}