notecrumbs

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

commit c529dcc14052b1388398422ce2cccf20876879be
parent 0ebffb1de9e613b9b6b3e0d12f3ae4aff4689bf9
Author: William Casarin <jb55@jb55.com>
Date:   Wed,  4 Feb 2026 12:51:55 -0800

feat: add NIP-84 highlights and NIP-18 embedded quotes

NIP-84 Highlights (kind:9802):
- Extract and render highlight metadata (context, comment)
- Source attribution for web URLs, notes, and articles
- Blockquote styling with left border accent

NIP-18 Embedded Quotes:
- Parse q tags and inline nevent/note/naddr mentions
- Rich quote cards with avatar, name, @handle, relative time
- Reply detection using nostrdb's NoteReply
- Type indicators for articles/highlights/drafts

Other improvements:
- Draft badge for unpublished articles (kind:30024)
- @username handles displayed under profile names
- Human-readable @mentions (resolve npub to display names)

Closes: #51
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Co-Authored-By: alltheseas <alltheseas@users.noreply.github.com>

Diffstat:
Massets/damus.css | 169+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/html.rs | 830++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
2 files changed, 969 insertions(+), 30 deletions(-)

diff --git a/assets/damus.css b/assets/damus.css @@ -258,6 +258,13 @@ a:hover { color: #ffffff; } +.damus-note-handle { + display: block; + font-size: 0.95rem; + font-weight: 400; + color: var(--damus-muted); +} + .damus-note-time { font-size: 0.9rem; color: var(--damus-muted); @@ -348,6 +355,58 @@ a:hover { letter-spacing: 0.02em; } +/* Draft badge for unpublished articles (kind:30024) */ +.damus-article-draft { + background: linear-gradient(135deg, #ff6b35, #f7931a); + color: #ffffff; + padding: 0.25em 0.65em; + border-radius: 6px; + font-family: var(--damus-font); + font-size: 0.4em; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + margin-left: 0.75em; + vertical-align: middle; + box-shadow: 0 2px 8px rgba(255, 107, 53, 0.3); +} + +/* NIP-84 Highlight styles (kind:9802) */ +.damus-highlight-text { + border-left: 3px solid var(--damus-accent); + padding-left: 1.25rem; + margin: 0; + font-style: italic; + font-size: 1.15rem; + line-height: 1.7; + color: var(--damus-text); +} + +.damus-highlight-context { + color: var(--damus-muted); + font-size: 0.9rem; + line-height: 1.6; + margin-top: 1rem; +} + +.damus-highlight-source { + font-size: 0.9rem; + margin-top: 1.25rem; + padding-top: 1rem; + border-top: 1px solid var(--damus-card-border); +} + +.damus-highlight-source-label { + color: var(--damus-muted); + margin-right: 0.35em; +} + +.damus-highlight-comment { + font-size: 1.1rem; + line-height: 1.6; + margin-bottom: 1rem; +} + .damus-profile-card { gap: 1.5rem; } @@ -441,6 +500,116 @@ a:hover { color: var(--damus-muted); } +/* NIP-18 Embedded Quotes (q tags) */ +.damus-embedded-quotes { + display: flex; + flex-direction: column; + gap: 0.5rem; + margin-top: 0.35rem; +} + +.damus-embedded-quote { + background: rgba(0, 0, 0, 0.25); + border: 1px solid var(--damus-card-border); + border-radius: 16px; + padding: 0.875rem 1rem; + transition: border-color 160ms ease; + text-decoration: none; + display: block; +} + +.damus-embedded-quote:hover { + border-color: rgba(189, 102, 255, 0.3); + text-decoration: none; +} + +.damus-embedded-quote-header { + display: flex; + align-items: center; + gap: 0.35rem; + margin-bottom: 0.25rem; + flex-wrap: wrap; +} + +.damus-embedded-quote-avatar { + width: 20px; + height: 20px; + border-radius: 50%; + object-fit: cover; + flex-shrink: 0; +} + +.damus-embedded-quote-author { + font-weight: 600; + color: #ffffff; + font-size: 0.9rem; +} + +.damus-embedded-quote-username { + color: var(--damus-muted); + font-size: 0.85rem; +} + +.damus-embedded-quote-time { + color: var(--damus-muted); + font-size: 0.85rem; +} + +.damus-embedded-quote-type { + background: rgba(189, 102, 255, 0.2); + color: var(--damus-accent); + padding: 0.15em 0.5em; + border-radius: 4px; + font-size: 0.75rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.03em; + margin-left: 0.5rem; +} + +.damus-embedded-quote-type-draft { + background: rgba(255, 107, 53, 0.2); + color: #ff6b35; +} + +.damus-embedded-quote-reply { + color: var(--damus-muted); + font-size: 0.8rem; + margin-bottom: 0.5rem; +} + +.damus-embedded-quote-content { + color: var(--damus-text); + font-size: 0.9rem; + line-height: 1.5; +} + +.damus-embedded-quote-highlight { + border-left: 3px solid var(--damus-accent); + padding-left: 0.75rem; + font-style: italic; +} + +.damus-embedded-quote-showmore { + color: var(--damus-accent); + font-weight: 500; +} + +.damus-embedded-quote-urls { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + margin-top: 0.75rem; +} + +.damus-embedded-quote-url { + background: rgba(255, 255, 255, 0.08); + color: var(--damus-muted); + padding: 0.4rem 0.75rem; + border-radius: 8px; + font-size: 0.8rem; +} + @media (max-width: 640px) { .damus-header { flex-direction: column; diff --git a/src/html.rs b/src/html.rs @@ -8,7 +8,7 @@ use ammonia::Builder as HtmlSanitizer; use http_body_util::Full; use hyper::{body::Bytes, header, Request, Response, StatusCode}; use nostr::nips::nip19::Nip19Event; -use nostr_sdk::prelude::{EventId, Nip19, PublicKey, RelayUrl, ToBech32}; +use nostr_sdk::prelude::{EventId, FromBech32, Nip19, PublicKey, RelayUrl, ToBech32}; use nostrdb::{ BlockType, Blocks, Filter, Mention, Ndb, NdbProfile, Note, NoteKey, ProfileRecord, Transaction, }; @@ -77,6 +77,31 @@ struct ArticleMetadata { topics: Vec<String>, } +/// Metadata extracted from NIP-84 highlight events (kind:9802). +/// +/// Highlights capture a passage from source content with optional context. +/// Sources can be: web URLs (r tag), nostr notes (e tag), or articles (a tag). +#[derive(Default)] +struct HighlightMetadata { + /// Surrounding text providing context for the highlight (from "context" tag) + context: Option<String>, + /// User's comment/annotation on the highlight (from "comment" tag) + comment: Option<String>, + /// Web URL source - external article or page (from "r" tag) + source_url: Option<String>, + /// Nostr note ID - reference to a kind:1 shortform note (from "e" tag) + source_event_id: Option<[u8; 32]>, + /// Nostr article address - reference to kind:30023/30024 (from "a" tag) + /// Format: "30023:{pubkey_hex}:{d-identifier}" + source_article_addr: Option<String>, +} + +/// Normalizes text for comparison by trimming whitespace and trailing punctuation. +/// Used to detect when context and content are essentially the same text. +fn normalize_for_comparison(s: &str) -> String { + s.trim().trim_end_matches(|c: char| c.is_ascii_punctuation()).to_lowercase() +} + fn collapse_whitespace<S: AsRef<str>>(input: S) -> String { let mut result = String::with_capacity(input.as_ref().len()); let mut last_space = false; @@ -151,6 +176,101 @@ fn extract_article_metadata(note: &Note) -> ArticleMetadata { meta } +/// Extracts NIP-84 highlight metadata from a kind:9802 note. +/// +/// Parses tags to identify the highlight source: +/// - "context" tag: surrounding text for context +/// - "comment" tag: user's annotation +/// - "r" tag: web URL source (external article/page) +/// - "e" tag: nostr note ID (kind:1 shortform note) +/// - "a" tag: nostr article address (kind:30023/30024 longform) +fn extract_highlight_metadata(note: &Note) -> HighlightMetadata { + let mut meta = HighlightMetadata::default(); + + for tag in note.tags() { + let Some(tag_name) = tag.get_str(0) else { + continue; + }; + + match tag_name { + "context" => { + if let Some(value) = tag.get_str(1) { + if !value.trim().is_empty() { + meta.context = Some(value.to_owned()); + } + } + } + + "comment" => { + if let Some(value) = tag.get_str(1) { + if !value.trim().is_empty() { + meta.comment = Some(value.to_owned()); + } + } + } + + "r" => { + if let Some(value) = tag.get_str(1) { + let trimmed = value.trim(); + if trimmed.starts_with("http://") || trimmed.starts_with("https://") { + meta.source_url = Some(trimmed.to_owned()); + } + } + } + + "e" => { + // The e tag value is guaranteed to be an ID + if let Some(event_id) = tag.get_id(1) { + meta.source_event_id = Some(*event_id); + } + } + + "a" => { + if let Some(value) = tag.get_str(1) { + let trimmed = value.trim(); + if trimmed.starts_with("30023:") || trimmed.starts_with("30024:") { + meta.source_article_addr = Some(trimmed.to_owned()); + } + } + } + + _ => {} + } + } + + meta +} + +/// Formats a unix timestamp as a relative time string (e.g., "6h", "2d", "3w"). +fn format_relative_time(timestamp: u64) -> String { + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + + if timestamp > now { + return "now".to_string(); + } + + let diff = now - timestamp; + let minutes = diff / 60; + let hours = diff / 3600; + let days = diff / 86400; + let weeks = diff / 604800; + + if minutes < 1 { + "now".to_string() + } else if minutes < 60 { + format!("{}m", minutes) + } else if hours < 24 { + format!("{}h", hours) + } else if days < 7 { + format!("{}d", days) + } else { + format!("{}w", weeks) + } +} + fn render_markdown(markdown: &str) -> String { let mut options = Options::empty(); options.insert(Options::ENABLE_TABLES); @@ -256,7 +376,21 @@ fn is_image(url: &str) -> bool { .any(|ext| ends_with(strip_querystring(url), ext)) } -pub fn render_note_content(body: &mut Vec<u8>, note: &Note, blocks: &Blocks) { +/// Gets the display name for a profile, preferring display_name, falling back to name. +fn get_profile_display_name<'a>(record: Option<&ProfileRecord<'a>>) -> Option<&'a str> { + let profile = record?.record().profile()?; + let display_name = profile.display_name().filter(|n| !n.trim().is_empty()); + let username = profile.name().filter(|n| !n.trim().is_empty()); + display_name.or(username) +} + +pub fn render_note_content( + body: &mut Vec<u8>, + note: &Note, + blocks: &Blocks, + ndb: &Ndb, + txn: &Transaction, +) { for block in blocks.iter(note) { match block.blocktype() { BlockType::Url => { @@ -293,33 +427,419 @@ pub fn render_note_content(body: &mut Vec<u8>, note: &Note, blocks: &Blocks) { } BlockType::MentionBech32 => { - match block.as_mention().unwrap() { - Mention::Event(_) - | Mention::Note(_) - | Mention::Profile(_) - | Mention::Pubkey(_) - | Mention::Secret(_) - | Mention::Addr(_) => { - let _ = write!( - body, - r#"<a href="/{}">@{}</a>"#, - block.as_str(), - &abbrev_str(block.as_str()) - ); + let mention = block.as_mention().unwrap(); + let pubkey = match mention { + Mention::Profile(p) => Some(p.pubkey()), + Mention::Pubkey(p) => Some(p.pubkey()), + _ => None, + }; + + if let Some(pk) = pubkey { + // Profile/pubkey mentions: show the human-readable name + let record = ndb.get_profile_by_pubkey(txn, pk).ok(); + let display = get_profile_display_name(record.as_ref()) + .map(|s| s.to_string()) + .unwrap_or_else(|| abbrev_str(block.as_str())); + let display_html = html_escape::encode_text(&display); + let _ = write!( + body, + r#"<a href="/{bech32}">@{display}</a>"#, + bech32 = block.as_str(), + display = display_html + ); + } else { + match mention { + // Event/note mentions: skip inline rendering (shown as embedded quotes) + Mention::Event(_) | Mention::Note(_) => {} + + // Other mentions: link with abbreviated bech32 + _ => { + let _ = write!( + body, + r#"<a href="/{bech32}">{abbrev}</a>"#, + bech32 = block.as_str(), + abbrev = abbrev_str(block.as_str()) + ); + } } + } + } + }; + } +} + +/// Represents a quoted event reference from a q tag (NIP-18) or inline mention. +enum QuoteRef { + Event { id: [u8; 32], bech32: Option<String> }, + Article { addr: String, bech32: Option<String> }, +} + +/// Extracts quote references from inline nevent/note mentions in content. +fn extract_quote_refs_from_content(note: &Note, blocks: &Blocks) -> Vec<QuoteRef> { + use nostr_sdk::prelude::Nip19; + + let mut quotes = Vec::new(); + + for block in blocks.iter(note) { + if block.blocktype() != BlockType::MentionBech32 { + continue; + } - Mention::Relay(relay) => { - let _ = write!( - body, - r#"<a href="/{}">{}</a>"#, - block.as_str(), - &abbrev_str(relay.as_str()) + let Some(mention) = block.as_mention() else { + continue; + }; + + match mention { + Mention::Event(ev) => { + quotes.push(QuoteRef::Event { + id: *ev.id(), + bech32: Some(block.as_str().to_string()), + }); + } + Mention::Note(note_ref) => { + quotes.push(QuoteRef::Event { + id: *note_ref.id(), + bech32: Some(block.as_str().to_string()), + }); + } + // naddr mentions - articles (30023/30024) and highlights (9802) + Mention::Addr(_) => { + let bech32_str = block.as_str(); + if let Ok(Nip19::Coordinate(coord)) = Nip19::from_bech32(bech32_str) { + let kind = coord.kind.as_u16(); + if kind == 30023 || kind == 30024 || kind == 9802 { + let addr = format!( + "{}:{}:{}", + kind, + coord.public_key.to_hex(), + coord.identifier ); + quotes.push(QuoteRef::Article { + addr, + bech32: Some(bech32_str.to_string()), + }); } + } + } + _ => {} + } + } + + quotes +} + +/// Extracts quote references from q tags (NIP-18 quote reposts). +fn extract_quote_refs_from_tags(note: &Note) -> Vec<QuoteRef> { + use nostr_sdk::prelude::Nip19; + + let mut quotes = Vec::new(); + + for tag in note.tags() { + if tag.get_str(0) != Some("q") { + continue; + } + + let Some(value) = tag.get_str(1) else { + continue; + }; + let trimmed = value.trim(); + + // Try nevent/note bech32 + if trimmed.starts_with("nevent1") || trimmed.starts_with("note1") { + if let Ok(nip19) = Nip19::from_bech32(trimmed) { + let id = match &nip19 { + Nip19::Event(ev) => Some(*ev.event_id.as_bytes()), + Nip19::EventId(id) => Some(*id.as_bytes()), + _ => None, }; + if let Some(id) = id { + quotes.push(QuoteRef::Event { + id, + bech32: Some(trimmed.to_owned()), + }); + continue; + } + } + } + + // Try naddr bech32 + if trimmed.starts_with("naddr1") { + if let Ok(Nip19::Coordinate(coord)) = Nip19::from_bech32(trimmed) { + let addr = format!( + "{}:{}:{}", + coord.kind.as_u16(), + coord.public_key.to_hex(), + coord.identifier + ); + quotes.push(QuoteRef::Article { + addr, + bech32: Some(trimmed.to_owned()), + }); + continue; + } + } + + // Try article address format + if trimmed.starts_with("30023:") || trimmed.starts_with("30024:") { + quotes.push(QuoteRef::Article { + addr: trimmed.to_owned(), + bech32: None, + }); + continue; + } + + // Try hex event ID + if let Ok(bytes) = hex::decode(trimmed) { + if let Ok(id) = bytes.try_into() { + quotes.push(QuoteRef::Event { id, bech32: None }); } + } + } + + quotes +} + +/// Looks up an article by address (kind:pubkey:d-tag) and returns the note key + optional title. +fn lookup_article_by_addr(ndb: &Ndb, txn: &Transaction, addr: &str) -> Option<(NoteKey, Option<String>)> { + let parts: Vec<&str> = addr.splitn(3, ':').collect(); + if parts.len() < 3 { + return None; + } + + let kind: u64 = parts[0].parse().ok()?; + let pubkey_bytes = hex::decode(parts[1]).ok()?; + let pubkey: [u8; 32] = pubkey_bytes.try_into().ok()?; + let d_identifier = parts[2]; + + let filter = Filter::new().authors([&pubkey]).kinds([kind]).build(); + let results = ndb.query(txn, &[filter], 10).ok()?; + + for result in results { + let mut found_d_match = false; + let mut title = None; + + for tag in result.note.tags() { + let tag_name = tag.get_str(0)?; + match tag_name { + "d" => { + if tag.get_str(1) == Some(d_identifier) { + found_d_match = true; + } + } + "title" => { + if let Some(t) = tag.get_str(1) { + if !t.trim().is_empty() { + title = Some(t.to_owned()); + } + } + } + _ => {} + } + } + + if found_d_match { + return Some((result.note_key, title)); + } + } + + None +} + +/// Builds a link URL for a quote reference. +fn build_quote_link(quote_ref: &QuoteRef) -> String { + use nostr_sdk::prelude::{Coordinate, EventId, Kind}; + + match quote_ref { + QuoteRef::Event { id, bech32 } => { + if let Some(b) = bech32 { + return format!("/{}", b); + } + if let Ok(eid) = EventId::from_slice(id) { + if let Ok(b) = eid.to_bech32() { + return format!("/{}", b); + } + } + } + QuoteRef::Article { addr, bech32 } => { + if let Some(b) = bech32 { + return format!("/{}", b); + } + let parts: Vec<&str> = addr.splitn(3, ':').collect(); + if parts.len() >= 3 { + if let Ok(kind) = parts[0].parse::<u16>() { + if let Ok(pubkey) = PublicKey::from_hex(parts[1]) { + let coordinate = + Coordinate::new(Kind::from(kind), pubkey).identifier(parts[2]); + if let Ok(naddr) = coordinate.to_bech32() { + return format!("/{}", naddr); + } + } + } + } + } + } + "#".to_string() +} + +/// Builds embedded quote HTML for referenced events. +fn build_embedded_quotes_html(ndb: &Ndb, txn: &Transaction, quote_refs: &[QuoteRef]) -> String { + use nostrdb::NoteReply; + + if quote_refs.is_empty() { + return String::new(); + } + + let mut quotes_html = String::new(); + + for quote_ref in quote_refs { + let (quoted_note, article_title) = match quote_ref { + QuoteRef::Event { id, .. } => match ndb.get_note_by_id(txn, id) { + Ok(note) => (note, None), + 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), + Err(_) => continue, + }, + None => continue, + }, }; + + // Get author profile (filter empty strings for proper fallback) + let (display_name, username, pfp_url) = ndb + .get_profile_by_pubkey(txn, quoted_note.pubkey()) + .ok() + .and_then(|rec| { + rec.record().profile().map(|p| { + let name = p + .display_name() + .filter(|s| !s.is_empty()) + .or_else(|| p.name().filter(|s| !s.is_empty())) + .map(|n| n.to_owned()); + let handle = p.name().filter(|s| !s.is_empty()).map(|n| format!("@{}", n)); + let picture = p.picture().filter(|s| !s.is_empty()).map(|s| s.to_owned()); + (name, handle, picture) + }) + }) + .unwrap_or((None, None, None)); + + let display_name = display_name.unwrap_or_else(|| "nostrich".to_string()); + let display_name_html = html_escape::encode_text(&display_name); + let username_html = username + .map(|u| { + format!( + r#" <span class="damus-embedded-quote-username">{}</span>"#, + html_escape::encode_text(&u) + ) + }) + .unwrap_or_default(); + + let pfp_html = pfp_url + .filter(|url| !url.trim().is_empty()) + .map(|url| { + let pfp_attr = html_escape::encode_double_quoted_attribute(&url); + format!( + r#"<img src="{}" class="damus-embedded-quote-avatar" alt="" />"#, + pfp_attr + ) + }) + .unwrap_or_else(|| { + r#"<img src="/img/no-profile.svg" class="damus-embedded-quote-avatar" alt="" />"# + .to_string() + }); + + let relative_time = format_relative_time(quoted_note.created_at()); + let time_html = html_escape::encode_text(&relative_time); + + // Detect reply using nostrdb's NoteReply + let reply_html = NoteReply::new(quoted_note.tags()) + .reply() + .and_then(|reply_ref| ndb.get_note_by_id(txn, reply_ref.id).ok()) + .and_then(|parent| { + get_profile_display_name(ndb.get_profile_by_pubkey(txn, parent.pubkey()).ok().as_ref()) + .map(|name| format!("@{}", name)) + }) + .map(|name| { + format!( + r#"<div class="damus-embedded-quote-reply">Replying to {}</div>"#, + html_escape::encode_text(&name) + ) + }) + .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() + { + 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, "") + } + // 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 = quoted_note.content(); + let abbr = abbreviate(full, 280); + (abbr.to_string(), abbr.len() < full.len(), "", "") + } + }; + 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>"# + } else { + "" + }; + + let link = build_quote_link(quote_ref); + + let _ = write!( + quotes_html, + r#"<a href="{link}" class="damus-embedded-quote"> + <div class="damus-embedded-quote-header"> + {pfp} + <span class="damus-embedded-quote-author">{name}</span>{username} + <span class="damus-embedded-quote-time">· {time}</span> + {type_indicator} + </div> + {reply} + <div class="damus-embedded-quote-content{content_class}">{content} {showmore}</div> + </a>"#, + link = link, + 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 + ); + } + + if quotes_html.is_empty() { + return String::new(); } + + format!( + r#"<div class="damus-embedded-quotes">{}</div>"#, + quotes_html + ) } struct Profile<'a> { @@ -334,11 +854,22 @@ impl<'a> Profile<'a> { } fn author_display_html(profile: Option<&ProfileRecord<'_>>) -> String { - let profile_name_raw = profile + let profile_name_raw = get_profile_display_name(profile).unwrap_or("nostrich"); + html_escape::encode_text(profile_name_raw).into_owned() +} + +/// Returns the @username handle markup if available, empty string otherwise. +/// Uses profile.name() (the NIP-01 "name" field) as the handle. +fn author_handle_html(profile: Option<&ProfileRecord<'_>>) -> String { + profile .and_then(|p| p.record().profile()) .and_then(|p| p.name()) - .unwrap_or("nostrich"); - html_escape::encode_text(profile_name_raw).into_owned() + .filter(|name| !name.is_empty()) + .map(|name| { + let escaped = html_escape::encode_text(name); + format!(r#"<span class="damus-note-handle">@{}</span>"#, escaped) + }) + .unwrap_or_default() } fn build_note_content_html( @@ -350,16 +881,18 @@ fn build_note_content_html( relays: &[RelayUrl], ) -> String { let mut body_buf = Vec::new(); - if let Some(blocks) = note + let blocks = note .key() - .and_then(|nk| app.ndb.get_blocks_by_key(txn, nk).ok()) - { - render_note_content(&mut body_buf, note, &blocks); + .and_then(|nk| app.ndb.get_blocks_by_key(txn, nk).ok()); + + if let Some(ref blocks) = blocks { + render_note_content(&mut body_buf, note, blocks, &app.ndb, txn); } else { let _ = write!(body_buf, "{}", html_escape::encode_text(note.content())); } let author_display = author_display_html(profile.record.as_ref()); + let author_handle = author_handle_html(profile.record.as_ref()); let npub = profile.key.to_bech32().unwrap(); let note_body = String::from_utf8(body_buf).unwrap_or_default(); let pfp_attr = pfp_url_attr( @@ -373,6 +906,23 @@ fn build_note_content_html( ); let note_id = nevent.to_bech32().unwrap(); + // Extract quote refs from q tags and inline mentions + let mut quote_refs = extract_quote_refs_from_tags(note); + if let Some(ref blocks) = blocks { + for content_ref in extract_quote_refs_from_content(note, blocks) { + // Deduplicate by event_id or article_addr + let is_dup = quote_refs.iter().any(|existing| match (existing, &content_ref) { + (QuoteRef::Event { id: a, .. }, QuoteRef::Event { id: b, .. }) => a == b, + (QuoteRef::Article { addr: a, .. }, QuoteRef::Article { addr: b, .. }) => a == b, + _ => false, + }); + if !is_dup { + quote_refs.push(content_ref); + } + } + } + let quotes_html = build_embedded_quotes_html(&app.ndb, txn, &quote_refs); + format!( r#"<article class="damus-card damus-note"> <header class="damus-note-header"> @@ -382,6 +932,7 @@ fn build_note_content_html( <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> @@ -389,12 +940,15 @@ fn build_note_content_html( </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 + body = note_body, + quotes = quotes_html ) } @@ -407,6 +961,7 @@ fn build_article_content_html( summary_html: Option<&str>, article_body_html: &str, topics: &[String], + is_draft: bool, base_url: &str, ) -> String { let pfp_attr = pfp_url_attr( @@ -415,6 +970,7 @@ fn build_article_content_html( ); let timestamp_attr = timestamp_value.to_string(); let author_display = author_display_html(profile.record.as_ref()); + let author_handle = author_handle_html(profile.record.as_ref()); let hero_markup = hero_image .filter(|url| !url.is_empty()) @@ -448,16 +1004,24 @@ fn build_article_content_html( topics_markup.push_str("</div>"); } + // Draft badge for unpublished articles (kind:30024) + let draft_markup = if is_draft { + r#"<span class="damus-article-draft">DRAFT</span>"# + } else { + "" + }; + format!( r#"<article class="damus-card damus-note"> <header class="damus-note-header"> <img src="{pfp}" class="damus-note-avatar" alt="{author} profile picture" /> <div> <div class="damus-note-author">{author}</div> + {handle} <time class="damus-note-time" data-timestamp="{ts}" datetime="{ts}" title="{ts}">{ts}</time> </div> </header> - <h1 class="damus-article-title">{title}</h1> + <h1 class="damus-article-title">{title}{draft}</h1> {hero} {summary} {topics} @@ -465,8 +1029,10 @@ fn build_article_content_html( </article>"#, pfp = pfp_attr, author = author_display, + handle = author_handle, ts = timestamp_attr, title = article_title_html, + draft = draft_markup, hero = hero_markup, summary = summary_markup, topics = topics_markup, @@ -474,6 +1040,174 @@ fn build_article_content_html( ) } +/// Builds HTML for a NIP-84 highlight (kind:9802). +fn build_highlight_content_html( + profile: &Profile<'_>, + base_url: &str, + timestamp_value: u64, + highlight_text_html: &str, + context_html: Option<&str>, + comment_html: Option<&str>, + source_markup: &str, +) -> String { + let author_display = author_display_html(profile.record.as_ref()); + let author_handle = author_handle_html(profile.record.as_ref()); + let pfp_attr = pfp_url_attr( + profile.record.as_ref().and_then(|r| r.record().profile()), + base_url, + ); + let timestamp_attr = timestamp_value.to_string(); + + let context_markup = context_html + .filter(|ctx| !ctx.is_empty()) + .map(|ctx| format!(r#"<div class="damus-highlight-context">…{ctx}…</div>"#)) + .unwrap_or_default(); + + let comment_markup = comment_html + .filter(|c| !c.is_empty()) + .map(|c| format!(r#"<div class="damus-highlight-comment">{c}</div>"#)) + .unwrap_or_default(); + + format!( + r#"<article class="damus-card damus-highlight"> + <header class="damus-note-header"> + <img src="{pfp}" class="damus-note-avatar" alt="{author} profile picture" /> + <div> + <div class="damus-note-author">{author}</div> + {handle} + <time class="damus-note-time" data-timestamp="{ts}" datetime="{ts}" title="{ts}">{ts}</time> + </div> + </header> + {comment} + <blockquote class="damus-highlight-text">{highlight}</blockquote> + {context} + {source} + </article>"#, + pfp = pfp_attr, + author = author_display, + handle = author_handle, + ts = timestamp_attr, + comment = comment_markup, + highlight = highlight_text_html, + context = context_markup, + source = source_markup + ) +} + +/// Builds source attribution markup for a highlight. +fn build_highlight_source_markup(ndb: &Ndb, txn: &Transaction, meta: &HighlightMetadata) -> String { + // Priority: article > note > URL + + // Case 1: Source is a nostr article (a tag) + if let Some(addr) = &meta.source_article_addr { + if let Some((note_key, title)) = lookup_article_by_addr(ndb, txn, addr) { + let author_name = ndb + .get_note_by_key(txn, note_key) + .ok() + .and_then(|note| { + get_profile_display_name( + ndb.get_profile_by_pubkey(txn, note.pubkey()).ok().as_ref(), + ) + .map(|s| s.to_owned()) + }); + + return build_article_source_link(addr, title.as_deref(), author_name.as_deref()); + } + } + + // Case 2: Source is a nostr note (e tag) + if let Some(event_id) = &meta.source_event_id { + return build_note_source_link(event_id); + } + + // Case 3: Source is a web URL (r tag) + if let Some(url) = &meta.source_url { + return build_url_source_link(url); + } + + String::new() +} + +/// Builds source link for an article reference. +fn build_article_source_link(addr: &str, title: Option<&str>, author: Option<&str>) -> String { + use nostr_sdk::prelude::{Coordinate, Kind}; + + let parts: Vec<&str> = addr.splitn(3, ':').collect(); + if parts.len() < 3 { + return String::new(); + } + + let Ok(kind) = parts[0].parse::<u16>() else { + return String::new(); + }; + let Ok(pubkey) = PublicKey::from_hex(parts[1]) else { + return String::new(); + }; + + let coordinate = Coordinate::new(Kind::from(kind), pubkey).identifier(parts[2]); + let Ok(naddr) = coordinate.to_bech32() else { + return String::new(); + }; + + let display_text = match (title, author) { + (Some(t), Some(a)) => format!( + "{} by {}", + html_escape::encode_text(t), + html_escape::encode_text(a) + ), + (Some(t), None) => html_escape::encode_text(t).into_owned(), + (None, Some(a)) => format!("Article by {}", html_escape::encode_text(a)), + (None, None) => abbrev_str(&naddr).to_string(), + }; + + let href_raw = format!("/{naddr}"); + let href = html_escape::encode_double_quoted_attribute(&href_raw); + format!( + r#"<div class="damus-highlight-source"><span class="damus-highlight-source-label">From article:</span> <a href="{href}">{display}</a></div>"#, + href = href, + display = display_text + ) +} + +/// Builds source link for a note reference. +fn build_note_source_link(event_id: &[u8; 32]) -> String { + use nostr_sdk::prelude::EventId; + + let Ok(id) = EventId::from_slice(event_id) else { + return String::new(); + }; + let Ok(nevent) = id.to_bech32() else { + return String::new(); + }; + + let href_raw = format!("/{nevent}"); + let href = html_escape::encode_double_quoted_attribute(&href_raw); + format!( + r#"<div class="damus-highlight-source"><span class="damus-highlight-source-label">From note:</span> <a href="{href}">{abbrev}</a></div>"#, + href = href, + abbrev = abbrev_str(&nevent) + ) +} + +/// Builds source link for a web URL. +fn build_url_source_link(url: &str) -> String { + let domain = url + .trim_start_matches("https://") + .trim_start_matches("http://") + .split('/') + .next() + .unwrap_or(url); + + let href = html_escape::encode_double_quoted_attribute(url); + let domain_html = html_escape::encode_text(domain); + + format!( + r#"<div class="damus-highlight-source"><span class="damus-highlight-source-label">From:</span> <a href="{href}" target="_blank" rel="noopener noreferrer">{domain}</a></div>"#, + href = href, + domain = domain_html + ) +} + const LOCAL_TIME_SCRIPT: &str = r#" <script> (function() { @@ -1204,9 +1938,45 @@ pub fn serve_note_html( summary_display_html.as_deref(), &article_body_html, &topics, + note.kind() == 30024, // is_draft + &base_url, + ) + } else if note.kind() == 9802 { + // NIP-84: Highlights + let highlight_meta = extract_highlight_metadata(&note); + + display_title_raw = format!("Highlight by {}", profile_name_raw); + og_description_raw = collapse_whitespace(abbreviate(note.content(), 200)); + + let highlight_text_html = html_escape::encode_text(note.content()).replace("\n", "<br/>"); + + // Only show context if it meaningfully differs from the highlight text. + // Some clients add/remove trailing punctuation, so we normalize before comparing. + let content_normalized = normalize_for_comparison(note.content()); + let context_html = highlight_meta + .context + .as_deref() + .filter(|ctx| normalize_for_comparison(ctx) != content_normalized) + .map(|ctx| html_escape::encode_text(ctx).into_owned()); + + let comment_html = highlight_meta + .comment + .as_deref() + .map(|c| html_escape::encode_text(c).into_owned()); + + let source_markup = build_highlight_source_markup(&app.ndb, &txn, &highlight_meta); + + build_highlight_content_html( + &profile, &base_url, + timestamp_value, + &highlight_text_html, + context_html.as_deref(), + comment_html.as_deref(), + &source_markup, ) } else { + // Regular notes (kind 1, etc.) build_note_content_html( app, &note,