notecrumbs

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

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:
Massets/damus.css | 48++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/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 ); }