notecrumbs

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

html.rs (101220B)


      1 use crate::Error;
      2 use crate::{
      3     abbrev::{abbrev_str, abbreviate},
      4     render::{NoteAndProfileRenderData, ProfileRenderData, PROFILE_FEED_RECENT_LIMIT},
      5     Notecrumbs,
      6 };
      7 use ammonia::Builder as HtmlSanitizer;
      8 use http_body_util::Full;
      9 use hyper::{body::Bytes, header, Request, Response, StatusCode};
     10 use nostr::nips::nip19::Nip19Event;
     11 use nostr_sdk::prelude::{EventId, FromBech32, Nip19, PublicKey, RelayUrl, ToBech32};
     12 use nostrdb::NoteMetadataEntryVariant;
     13 use nostrdb::{
     14     BlockType, Blocks, Filter, Mention, Ndb, NdbProfile, Note, NoteKey, ProfileRecord, Transaction,
     15 };
     16 use pulldown_cmark::{html, Options, Parser};
     17 use std::fmt::Write as _;
     18 use std::io::Write;
     19 use std::str::FromStr;
     20 use tracing::warn;
     21 
     22 struct QuoteProfileInfo {
     23     display_name: Option<String>,
     24     username: Option<String>,
     25     pfp_url: Option<String>,
     26 }
     27 
     28 #[derive(Debug, Clone, PartialEq, Eq)]
     29 struct RelayEntry {
     30     url: String,
     31     read: bool,
     32     write: bool,
     33 }
     34 
     35 fn merge_relay_entry(relays: &mut Vec<RelayEntry>, url: &str, marker: Option<&str>) {
     36     let cleaned_url = url.trim();
     37     if cleaned_url.is_empty() {
     38         return;
     39     }
     40 
     41     let (read, write) = marker
     42         .map(|value| value.trim().to_ascii_lowercase())
     43         .map(|value| match value.as_str() {
     44             "read" => (true, false),
     45             "write" => (false, true),
     46             _ => (true, true),
     47         })
     48         .unwrap_or((true, true));
     49 
     50     if let Some(existing) = relays.iter_mut().find(|entry| entry.url == cleaned_url) {
     51         existing.read |= read;
     52         existing.write |= write;
     53         return;
     54     }
     55 
     56     relays.push(RelayEntry {
     57         url: cleaned_url.to_string(),
     58         read,
     59         write,
     60     });
     61 }
     62 
     63 const ICON_KEY_CIRCLE: &str = r#"<svg viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M11.3058 6.37751C11.4643 7.01298 11.0775 7.65657 10.4421 7.81501C9.80661 7.97345 9.16302 7.58674 9.00458 6.95127C8.84614 6.3158 9.23285 5.67221 9.86831 5.51377C10.5038 5.35533 11.1474 5.74204 11.3058 6.37751Z" fill="currentColor"/><path fill-rule="evenodd" clip-rule="evenodd" d="M9 18C13.9706 18 18 13.9706 18 9C18 4.02944 13.9706 0 9 0C4.02944 0 0 4.02944 0 9C0 13.9706 4.02944 18 9 18ZM10.98 10.0541C12.8102 9.59778 13.9381 7.80131 13.4994 6.04155C13.0606 4.28178 11.2213 3.22513 9.39116 3.68144C7.56101 4.13774 6.43306 5.93422 6.87182 7.69398C6.97647 8.11372 7.1608 8.49345 7.40569 8.8222L5.3739 12.0582C5.30459 12.1686 5.28324 12.3025 5.31477 12.4289L5.73708 14.1228C5.7691 14.2511 5.89912 14.3293 6.02751 14.2973L7.81697 13.8511C7.93712 13.8211 8.04101 13.7458 8.10686 13.641L10.295 10.1559C10.5216 10.1446 10.7509 10.1112 10.98 10.0541Z" fill="currentColor"/></svg>"#;
     64 const ICON_CONTACT_CIRCLE: &str = r#"<svg viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" clip-rule="evenodd" d="M9 18C13.9706 18 18 13.9706 18 9C18 4.02944 13.9706 0 9 0C4.02944 0 0 4.02944 0 9C0 13.9706 4.02944 18 9 18ZM11.6667 6.66667C11.6667 8.13943 10.4728 9.33333 9.00004 9.33333C7.52728 9.33333 6.33337 8.13943 6.33337 6.66667C6.33337 5.19391 7.52728 4 9.00004 4C10.4728 4 11.6667 5.19391 11.6667 6.66667ZM13.6667 12.3333C13.6667 13.2538 11.5774 14 9.00004 14C6.42271 14 4.33337 13.2538 4.33337 12.3333C4.33337 11.4129 6.42271 10.6667 9.00004 10.6667C11.5774 10.6667 13.6667 11.4129 13.6667 12.3333Z" fill="currentColor"/></svg>"#;
     65 const ICON_LINK_CIRCLE: &str = r#"<svg viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" clip-rule="evenodd" d="M9 18C13.9706 18 18 13.9706 18 9C18 4.02944 13.9706 0 9 0C4.02944 0 0 4.02944 0 9C0 13.9706 4.02944 18 9 18ZM10.5074 5.12274C10.7369 4.89317 11.1091 4.89317 11.3387 5.12274L12.8772 6.6612C13.1067 6.89077 13.1067 7.26298 12.8772 7.49256L10.9541 9.41563C10.7588 9.6109 10.7588 9.92748 10.9541 10.1227C11.1494 10.318 11.4659 10.318 11.6612 10.1227L13.5843 8.19966C14.2044 7.57957 14.2044 6.57419 13.5843 5.95409L12.0458 4.41563C11.4257 3.79554 10.4203 3.79554 9.80025 4.41563L7.87718 6.33871C7.68191 6.53397 7.68191 6.85055 7.87718 7.04582C8.07244 7.24108 8.38902 7.24108 8.58428 7.04582L10.5074 5.12274ZM11.0843 7.62274C11.2795 7.42748 11.2795 7.1109 11.0843 6.91563C10.889 6.72037 10.5724 6.72037 10.3772 6.91563L7.10794 10.1849C6.91268 10.3801 6.91268 10.6967 7.10794 10.892C7.30321 11.0872 7.61979 11.0872 7.81505 10.892L11.0843 7.62274ZM7.04582 8.5843C7.24108 8.38904 7.24108 8.07246 7.04582 7.8772C6.85055 7.68194 6.53397 7.68194 6.33871 7.8772L4.41563 9.80027C3.79554 10.4204 3.79554 11.4257 4.41563 12.0458L5.9541 13.5843C6.57419 14.2044 7.57957 14.2044 8.19966 13.5843L10.1227 11.6612C10.318 11.466 10.318 11.1494 10.1227 10.9541C9.92748 10.7589 9.6109 10.7589 9.41563 10.9541L7.49256 12.8772C7.26299 13.1068 6.89077 13.1068 6.6612 12.8772L5.12274 11.3387C4.89317 11.1092 4.89317 10.737 5.12274 10.5074L7.04582 8.5843Z" fill="currentColor"/></svg>"#;
     66 const ICON_BITCOIN: &str = r#"<svg viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M8.28295 7.96658L8.23361 7.95179L8.76146 5.8347C8.81784 5.84928 8.88987 5.86543 8.97324 5.88412C9.67913 6.04237 11.1984 6.38297 10.9233 7.49805C10.6279 8.67114 8.87435 8.14427 8.28295 7.96658Z" fill="currentColor"/><path d="M7.3698 11.4046L7.4555 11.43C8.18407 11.6467 10.2516 12.2615 10.532 11.0972C10.8209 9.97593 8.96224 9.53925 8.13013 9.34375C8.0389 9.32232 7.96002 9.30378 7.89765 9.28756L7.3698 11.4046Z" fill="currentColor"/><path fill-rule="evenodd" clip-rule="evenodd" d="M9 18C13.9706 18 18 13.9706 18 9C18 4.02944 13.9706 0 9 0C4.02944 0 0 4.02944 0 9C0 13.9706 4.02944 18 9 18ZM12.8732 7.61593C13.0794 6.31428 12.1803 5.63589 10.9322 5.17799L11.3709 3.40745L10.3814 3.16221L9.95392 4.88751C9.88913 4.87105 9.82482 4.85441 9.76074 4.83784C9.56538 4.78731 9.3721 4.73731 9.17436 4.69431L9.6018 2.96901L8.58479 2.71696L8.15735 4.44226L6.13863 3.94193L5.847 5.12223C5.847 5.12223 6.59551 5.285 6.56824 5.30098C6.96889 5.40897 7.03686 5.69278 7.01489 5.90971L6.50629 7.91664L5.80746 10.7404C5.75255 10.8744 5.61847 11.0659 5.34426 10.9993C5.35573 11.012 4.61643 10.8087 4.61643 10.8087L4.12964 12.0541L6.08834 12.5875L5.65196 14.3489L6.63523 14.5926L7.07161 12.8312C7.22991 12.8767 7.38989 12.9139 7.54471 12.95C7.66051 12.9769 7.77355 13.0032 7.8807 13.0318L7.44432 14.7931L8.42939 15.0373L8.86577 13.2759C10.5611 13.5993 11.841 13.448 12.4129 11.7791C12.8726 10.4484 12.4427 9.68975 11.5496 9.18998C12.2207 9.02654 12.7174 8.56346 12.8732 7.61593Z" fill="currentColor"/></svg>"#;
     67 fn blocktype_name(blocktype: &BlockType) -> &'static str {
     68     match blocktype {
     69         BlockType::MentionBech32 => "mention",
     70         BlockType::Hashtag => "hashtag",
     71         BlockType::Url => "url",
     72         BlockType::Text => "text",
     73         BlockType::MentionIndex => "indexed_mention",
     74         BlockType::Invoice => "invoice",
     75     }
     76 }
     77 
     78 #[derive(Default)]
     79 struct ArticleMetadata {
     80     title: Option<String>,
     81     image: Option<String>,
     82     summary: Option<String>,
     83     published_at: Option<u64>,
     84     topics: Vec<String>,
     85 }
     86 
     87 /// Metadata extracted from NIP-84 highlight events (kind:9802).
     88 ///
     89 /// Highlights capture a passage from source content with optional context.
     90 /// Sources can be: web URLs (r tag), nostr notes (e tag), or articles (a tag).
     91 #[derive(Default)]
     92 struct HighlightMetadata {
     93     /// Surrounding text providing context for the highlight (from "context" tag)
     94     context: Option<String>,
     95     /// User's comment/annotation on the highlight (from "comment" tag)
     96     comment: Option<String>,
     97     /// Web URL source - external article or page (from "r" tag)
     98     source_url: Option<String>,
     99     /// Nostr note ID - reference to a kind:1 shortform note (from "e" tag)
    100     source_event_id: Option<[u8; 32]>,
    101     /// Nostr article address - reference to kind:30023/30024 (from "a" tag)
    102     /// Format: "30023:{pubkey_hex}:{d-identifier}"
    103     source_article_addr: Option<String>,
    104 }
    105 
    106 /// Normalizes text for comparison by trimming whitespace and trailing punctuation.
    107 /// Used to detect when context and content are essentially the same text.
    108 fn normalize_for_comparison(s: &str) -> String {
    109     s.trim()
    110         .trim_end_matches(|c: char| c.is_ascii_punctuation())
    111         .to_lowercase()
    112 }
    113 
    114 fn collapse_whitespace<S: AsRef<str>>(input: S) -> String {
    115     let mut result = String::with_capacity(input.as_ref().len());
    116     let mut last_space = false;
    117     for ch in input.as_ref().chars() {
    118         if ch.is_whitespace() {
    119             if !last_space && !result.is_empty() {
    120                 result.push(' ');
    121                 last_space = true;
    122             }
    123         } else {
    124             result.push(ch);
    125             last_space = false;
    126         }
    127     }
    128 
    129     result.trim().to_string()
    130 }
    131 
    132 fn extract_article_metadata(note: &Note) -> ArticleMetadata {
    133     let mut meta = ArticleMetadata::default();
    134 
    135     for tag in note.tags() {
    136         let mut iter = tag.into_iter();
    137         let Some(tag_kind) = iter.next().and_then(|nstr| nstr.variant().str()) else {
    138             continue;
    139         };
    140 
    141         match tag_kind {
    142             "title" => {
    143                 if let Some(value) = iter.next().and_then(|nstr| nstr.variant().str()) {
    144                     meta.title = Some(value.to_owned());
    145                 }
    146             }
    147             "image" => {
    148                 if let Some(value) = iter.next().and_then(|nstr| nstr.variant().str()) {
    149                     meta.image = Some(value.to_owned());
    150                 }
    151             }
    152             "summary" => {
    153                 if let Some(value) = iter.next().and_then(|nstr| nstr.variant().str()) {
    154                     meta.summary = Some(value.to_owned());
    155                 }
    156             }
    157             "published_at" => {
    158                 if let Some(value) = iter.next().and_then(|nstr| nstr.variant().str()) {
    159                     if let Ok(ts) = u64::from_str(value) {
    160                         meta.published_at = Some(ts);
    161                     }
    162                 }
    163             }
    164             "t" => {
    165                 for topic in iter {
    166                     if let Some(value) = topic.variant().str() {
    167                         if !value.is_empty()
    168                             && !meta
    169                                 .topics
    170                                 .iter()
    171                                 .any(|existing| existing.eq_ignore_ascii_case(value))
    172                         {
    173                             meta.topics.push(value.to_owned());
    174                         }
    175                         if meta.topics.len() >= 10 {
    176                             break;
    177                         }
    178                     }
    179                 }
    180             }
    181             _ => {}
    182         }
    183     }
    184 
    185     meta
    186 }
    187 
    188 /// Extracts NIP-84 highlight metadata from a kind:9802 note.
    189 ///
    190 /// Parses tags to identify the highlight source:
    191 /// - "context" tag: surrounding text for context
    192 /// - "comment" tag: user's annotation
    193 /// - "r" tag: web URL source (external article/page)
    194 /// - "e" tag: nostr note ID (kind:1 shortform note)
    195 /// - "a" tag: nostr article address (kind:30023/30024 longform)
    196 fn extract_highlight_metadata(note: &Note) -> HighlightMetadata {
    197     let mut meta = HighlightMetadata::default();
    198 
    199     for tag in note.tags() {
    200         let Some(tag_name) = tag.get_str(0) else {
    201             continue;
    202         };
    203 
    204         match tag_name {
    205             "context" => {
    206                 if let Some(value) = tag.get_str(1) {
    207                     if !value.trim().is_empty() {
    208                         meta.context = Some(value.to_owned());
    209                     }
    210                 }
    211             }
    212 
    213             "comment" => {
    214                 if let Some(value) = tag.get_str(1) {
    215                     if !value.trim().is_empty() {
    216                         meta.comment = Some(value.to_owned());
    217                     }
    218                 }
    219             }
    220 
    221             "r" => {
    222                 if let Some(value) = tag.get_str(1) {
    223                     let trimmed = value.trim();
    224                     if trimmed.starts_with("http://") || trimmed.starts_with("https://") {
    225                         meta.source_url = Some(trimmed.to_owned());
    226                     }
    227                 }
    228             }
    229 
    230             "e" => {
    231                 // The e tag value is guaranteed to be an ID
    232                 if let Some(event_id) = tag.get_id(1) {
    233                     meta.source_event_id = Some(*event_id);
    234                 }
    235             }
    236 
    237             "a" => {
    238                 if let Some(value) = tag.get_str(1) {
    239                     let trimmed = value.trim();
    240                     if trimmed.starts_with("30023:") || trimmed.starts_with("30024:") {
    241                         meta.source_article_addr = Some(trimmed.to_owned());
    242                     }
    243                 }
    244             }
    245 
    246             _ => {}
    247         }
    248     }
    249 
    250     meta
    251 }
    252 
    253 /// Formats a unix timestamp as a relative time string (e.g., "6h", "2d", "3w").
    254 fn format_relative_time(timestamp: u64) -> String {
    255     let now = std::time::SystemTime::now()
    256         .duration_since(std::time::UNIX_EPOCH)
    257         .map(|d| d.as_secs())
    258         .unwrap_or(0);
    259 
    260     if timestamp > now {
    261         return "now".to_string();
    262     }
    263 
    264     let diff = now - timestamp;
    265     let minutes = diff / 60;
    266     let hours = diff / 3600;
    267     let days = diff / 86400;
    268     let weeks = diff / 604800;
    269 
    270     if minutes < 1 {
    271         "now".to_string()
    272     } else if minutes < 60 {
    273         format!("{}m", minutes)
    274     } else if hours < 24 {
    275         format!("{}h", hours)
    276     } else if days < 7 {
    277         format!("{}d", days)
    278     } else {
    279         format!("{}w", weeks)
    280     }
    281 }
    282 
    283 fn render_markdown(markdown: &str) -> String {
    284     let mut options = Options::empty();
    285     options.insert(Options::ENABLE_TABLES);
    286     options.insert(Options::ENABLE_FOOTNOTES);
    287     options.insert(Options::ENABLE_STRIKETHROUGH);
    288     options.insert(Options::ENABLE_TASKLISTS);
    289 
    290     let parser = Parser::new_ext(markdown, options);
    291     let mut html_buf = String::new();
    292     html::push_html(&mut html_buf, parser);
    293 
    294     HtmlSanitizer::default().clean(&html_buf).to_string()
    295 }
    296 
    297 pub fn serve_note_json(
    298     ndb: &Ndb,
    299     note_rd: &NoteAndProfileRenderData,
    300 ) -> Result<Response<Full<Bytes>>, Error> {
    301     let mut body: Vec<u8> = vec![];
    302 
    303     let txn = Transaction::new(ndb)?;
    304 
    305     let note = match note_rd.note_rd.lookup(&txn, ndb) {
    306         Ok(note) => note,
    307         Err(_) => return Err(Error::NotFound),
    308     };
    309 
    310     let note_key = match note.key() {
    311         Some(note_key) => note_key,
    312         None => return Err(Error::NotFound),
    313     };
    314 
    315     write!(body, "{{\"note\":{},\"parsed_content\":[", &note.json()?)?;
    316 
    317     if let Ok(blocks) = ndb.get_blocks_by_key(&txn, note_key) {
    318         for (i, block) in blocks.iter(&note).enumerate() {
    319             if i != 0 {
    320                 write!(body, ",")?;
    321             }
    322             write!(
    323                 body,
    324                 "{{\"{}\":{}}}",
    325                 blocktype_name(&block.blocktype()),
    326                 serde_json::to_string(block.as_str())?
    327             )?;
    328         }
    329     };
    330 
    331     write!(body, "]")?;
    332 
    333     if let Ok(results) = ndb.query(
    334         &txn,
    335         &[Filter::new()
    336             .authors([note.pubkey()])
    337             .kinds([0])
    338             .limit(1)
    339             .build()],
    340         1,
    341     ) {
    342         if let Some(profile_note) = results.first() {
    343             write!(body, ",\"profile\":{}", profile_note.note.json()?)?;
    344         }
    345     }
    346 
    347     writeln!(body, "}}")?;
    348 
    349     Ok(Response::builder()
    350         .header(header::CONTENT_TYPE, "application/json; charset=utf-8")
    351         .status(StatusCode::OK)
    352         .body(Full::new(Bytes::from(body)))?)
    353 }
    354 
    355 fn ends_with(haystack: &str, needle: &str) -> bool {
    356     if haystack.len() < needle.len() {
    357         return false;
    358     }
    359     haystack
    360         .get(haystack.len() - needle.len()..)
    361         .is_some_and(|tail| tail.eq_ignore_ascii_case(needle))
    362 }
    363 
    364 fn strip_querystring(url: &str) -> &str {
    365     let end = url.find(['?', '#']).unwrap_or(url.len());
    366 
    367     &url[..end]
    368 }
    369 
    370 fn is_video(url: &str) -> bool {
    371     const VIDEOS: [&str; 2] = ["mp4", "mov"];
    372 
    373     VIDEOS
    374         .iter()
    375         .any(|ext| ends_with(strip_querystring(url), ext))
    376 }
    377 
    378 fn is_image(url: &str) -> bool {
    379     const IMAGES: [&str; 10] = [
    380         "jpg", "jpeg", "png", "gif", "webp", "svg", "avif", "bmp", "ico", "apng",
    381     ];
    382 
    383     IMAGES
    384         .iter()
    385         .any(|ext| ends_with(strip_querystring(url), ext))
    386 }
    387 
    388 /// Gets the display name for a profile, preferring display_name, falling back to name.
    389 fn get_profile_display_name<'a>(record: Option<&ProfileRecord<'a>>) -> Option<&'a str> {
    390     let profile = record?.record().profile()?;
    391     let display_name = profile.display_name().filter(|n| !n.trim().is_empty());
    392     let username = profile.name().filter(|n| !n.trim().is_empty());
    393     display_name.or(username)
    394 }
    395 
    396 pub fn render_note_content(
    397     body: &mut Vec<u8>,
    398     note: &Note,
    399     blocks: &Blocks,
    400     ndb: &Ndb,
    401     txn: &Transaction,
    402 ) {
    403     for block in blocks.iter(note) {
    404         match block.blocktype() {
    405             BlockType::Url => {
    406                 let url = html_escape::encode_text(block.as_str());
    407                 if is_image(&url) {
    408                     let _ = write!(body, r#"<img src="{}">"#, url);
    409                 } else if is_video(&url) {
    410                     let _ = write!(
    411                         body,
    412                         r#"<video src="{}" loop autoplay muted playsinline controls></video>"#,
    413                         url
    414                     );
    415                 } else {
    416                     let _ = write!(body, r#"<a href="{}">{}</a>"#, url, url);
    417                 }
    418             }
    419 
    420             BlockType::Hashtag => {
    421                 let hashtag = html_escape::encode_text(block.as_str());
    422                 let _ = write!(body, r#"<span class="hashtag">#{}</span>"#, hashtag);
    423             }
    424 
    425             BlockType::Text => {
    426                 let text = html_escape::encode_text(block.as_str()).replace("\n", "<br/>");
    427                 let _ = write!(body, r"{}", text);
    428             }
    429 
    430             BlockType::Invoice => {
    431                 let _ = write!(body, r"{}", block.as_str());
    432             }
    433 
    434             BlockType::MentionIndex => {
    435                 let _ = write!(body, r"@nostrich");
    436             }
    437 
    438             BlockType::MentionBech32 => {
    439                 let mention = block.as_mention().unwrap();
    440                 let pubkey = match mention {
    441                     Mention::Profile(p) => Some(p.pubkey()),
    442                     Mention::Pubkey(p) => Some(p.pubkey()),
    443                     _ => None,
    444                 };
    445 
    446                 if let Some(pk) = pubkey {
    447                     // Profile/pubkey mentions: show the human-readable name
    448                     let record = ndb.get_profile_by_pubkey(txn, pk).ok();
    449                     let display = get_profile_display_name(record.as_ref())
    450                         .map(|s| s.to_string())
    451                         .unwrap_or_else(|| abbrev_str(block.as_str()));
    452                     let display_html = html_escape::encode_text(&display);
    453                     let _ = write!(
    454                         body,
    455                         r#"<a href="/{bech32}">@{display}</a>"#,
    456                         bech32 = block.as_str(),
    457                         display = display_html
    458                     );
    459                 } else {
    460                     match mention {
    461                         // Event/note mentions: skip inline rendering (shown as embedded quotes)
    462                         Mention::Event(_) | Mention::Note(_) => {}
    463 
    464                         // Other mentions: link with abbreviated bech32
    465                         _ => {
    466                             let _ = write!(
    467                                 body,
    468                                 r#"<a href="/{bech32}">{abbrev}</a>"#,
    469                                 bech32 = block.as_str(),
    470                                 abbrev = abbrev_str(block.as_str())
    471                             );
    472                         }
    473                     }
    474                 }
    475             }
    476         };
    477     }
    478 }
    479 
    480 /// Represents a quoted event reference from a q tag (NIP-18) or inline mention.
    481 #[derive(Clone, PartialEq)]
    482 pub enum QuoteRef {
    483     Event {
    484         id: [u8; 32],
    485         bech32: Option<String>,
    486         relays: Vec<RelayUrl>,
    487     },
    488     Article {
    489         addr: String,
    490         bech32: Option<String>,
    491         relays: Vec<RelayUrl>,
    492     },
    493 }
    494 
    495 /// Extracts quote references from inline nevent/note mentions in content.
    496 fn extract_quote_refs_from_content(note: &Note, blocks: &Blocks) -> Vec<QuoteRef> {
    497     use nostr_sdk::prelude::Nip19;
    498 
    499     let mut quotes = Vec::new();
    500 
    501     for block in blocks.iter(note) {
    502         if block.blocktype() != BlockType::MentionBech32 {
    503             continue;
    504         }
    505 
    506         let Some(mention) = block.as_mention() else {
    507             continue;
    508         };
    509 
    510         match mention {
    511             Mention::Event(_ev) => {
    512                 let bech32_str = block.as_str();
    513                 // Parse to get relay hints from nevent
    514                 if let Ok(Nip19::Event(ev)) = Nip19::from_bech32(bech32_str) {
    515                     let relays: Vec<RelayUrl> = ev.relays.to_vec();
    516                     quotes.push(QuoteRef::Event {
    517                         id: *ev.event_id.as_bytes(),
    518                         bech32: Some(bech32_str.to_string()),
    519                         relays,
    520                     });
    521                 } else if let Ok(Nip19::EventId(id)) = Nip19::from_bech32(bech32_str) {
    522                     // note1 format has no relay hints
    523                     quotes.push(QuoteRef::Event {
    524                         id: *id.as_bytes(),
    525                         bech32: Some(bech32_str.to_string()),
    526                         relays: vec![],
    527                     });
    528                 }
    529             }
    530             Mention::Note(_note_ref) => {
    531                 let bech32_str = block.as_str();
    532                 // note1 format has no relay hints
    533                 if let Ok(Nip19::EventId(id)) = Nip19::from_bech32(bech32_str) {
    534                     quotes.push(QuoteRef::Event {
    535                         id: *id.as_bytes(),
    536                         bech32: Some(bech32_str.to_string()),
    537                         relays: vec![],
    538                     });
    539                 }
    540             }
    541             // naddr mentions - articles (30023/30024) and highlights (9802)
    542             Mention::Addr(_) => {
    543                 let bech32_str = block.as_str();
    544                 if let Ok(Nip19::Coordinate(coord)) = Nip19::from_bech32(bech32_str) {
    545                     let kind = coord.kind.as_u16();
    546                     if kind == 30023 || kind == 30024 || kind == 9802 {
    547                         let addr = format!(
    548                             "{}:{}:{}",
    549                             kind,
    550                             coord.public_key.to_hex(),
    551                             coord.identifier
    552                         );
    553                         quotes.push(QuoteRef::Article {
    554                             addr,
    555                             bech32: Some(bech32_str.to_string()),
    556                             relays: coord.relays,
    557                         });
    558                     }
    559                 }
    560             }
    561             _ => {}
    562         }
    563     }
    564 
    565     quotes
    566 }
    567 
    568 /// Extracts quote references from q tags (NIP-18 quote reposts).
    569 fn extract_quote_refs_from_tags(note: &Note) -> Vec<QuoteRef> {
    570     use nostr_sdk::prelude::Nip19;
    571 
    572     let mut quotes = Vec::new();
    573 
    574     for tag in note.tags() {
    575         if tag.get_str(0) != Some("q") {
    576             continue;
    577         }
    578 
    579         let Some(value) = tag.get_str(1) else {
    580             continue;
    581         };
    582         let trimmed = value.trim();
    583 
    584         // Optional relay hint in third element of q tag
    585         let tag_relay_hint: Option<RelayUrl> = tag
    586             .get_str(2)
    587             .filter(|s| !s.is_empty())
    588             .and_then(|s| RelayUrl::parse(s).ok());
    589 
    590         // Try nevent/note bech32
    591         if trimmed.starts_with("nevent1") || trimmed.starts_with("note1") {
    592             if let Ok(nip19) = Nip19::from_bech32(trimmed) {
    593                 match nip19 {
    594                     Nip19::Event(ev) => {
    595                         // Combine relays from nevent with q tag relay hint
    596                         let mut relays: Vec<RelayUrl> = ev.relays.to_vec();
    597                         if let Some(hint) = &tag_relay_hint {
    598                             if !relays.contains(hint) {
    599                                 relays.push(hint.clone());
    600                             }
    601                         }
    602                         quotes.push(QuoteRef::Event {
    603                             id: *ev.event_id.as_bytes(),
    604                             bech32: Some(trimmed.to_owned()),
    605                             relays,
    606                         });
    607                         continue;
    608                     }
    609                     Nip19::EventId(id) => {
    610                         quotes.push(QuoteRef::Event {
    611                             id: *id.as_bytes(),
    612                             bech32: Some(trimmed.to_owned()),
    613                             relays: tag_relay_hint.clone().into_iter().collect(),
    614                         });
    615                         continue;
    616                     }
    617                     _ => {}
    618                 }
    619             }
    620         }
    621 
    622         // Try naddr bech32
    623         if trimmed.starts_with("naddr1") {
    624             if let Ok(Nip19::Coordinate(coord)) = Nip19::from_bech32(trimmed) {
    625                 let addr = format!(
    626                     "{}:{}:{}",
    627                     coord.kind.as_u16(),
    628                     coord.public_key.to_hex(),
    629                     coord.identifier
    630                 );
    631                 // Combine relays from naddr with q tag relay hint
    632                 let mut relays = coord.relays;
    633                 if let Some(hint) = &tag_relay_hint {
    634                     if !relays.contains(hint) {
    635                         relays.push(hint.clone());
    636                     }
    637                 }
    638                 quotes.push(QuoteRef::Article {
    639                     addr,
    640                     bech32: Some(trimmed.to_owned()),
    641                     relays,
    642                 });
    643                 continue;
    644             }
    645         }
    646 
    647         // Try article address format
    648         if trimmed.starts_with("30023:") || trimmed.starts_with("30024:") {
    649             quotes.push(QuoteRef::Article {
    650                 addr: trimmed.to_owned(),
    651                 bech32: None,
    652                 relays: tag_relay_hint.into_iter().collect(),
    653             });
    654             continue;
    655         }
    656 
    657         // Try hex event ID
    658         if let Ok(bytes) = hex::decode(trimmed) {
    659             if let Ok(id) = bytes.try_into() {
    660                 quotes.push(QuoteRef::Event {
    661                     id,
    662                     bech32: None,
    663                     relays: tag_relay_hint.into_iter().collect(),
    664                 });
    665             }
    666         }
    667     }
    668 
    669     quotes
    670 }
    671 
    672 /// Collects all quote refs from a note (q tags + inline mentions).
    673 pub fn collect_all_quote_refs(ndb: &Ndb, txn: &Transaction, note: &Note) -> Vec<QuoteRef> {
    674     let mut refs = extract_quote_refs_from_tags(note);
    675 
    676     if let Some(blocks) = note.key().and_then(|k| ndb.get_blocks_by_key(txn, k).ok()) {
    677         let inline = extract_quote_refs_from_content(note, &blocks);
    678         // Deduplicate - only add inline refs not already in q tags
    679         for r in inline {
    680             if !refs.contains(&r) {
    681                 refs.push(r);
    682             }
    683         }
    684     }
    685 
    686     refs
    687 }
    688 
    689 /// Looks up an article by address (kind:pubkey:d-tag) and returns the note key + optional title.
    690 fn lookup_article_by_addr(
    691     ndb: &Ndb,
    692     txn: &Transaction,
    693     addr: &str,
    694 ) -> Option<(NoteKey, Option<String>)> {
    695     let parts: Vec<&str> = addr.splitn(3, ':').collect();
    696     if parts.len() < 3 {
    697         return None;
    698     }
    699 
    700     let kind: u64 = parts[0].parse().ok()?;
    701     let pubkey_bytes = hex::decode(parts[1]).ok()?;
    702     let pubkey: [u8; 32] = pubkey_bytes.try_into().ok()?;
    703     let d_identifier = parts[2];
    704 
    705     let filter = Filter::new().authors([&pubkey]).kinds([kind]).build();
    706     let results = ndb.query(txn, &[filter], 10).ok()?;
    707 
    708     for result in results {
    709         let mut found_d_match = false;
    710         let mut title = None;
    711 
    712         for tag in result.note.tags() {
    713             let tag_name = tag.get_str(0)?;
    714             match tag_name {
    715                 "d" => {
    716                     if tag.get_str(1) == Some(d_identifier) {
    717                         found_d_match = true;
    718                     }
    719                 }
    720                 "title" => {
    721                     if let Some(t) = tag.get_str(1) {
    722                         if !t.trim().is_empty() {
    723                             title = Some(t.to_owned());
    724                         }
    725                     }
    726                 }
    727                 _ => {}
    728             }
    729         }
    730 
    731         if found_d_match {
    732             return Some((result.note_key, title));
    733         }
    734     }
    735 
    736     None
    737 }
    738 
    739 /// Builds a link URL for a quote reference.
    740 fn build_quote_link(quote_ref: &QuoteRef) -> String {
    741     use nostr_sdk::prelude::{Coordinate, EventId, Kind};
    742 
    743     match quote_ref {
    744         QuoteRef::Event { id, bech32, .. } => {
    745             if let Some(b) = bech32 {
    746                 return format!("/{}", b);
    747             }
    748             if let Ok(b) =
    749                 EventId::from_slice(id).map(|eid| eid.to_bech32().expect("infallible apparently"))
    750             {
    751                 return format!("/{}", b);
    752             }
    753         }
    754         QuoteRef::Article { addr, bech32, .. } => {
    755             if let Some(b) = bech32 {
    756                 return format!("/{}", b);
    757             }
    758             let parts: Vec<&str> = addr.splitn(3, ':').collect();
    759             if parts.len() >= 3 {
    760                 if let Ok(kind) = parts[0].parse::<u16>() {
    761                     if let Ok(pubkey) = PublicKey::from_hex(parts[1]) {
    762                         let coordinate =
    763                             Coordinate::new(Kind::from(kind), pubkey).identifier(parts[2]);
    764                         if let Ok(naddr) = coordinate.to_bech32() {
    765                             return format!("/{}", naddr);
    766                         }
    767                     }
    768                 }
    769             }
    770         }
    771     }
    772     "#".to_string()
    773 }
    774 
    775 /// Builds embedded quote HTML for referenced events.
    776 fn build_embedded_quotes_html(ndb: &Ndb, txn: &Transaction, quote_refs: &[QuoteRef]) -> String {
    777     use nostrdb::NoteReply;
    778 
    779     if quote_refs.is_empty() {
    780         return String::new();
    781     }
    782 
    783     let mut quotes_html = String::new();
    784 
    785     for quote_ref in quote_refs {
    786         let quoted_note = match quote_ref {
    787             QuoteRef::Event { id, .. } => match ndb.get_note_by_id(txn, id) {
    788                 Ok(note) => note,
    789                 Err(_) => continue,
    790             },
    791             QuoteRef::Article { addr, .. } => match lookup_article_by_addr(ndb, txn, addr) {
    792                 Some((note_key, _title)) => match ndb.get_note_by_key(txn, note_key) {
    793                     Ok(note) => note,
    794                     Err(_) => continue,
    795                 },
    796                 None => continue,
    797             },
    798         };
    799 
    800         // Get author profile (filter empty strings for proper fallback)
    801         let profile_info = ndb
    802             .get_profile_by_pubkey(txn, quoted_note.pubkey())
    803             .ok()
    804             .and_then(|rec| {
    805                 rec.record().profile().map(|p| {
    806                     let display_name = p
    807                         .display_name()
    808                         .filter(|s| !s.is_empty())
    809                         .or_else(|| p.name().filter(|s| !s.is_empty()))
    810                         .map(|n| n.to_owned());
    811                     let username = p
    812                         .name()
    813                         .filter(|s| !s.is_empty())
    814                         .map(|n| format!("@{}", n));
    815                     let pfp_url = p.picture().filter(|s| !s.is_empty()).map(|s| s.to_owned());
    816                     QuoteProfileInfo {
    817                         display_name,
    818                         username,
    819                         pfp_url,
    820                     }
    821                 })
    822             })
    823             .unwrap_or(QuoteProfileInfo {
    824                 display_name: None,
    825                 username: None,
    826                 pfp_url: None,
    827             });
    828 
    829         let display_name = profile_info
    830             .display_name
    831             .unwrap_or_else(|| "nostrich".to_string());
    832         let display_name_html = html_escape::encode_text(&display_name);
    833         let username_html = profile_info
    834             .username
    835             .map(|u| {
    836                 format!(
    837                     r#" <span class="damus-embedded-quote-username">{}</span>"#,
    838                     html_escape::encode_text(&u)
    839                 )
    840             })
    841             .unwrap_or_default();
    842 
    843         let pfp_html = profile_info
    844             .pfp_url
    845             .filter(|url| !url.trim().is_empty())
    846             .map(|url| {
    847                 let pfp_attr = html_escape::encode_double_quoted_attribute(&url);
    848                 format!(
    849                     r#"<img src="{}" class="damus-embedded-quote-avatar" alt="" />"#,
    850                     pfp_attr
    851                 )
    852             })
    853             .unwrap_or_else(|| {
    854                 r#"<img src="/img/no-profile.svg" class="damus-embedded-quote-avatar" alt="" />"#
    855                     .to_string()
    856             });
    857 
    858         let relative_time = format_relative_time(quoted_note.created_at());
    859         let time_html = html_escape::encode_text(&relative_time);
    860 
    861         // Detect reply using nostrdb's NoteReply
    862         let reply_html = NoteReply::new(quoted_note.tags())
    863             .reply()
    864             .and_then(|reply_ref| ndb.get_note_by_id(txn, reply_ref.id).ok())
    865             .and_then(|parent| {
    866                 get_profile_display_name(
    867                     ndb.get_profile_by_pubkey(txn, parent.pubkey())
    868                         .ok()
    869                         .as_ref(),
    870                 )
    871                 .map(|name| format!("@{}", name))
    872             })
    873             .map(|name| {
    874                 format!(
    875                     r#"<div class="damus-embedded-quote-reply">Replying to {}</div>"#,
    876                     html_escape::encode_text(&name)
    877                 )
    878             })
    879             .unwrap_or_default();
    880 
    881         // For articles, we use a special card layout with image, title, summary, word count
    882         let (content_preview, is_truncated, type_indicator, content_class, article_card) =
    883             match quoted_note.kind() {
    884                 // For articles, extract metadata and build card layout
    885                 30023 | 30024 => {
    886                     let mut title: Option<&str> = None;
    887                     let mut image: Option<&str> = None;
    888                     let mut summary: Option<&str> = None;
    889 
    890                     for tag in quoted_note.tags() {
    891                         let mut iter = tag.into_iter();
    892                         let Some(tag_name) = iter.next().and_then(|n| n.variant().str()) else {
    893                             continue;
    894                         };
    895                         let tag_value = iter.next().and_then(|n| n.variant().str());
    896                         match tag_name {
    897                             "title" => title = tag_value,
    898                             "image" => image = tag_value.filter(|s| !s.is_empty()),
    899                             "summary" => summary = tag_value.filter(|s| !s.is_empty()),
    900                             _ => {}
    901                         }
    902                     }
    903 
    904                     // Calculate word count
    905                     let word_count = quoted_note.content().split_whitespace().count();
    906                     let word_count_text = format!("{} Words", word_count);
    907 
    908                     // Build article card HTML
    909                     let title_text = title.unwrap_or("Untitled article");
    910                     let title_html = html_escape::encode_text(title_text);
    911 
    912                     let image_html = image
    913                         .map(|url| {
    914                             let url_attr = html_escape::encode_double_quoted_attribute(url);
    915                             format!(
    916                                 r#"<img src="{}" class="damus-embedded-article-image" alt="" />"#,
    917                                 url_attr
    918                             )
    919                         })
    920                         .unwrap_or_default();
    921 
    922                     let summary_html = summary
    923                         .map(|s| {
    924                             let text = html_escape::encode_text(abbreviate(s, 150));
    925                             format!(
    926                                 r#"<div class="damus-embedded-article-summary">{}</div>"#,
    927                                 text
    928                             )
    929                         })
    930                         .unwrap_or_default();
    931 
    932                     let draft_class = if quoted_note.kind() == 30024 {
    933                         " damus-embedded-article-draft"
    934                     } else {
    935                         ""
    936                     };
    937 
    938                     let card_html = format!(
    939                         r#"{image}<div class="damus-embedded-article-title{draft}">{title}</div>{summary}<div class="damus-embedded-article-wordcount">{words}</div>"#,
    940                         image = image_html,
    941                         draft = draft_class,
    942                         title = title_html,
    943                         summary = summary_html,
    944                         words = word_count_text
    945                     );
    946 
    947                     (
    948                         String::new(),
    949                         false,
    950                         "",
    951                         " damus-embedded-quote-article",
    952                         Some(card_html),
    953                     )
    954                 }
    955                 // For highlights, use left border styling (no tag needed)
    956                 9802 => {
    957                     let full_content = quoted_note.content();
    958                     let content = abbreviate(full_content, 200);
    959                     let truncated = content.len() < full_content.len();
    960                     (
    961                         content.to_string(),
    962                         truncated,
    963                         "",
    964                         " damus-embedded-quote-highlight",
    965                         None,
    966                     )
    967                 }
    968                 _ => {
    969                     let full_content = quoted_note.content();
    970                     let content = abbreviate(full_content, 280);
    971                     let truncated = content.len() < full_content.len();
    972                     (content.to_string(), truncated, "", "", None)
    973                 }
    974             };
    975         let content_html = html_escape::encode_text(&content_preview).replace("\n", " ");
    976 
    977         // Build link to quoted note
    978         let link = build_quote_link(quote_ref);
    979 
    980         // For articles, use card layout; for other types, use regular content layout
    981         let body_html = if let Some(card) = article_card {
    982             card
    983         } else {
    984             let show_more = if is_truncated {
    985                 r#" <span class="damus-embedded-quote-showmore">Show more</span>"#
    986             } else {
    987                 ""
    988             };
    989             format!(
    990                 r#"<div class="damus-embedded-quote-content{class}">{content}{showmore}</div>"#,
    991                 class = content_class,
    992                 content = content_html,
    993                 showmore = show_more
    994             )
    995         };
    996 
    997         let _ = write!(
    998             quotes_html,
    999             r#"<a href="{link}" class="damus-embedded-quote{content_class}">
   1000                 <div class="damus-embedded-quote-header">
   1001                     {pfp}
   1002                     <span class="damus-embedded-quote-author">{name}</span>{username}
   1003                     <span class="damus-embedded-quote-time">· {time}</span>
   1004                     {type_indicator}
   1005                 </div>
   1006                 {reply}
   1007                 {body}
   1008             </a>"#,
   1009             link = link,
   1010             content_class = content_class,
   1011             pfp = pfp_html,
   1012             name = display_name_html,
   1013             username = username_html,
   1014             time = time_html,
   1015             type_indicator = type_indicator,
   1016             reply = reply_html,
   1017             body = body_html
   1018         );
   1019     }
   1020 
   1021     if quotes_html.is_empty() {
   1022         return String::new();
   1023     }
   1024 
   1025     format!(
   1026         r#"<div class="damus-embedded-quotes">{}</div>"#,
   1027         quotes_html
   1028     )
   1029 }
   1030 
   1031 struct Profile<'a> {
   1032     pub key: PublicKey,
   1033     pub record: Option<ProfileRecord<'a>>,
   1034 }
   1035 
   1036 impl<'a> Profile<'a> {
   1037     pub fn from_record(key: PublicKey, record: Option<ProfileRecord<'a>>) -> Self {
   1038         Self { key, record }
   1039     }
   1040 }
   1041 
   1042 fn author_display_html(profile: Option<&ProfileRecord<'_>>) -> String {
   1043     let profile_name_raw = get_profile_display_name(profile).unwrap_or("nostrich");
   1044     html_escape::encode_text(profile_name_raw).into_owned()
   1045 }
   1046 
   1047 /// Returns the @username handle markup if available, empty string otherwise.
   1048 /// Uses profile.name() (the NIP-01 "name" field) as the handle.
   1049 fn author_handle_html(profile: Option<&ProfileRecord<'_>>) -> String {
   1050     profile
   1051         .and_then(|p| p.record().profile())
   1052         .and_then(|p| p.name())
   1053         .filter(|name| !name.is_empty())
   1054         .map(|name| {
   1055             let escaped = html_escape::encode_text(name);
   1056             format!(r#"<span class="damus-note-handle">@{}</span>"#, escaped)
   1057         })
   1058         .unwrap_or_default()
   1059 }
   1060 
   1061 /// Extracts parent note info for thread layout.
   1062 /// Returns None if the note is not a reply.
   1063 struct ParentNoteInfo {
   1064     link: String,
   1065     pfp: String,
   1066     name_html: String,
   1067     time_html: String,
   1068     content_html: String,
   1069 }
   1070 
   1071 fn get_parent_note_info(
   1072     ndb: &Ndb,
   1073     txn: &Transaction,
   1074     note: &Note,
   1075     base_url: &str,
   1076 ) -> Option<ParentNoteInfo> {
   1077     use nostrdb::NoteReply;
   1078 
   1079     let reply_info = NoteReply::new(note.tags());
   1080     let parent_ref = reply_info.reply().or_else(|| reply_info.root())?;
   1081 
   1082     let link = EventId::from_byte_array(*parent_ref.id)
   1083         .to_bech32()
   1084         .map(|b| format!("{}/{}", base_url, b))
   1085         .unwrap_or_else(|_| "#".to_string());
   1086 
   1087     match ndb.get_note_by_id(txn, parent_ref.id) {
   1088         Ok(parent_note) => {
   1089             let parent_profile = ndb.get_profile_by_pubkey(txn, parent_note.pubkey()).ok();
   1090             let name = get_profile_display_name(parent_profile.as_ref()).unwrap_or("nostrich");
   1091 
   1092             let content = abbreviate(parent_note.content(), 200);
   1093             let ellipsis = if content.len() < parent_note.content().len() {
   1094                 "..."
   1095             } else {
   1096                 ""
   1097             };
   1098 
   1099             let pfp = pfp_url_attr(
   1100                 parent_profile.as_ref().and_then(|r| r.record().profile()),
   1101                 base_url,
   1102             );
   1103 
   1104             Some(ParentNoteInfo {
   1105                 link,
   1106                 pfp,
   1107                 name_html: html_escape::encode_text(name).into_owned(),
   1108                 time_html: html_escape::encode_text(&format_relative_time(
   1109                     parent_note.created_at(),
   1110                 ))
   1111                 .into_owned(),
   1112                 content_html: format!("{}{}", html_escape::encode_text(content), ellipsis),
   1113             })
   1114         }
   1115         Err(_) => {
   1116             let id_display = EventId::from_byte_array(*parent_ref.id)
   1117                 .to_bech32()
   1118                 .map(|b| abbrev_str(&b))
   1119                 .unwrap_or_else(|_| "a note".to_string());
   1120 
   1121             Some(ParentNoteInfo {
   1122                 link,
   1123                 pfp: format!("{}/img/no-profile.svg", base_url),
   1124                 name_html: html_escape::encode_text(&id_display).into_owned(),
   1125                 time_html: String::new(),
   1126                 content_html: String::new(),
   1127             })
   1128         }
   1129     }
   1130 }
   1131 
   1132 fn build_note_stats_html(ndb: &Ndb, txn: &Transaction, note: &Note, is_root: bool) -> String {
   1133     let meta = match ndb.get_note_metadata(txn, note.id()) {
   1134         Ok(m) => m,
   1135         Err(_) => return String::new(),
   1136     };
   1137 
   1138     let mut total_reactions: u32 = 0;
   1139     let mut reply_count: u32 = 0;
   1140     let mut repost_count: u16 = 0;
   1141     let mut emojis: Vec<(String, u32)> = Vec::new();
   1142 
   1143     for entry in meta {
   1144         match entry {
   1145             NoteMetadataEntryVariant::Counts(counts) => {
   1146                 total_reactions = counts.reactions();
   1147                 reply_count = if is_root {
   1148                     counts.thread_replies()
   1149                 } else {
   1150                     counts.direct_replies() as u32
   1151                 };
   1152                 repost_count = counts.reposts();
   1153             }
   1154             NoteMetadataEntryVariant::Reaction(reaction) => {
   1155                 let mut buf = [0i8; 128];
   1156                 let s = reaction.as_str(&mut buf);
   1157                 let count = reaction.count();
   1158                 if count > 0 && s != "+" && !s.is_empty() {
   1159                     emojis.push((s.to_string(), count));
   1160                 }
   1161             }
   1162             NoteMetadataEntryVariant::Unknown(_)
   1163             | NoteMetadataEntryVariant::Zap(_)
   1164             | NoteMetadataEntryVariant::ZapUnverified(_) => {}
   1165         }
   1166     }
   1167 
   1168     if total_reactions == 0 && reply_count == 0 && repost_count == 0 {
   1169         return String::new();
   1170     }
   1171 
   1172     let mut html = String::from(r#"<div class="damus-note-stats">"#);
   1173 
   1174     // Reply count
   1175     if reply_count > 0 {
   1176         html.push_str(&format!(
   1177             r#"<span class="damus-stat"><svg class="damus-stat-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"></path></svg><span class="damus-stat-count">{}</span></span>"#,
   1178             reply_count
   1179         ));
   1180     }
   1181 
   1182     // Repost count
   1183     if repost_count > 0 {
   1184         html.push_str(&format!(
   1185             r#"<span class="damus-stat"><svg class="damus-stat-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17 1l4 4-4 4"></path><path d="M3 11V9a4 4 0 0 1 4-4h14"></path><path d="M7 23l-4-4 4-4"></path><path d="M21 13v2a4 4 0 0 1-4 4H3"></path></svg><span class="damus-stat-count">{}</span></span>"#,
   1186             repost_count
   1187         ));
   1188     }
   1189 
   1190     // Reactions
   1191     if total_reactions > 0 {
   1192         emojis.sort_by(|a, b| b.1.cmp(&a.1));
   1193         let custom_total: u32 = emojis.iter().map(|(_, c)| c).sum();
   1194         let likes = total_reactions.saturating_sub(custom_total);
   1195 
   1196         if likes > 0 {
   1197             html.push_str(&format!(
   1198                 r#"<span class="damus-stat"><span class="damus-reaction-emoji">❤️</span><span class="damus-stat-count">{}</span></span>"#,
   1199                 likes
   1200             ));
   1201         }
   1202 
   1203         for (emoji, count) in emojis.iter().take(5) {
   1204             html.push_str(&format!(
   1205                 r#"<span class="damus-stat"><span class="damus-reaction-emoji">{}</span><span class="damus-stat-count">{}</span></span>"#,
   1206                 html_escape::encode_text(emoji),
   1207                 count
   1208             ));
   1209         }
   1210     }
   1211 
   1212     html.push_str("</div>");
   1213     html
   1214 }
   1215 
   1216 /// Build HTML for direct replies to a note, shown below the note content.
   1217 fn build_replies_html(app: &Notecrumbs, txn: &Transaction, note: &Note, base_url: &str) -> String {
   1218     use crate::render::DIRECT_REPLY_LIMIT;
   1219     let filter = Filter::new()
   1220         .kinds([1])
   1221         .event(note.id())
   1222         .limit(DIRECT_REPLY_LIMIT as u64)
   1223         .build();
   1224     let mut results = match app.ndb.query(txn, &[filter], DIRECT_REPLY_LIMIT) {
   1225         Ok(r) => r,
   1226         Err(_) => return String::new(),
   1227     };
   1228 
   1229     if results.is_empty() {
   1230         return String::new();
   1231     }
   1232 
   1233     // Sort by created_at ascending (oldest first)
   1234     results.sort_by_key(|r| r.note.created_at());
   1235 
   1236     // Only show direct replies, not deeper thread replies
   1237     let note_id = note.id();
   1238     let mut html = String::from(r#"<section class="damus-replies">"#);
   1239     let mut count = 0;
   1240 
   1241     for result in &results {
   1242         let reply = &result.note;
   1243 
   1244         // Filter to only direct replies (where the reply target is this note)
   1245         use nostrdb::NoteReply;
   1246         let reply_info = NoteReply::new(reply.tags());
   1247         let is_direct = reply_info
   1248             .reply()
   1249             .map(|r| r.id == note_id)
   1250             .unwrap_or_else(|| {
   1251                 // If no reply tag, check root
   1252                 reply_info.root().map(|r| r.id == note_id).unwrap_or(false)
   1253             });
   1254         if !is_direct {
   1255             continue;
   1256         }
   1257 
   1258         let profile_rec = app.ndb.get_profile_by_pubkey(txn, reply.pubkey()).ok();
   1259         let display_name = get_profile_display_name(profile_rec.as_ref()).unwrap_or("nostrich");
   1260         let display_name_html = html_escape::encode_text(display_name);
   1261 
   1262         let pfp_url = profile_rec
   1263             .as_ref()
   1264             .and_then(|r| r.record().profile())
   1265             .and_then(|p| p.picture())
   1266             .filter(|s| !s.is_empty())
   1267             .unwrap_or("/img/no-profile.svg");
   1268         let pfp_attr = html_escape::encode_double_quoted_attribute(pfp_url);
   1269 
   1270         let time_str = format_relative_time(reply.created_at());
   1271         let time_html = html_escape::encode_text(&time_str);
   1272 
   1273         let content = abbreviate(reply.content(), 300);
   1274         let ellipsis = if content.len() < reply.content().len() {
   1275             "..."
   1276         } else {
   1277             ""
   1278         };
   1279         let content_html = format!("{}{}", html_escape::encode_text(content), ellipsis);
   1280 
   1281         let reply_nevent = Nip19Event::new(EventId::from_byte_array(reply.id().to_owned()));
   1282         let reply_id = reply_nevent.to_bech32().unwrap_or_default();
   1283 
   1284         let _ = write!(
   1285             html,
   1286             r#"<a href="{base}/{reply_id}" class="damus-reply">
   1287                 <img src="{pfp}" class="damus-reply-avatar" alt="" />
   1288                 <div class="damus-reply-body">
   1289                     <div class="damus-reply-header">
   1290                         <span class="damus-reply-author">{author}</span>
   1291                         <span class="damus-reply-time">&middot; {time}</span>
   1292                     </div>
   1293                     <div class="damus-reply-content">{content}</div>
   1294                 </div>
   1295             </a>"#,
   1296             base = base_url,
   1297             reply_id = reply_id,
   1298             pfp = pfp_attr,
   1299             author = display_name_html,
   1300             time = time_html,
   1301             content = content_html,
   1302         );
   1303         count += 1;
   1304     }
   1305 
   1306     if count == 0 {
   1307         return String::new();
   1308     }
   1309 
   1310     html.push_str("</section>");
   1311     html
   1312 }
   1313 
   1314 fn build_note_content_html(
   1315     app: &Notecrumbs,
   1316     note: &Note,
   1317     txn: &Transaction,
   1318     base_url: &str,
   1319     profile: &Profile<'_>,
   1320     relays: &[RelayUrl],
   1321 ) -> String {
   1322     let mut body_buf = Vec::new();
   1323     let blocks = note
   1324         .key()
   1325         .and_then(|nk| app.ndb.get_blocks_by_key(txn, nk).ok());
   1326 
   1327     if let Some(ref blocks) = blocks {
   1328         render_note_content(&mut body_buf, note, blocks, &app.ndb, txn);
   1329     } else {
   1330         let _ = write!(body_buf, "{}", html_escape::encode_text(note.content()));
   1331     }
   1332 
   1333     let author_display = author_display_html(profile.record.as_ref());
   1334     let author_handle = author_handle_html(profile.record.as_ref());
   1335     let npub = profile.key.to_bech32().unwrap();
   1336     let note_body = String::from_utf8(body_buf).unwrap_or_default();
   1337     let pfp_attr = pfp_url_attr(
   1338         profile.record.as_ref().and_then(|r| r.record().profile()),
   1339         base_url,
   1340     );
   1341     let timestamp_attr = note.created_at().to_string();
   1342     let nevent = Nip19Event::new(EventId::from_byte_array(note.id().to_owned()))
   1343         .relays(relays.iter().cloned());
   1344     let note_id = nevent.to_bech32().unwrap();
   1345 
   1346     // Extract quote refs from q tags and inline mentions
   1347     let mut quote_refs = extract_quote_refs_from_tags(note);
   1348     if let Some(ref blocks) = blocks {
   1349         for content_ref in extract_quote_refs_from_content(note, blocks) {
   1350             // Deduplicate by event_id or article_addr
   1351             let is_dup = quote_refs
   1352                 .iter()
   1353                 .any(|existing| match (existing, &content_ref) {
   1354                     (QuoteRef::Event { id: a, .. }, QuoteRef::Event { id: b, .. }) => a == b,
   1355                     (QuoteRef::Article { addr: a, .. }, QuoteRef::Article { addr: b, .. }) => {
   1356                         a == b
   1357                     }
   1358                     _ => false,
   1359                 });
   1360             if !is_dup {
   1361                 quote_refs.push(content_ref);
   1362             }
   1363         }
   1364     }
   1365     let quotes_html = build_embedded_quotes_html(&app.ndb, txn, &quote_refs);
   1366     let parent_info = get_parent_note_info(&app.ndb, txn, note, base_url);
   1367     let is_root = parent_info.is_none();
   1368     let stats_html = build_note_stats_html(&app.ndb, txn, note, is_root);
   1369     let replies_html = build_replies_html(app, txn, note, base_url);
   1370 
   1371     match parent_info {
   1372         Some(parent) => {
   1373             // Thread layout: one avatar column spanning both notes with a line between
   1374             let time_html = if parent.time_html.is_empty() {
   1375                 String::new()
   1376             } else {
   1377                 format!(
   1378                     r#"<span class="damus-thread-parent-time">&middot; {}</span>"#,
   1379                     parent.time_html
   1380                 )
   1381             };
   1382             let content_html = if parent.content_html.is_empty() {
   1383                 String::new()
   1384             } else {
   1385                 format!(
   1386                     r#"<div class="damus-thread-parent-text">{}</div>"#,
   1387                     parent.content_html
   1388                 )
   1389             };
   1390 
   1391             format!(
   1392                 r#"<article class="damus-card damus-note damus-thread">
   1393                     <div class="damus-thread-grid">
   1394                         <div class="damus-thread-line"></div>
   1395                         <a href="{parent_link}" class="damus-thread-pfp damus-thread-pfp-parent">
   1396                             <img src="{parent_pfp}" class="damus-thread-avatar" alt="" />
   1397                         </a>
   1398                         <a href="{parent_link}" class="damus-thread-parent-content">
   1399                             <div class="damus-thread-parent-meta">
   1400                                 <span class="damus-thread-parent-author">{parent_name}</span>
   1401                                 {time}
   1402                             </div>
   1403                             {content}
   1404                         </a>
   1405                         <a href="{base}/{npub}" class="damus-thread-pfp damus-thread-pfp-reply">
   1406                             <img src="{pfp}" class="damus-thread-avatar" alt="{author} profile picture" />
   1407                         </a>
   1408                         <div class="damus-thread-reply-content">
   1409                             <div class="damus-thread-reply-meta">
   1410                                 <a href="{base}/{npub}">
   1411                                     <span class="damus-note-author">{author}</span>
   1412                                     {handle}
   1413                                 </a>
   1414                                 <a href="{base}/{note_id}">
   1415                                     <time class="damus-note-time" data-timestamp="{ts}" datetime="{ts}" title="{ts}">{ts}</time>
   1416                                 </a>
   1417                             </div>
   1418                             <div class="damus-note-body">{body}</div>
   1419                             {quotes}
   1420                             {stats}
   1421                         </div>
   1422                     </div>
   1423                 </article>
   1424                 {replies}"#,
   1425                 parent_link = parent.link,
   1426                 parent_pfp = parent.pfp,
   1427                 parent_name = parent.name_html,
   1428                 time = time_html,
   1429                 content = content_html,
   1430                 base = base_url,
   1431                 npub = npub,
   1432                 pfp = pfp_attr,
   1433                 author = author_display,
   1434                 handle = author_handle,
   1435                 note_id = note_id,
   1436                 ts = timestamp_attr,
   1437                 body = note_body,
   1438                 quotes = quotes_html,
   1439                 stats = stats_html,
   1440                 replies = replies_html,
   1441             )
   1442         }
   1443         None => {
   1444             // Standard layout: no thread context
   1445             format!(
   1446                 r#"<article class="damus-card damus-note">
   1447                     <header class="damus-note-header">
   1448                        <a href="{base}/{npub}">
   1449                          <img src="{pfp}" class="damus-note-avatar" alt="{author} profile picture" />
   1450                        </a>
   1451                        <div>
   1452                          <a href="{base}/{npub}">
   1453                            <div class="damus-note-author">{author}</div>
   1454                            {handle}
   1455                          </a>
   1456                          <a href="{base}/{note_id}">
   1457                            <time class="damus-note-time" data-timestamp="{ts}" datetime="{ts}" title="{ts}">{ts}</time>
   1458                          </a>
   1459                        </div>
   1460                     </header>
   1461                     <div class="damus-note-body">{body}</div>
   1462                     {quotes}
   1463                     {stats}
   1464                 </article>
   1465                 {replies}"#,
   1466                 base = base_url,
   1467                 pfp = pfp_attr,
   1468                 author = author_display,
   1469                 handle = author_handle,
   1470                 ts = timestamp_attr,
   1471                 body = note_body,
   1472                 quotes = quotes_html,
   1473                 stats = stats_html,
   1474                 replies = replies_html,
   1475             )
   1476         }
   1477     }
   1478 }
   1479 
   1480 #[allow(clippy::too_many_arguments)]
   1481 fn build_article_content_html(
   1482     profile: &Profile<'_>,
   1483     timestamp_value: u64,
   1484     article_title_html: &str,
   1485     hero_image: Option<&str>,
   1486     summary_html: Option<&str>,
   1487     article_body_html: &str,
   1488     topics: &[String],
   1489     is_draft: bool,
   1490     base_url: &str,
   1491 ) -> String {
   1492     let pfp_attr = pfp_url_attr(
   1493         profile.record.as_ref().and_then(|r| r.record().profile()),
   1494         base_url,
   1495     );
   1496     let timestamp_attr = timestamp_value.to_string();
   1497     let author_display = author_display_html(profile.record.as_ref());
   1498     let author_handle = author_handle_html(profile.record.as_ref());
   1499 
   1500     let hero_markup = hero_image
   1501         .filter(|url| !url.is_empty())
   1502         .map(|url| {
   1503             let url_attr = html_escape::encode_double_quoted_attribute(url);
   1504             format!(
   1505                 r#"<img src="{url}" class="damus-article-hero" alt="Article header image" />"#,
   1506                 url = url_attr
   1507             )
   1508         })
   1509         .unwrap_or_default();
   1510 
   1511     let summary_markup = summary_html
   1512         .map(|summary| format!(r#"<p class="damus-article-summary">{}</p>"#, summary))
   1513         .unwrap_or_default();
   1514 
   1515     let mut topics_markup = String::new();
   1516     if !topics.is_empty() {
   1517         topics_markup.push_str(r#"<div class="damus-article-topics">"#);
   1518         for topic in topics {
   1519             if topic.is_empty() {
   1520                 continue;
   1521             }
   1522             let topic_text = html_escape::encode_text(topic);
   1523             let _ = write!(
   1524                 topics_markup,
   1525                 r#"<span class="damus-article-topic">#{}</span>"#,
   1526                 topic_text
   1527             );
   1528         }
   1529         topics_markup.push_str("</div>");
   1530     }
   1531 
   1532     // Draft badge for unpublished articles (kind:30024)
   1533     let draft_markup = if is_draft {
   1534         r#"<span class="damus-article-draft">DRAFT</span>"#
   1535     } else {
   1536         ""
   1537     };
   1538 
   1539     format!(
   1540         r#"<article class="damus-card damus-note">
   1541             <header class="damus-note-header">
   1542                <img src="{pfp}" class="damus-note-avatar" alt="{author} profile picture" />
   1543                <div>
   1544                  <div class="damus-note-author">{author}</div>
   1545                  {handle}
   1546                  <time class="damus-note-time" data-timestamp="{ts}" datetime="{ts}" title="{ts}">{ts}</time>
   1547                </div>
   1548             </header>
   1549             <h1 class="damus-article-title">{title}{draft}</h1>
   1550             {hero}
   1551             {summary}
   1552             {topics}
   1553             <div class="damus-note-body">{body}</div>
   1554         </article>"#,
   1555         pfp = pfp_attr,
   1556         author = author_display,
   1557         handle = author_handle,
   1558         ts = timestamp_attr,
   1559         title = article_title_html,
   1560         draft = draft_markup,
   1561         hero = hero_markup,
   1562         summary = summary_markup,
   1563         topics = topics_markup,
   1564         body = article_body_html
   1565     )
   1566 }
   1567 
   1568 /// Builds HTML for a NIP-84 highlight (kind:9802).
   1569 fn build_highlight_content_html(
   1570     profile: &Profile<'_>,
   1571     base_url: &str,
   1572     timestamp_value: u64,
   1573     highlight_text_html: &str,
   1574     context_html: Option<&str>,
   1575     comment_html: Option<&str>,
   1576     source_markup: &str,
   1577 ) -> String {
   1578     let author_display = author_display_html(profile.record.as_ref());
   1579     let author_handle = author_handle_html(profile.record.as_ref());
   1580     let pfp_attr = pfp_url_attr(
   1581         profile.record.as_ref().and_then(|r| r.record().profile()),
   1582         base_url,
   1583     );
   1584     let timestamp_attr = timestamp_value.to_string();
   1585 
   1586     let context_markup = context_html
   1587         .filter(|ctx| !ctx.is_empty())
   1588         .map(|ctx| format!(r#"<div class="damus-highlight-context">…{ctx}…</div>"#))
   1589         .unwrap_or_default();
   1590 
   1591     let comment_markup = comment_html
   1592         .filter(|c| !c.is_empty())
   1593         .map(|c| format!(r#"<div class="damus-highlight-comment">{c}</div>"#))
   1594         .unwrap_or_default();
   1595 
   1596     format!(
   1597         r#"<article class="damus-card damus-highlight">
   1598             <header class="damus-note-header">
   1599                <img src="{pfp}" class="damus-note-avatar" alt="{author} profile picture" />
   1600                <div>
   1601                  <div class="damus-note-author">{author}</div>
   1602                  {handle}
   1603                  <time class="damus-note-time" data-timestamp="{ts}" datetime="{ts}" title="{ts}">{ts}</time>
   1604                </div>
   1605             </header>
   1606             {comment}
   1607             <blockquote class="damus-highlight-text">{highlight}</blockquote>
   1608             {context}
   1609             {source}
   1610         </article>"#,
   1611         pfp = pfp_attr,
   1612         author = author_display,
   1613         handle = author_handle,
   1614         ts = timestamp_attr,
   1615         comment = comment_markup,
   1616         highlight = highlight_text_html,
   1617         context = context_markup,
   1618         source = source_markup
   1619     )
   1620 }
   1621 
   1622 /// Builds source attribution markup for a highlight.
   1623 fn build_highlight_source_markup(ndb: &Ndb, txn: &Transaction, meta: &HighlightMetadata) -> String {
   1624     // Priority: article > note > URL
   1625 
   1626     // Case 1: Source is a nostr article (a tag)
   1627     if let Some(addr) = &meta.source_article_addr {
   1628         if let Some((note_key, title)) = lookup_article_by_addr(ndb, txn, addr) {
   1629             let author_name = ndb.get_note_by_key(txn, note_key).ok().and_then(|note| {
   1630                 get_profile_display_name(
   1631                     ndb.get_profile_by_pubkey(txn, note.pubkey()).ok().as_ref(),
   1632                 )
   1633                 .map(|s| s.to_owned())
   1634             });
   1635 
   1636             return build_article_source_link(addr, title.as_deref(), author_name.as_deref());
   1637         }
   1638     }
   1639 
   1640     // Case 2: Source is a nostr note (e tag)
   1641     if let Some(event_id) = &meta.source_event_id {
   1642         return build_note_source_link(event_id);
   1643     }
   1644 
   1645     // Case 3: Source is a web URL (r tag)
   1646     if let Some(url) = &meta.source_url {
   1647         return build_url_source_link(url);
   1648     }
   1649 
   1650     String::new()
   1651 }
   1652 
   1653 /// Builds source link for an article reference.
   1654 fn build_article_source_link(addr: &str, title: Option<&str>, author: Option<&str>) -> String {
   1655     use nostr_sdk::prelude::{Coordinate, Kind};
   1656 
   1657     let parts: Vec<&str> = addr.splitn(3, ':').collect();
   1658     if parts.len() < 3 {
   1659         return String::new();
   1660     }
   1661 
   1662     let Ok(kind) = parts[0].parse::<u16>() else {
   1663         return String::new();
   1664     };
   1665     let Ok(pubkey) = PublicKey::from_hex(parts[1]) else {
   1666         return String::new();
   1667     };
   1668 
   1669     let coordinate = Coordinate::new(Kind::from(kind), pubkey).identifier(parts[2]);
   1670     let Ok(naddr) = coordinate.to_bech32() else {
   1671         return String::new();
   1672     };
   1673 
   1674     let display_text = match (title, author) {
   1675         (Some(t), Some(a)) => format!(
   1676             "{} by {}",
   1677             html_escape::encode_text(t),
   1678             html_escape::encode_text(a)
   1679         ),
   1680         (Some(t), None) => html_escape::encode_text(t).into_owned(),
   1681         (None, Some(a)) => format!("Article by {}", html_escape::encode_text(a)),
   1682         (None, None) => abbrev_str(&naddr).to_string(),
   1683     };
   1684 
   1685     let href_raw = format!("/{naddr}");
   1686     let href = html_escape::encode_double_quoted_attribute(&href_raw);
   1687     format!(
   1688         r#"<div class="damus-highlight-source"><span class="damus-highlight-source-label">From article:</span> <a href="{href}">{display}</a></div>"#,
   1689         href = href,
   1690         display = display_text
   1691     )
   1692 }
   1693 
   1694 /// Builds source link for a note reference.
   1695 fn build_note_source_link(event_id: &[u8; 32]) -> String {
   1696     use nostr_sdk::prelude::EventId;
   1697 
   1698     let Ok(id) = EventId::from_slice(event_id) else {
   1699         return String::new();
   1700     };
   1701     let nevent = id.to_bech32().expect("infallible");
   1702 
   1703     let href_raw = format!("/{nevent}");
   1704     let href = html_escape::encode_double_quoted_attribute(&href_raw);
   1705     format!(
   1706         r#"<div class="damus-highlight-source"><span class="damus-highlight-source-label">From note:</span> <a href="{href}">{abbrev}</a></div>"#,
   1707         href = href,
   1708         abbrev = abbrev_str(&nevent)
   1709     )
   1710 }
   1711 
   1712 /// Builds source link for a web URL.
   1713 fn build_url_source_link(url: &str) -> String {
   1714     let domain = url
   1715         .trim_start_matches("https://")
   1716         .trim_start_matches("http://")
   1717         .split('/')
   1718         .next()
   1719         .unwrap_or(url);
   1720 
   1721     let href = html_escape::encode_double_quoted_attribute(url);
   1722     let domain_html = html_escape::encode_text(domain);
   1723 
   1724     format!(
   1725         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>"#,
   1726         href = href,
   1727         domain = domain_html
   1728     )
   1729 }
   1730 
   1731 const LOCAL_TIME_SCRIPT: &str = r#"
   1732         <script>
   1733           (function() {
   1734             'use strict';
   1735             if (!('Intl' in window) || typeof Intl.DateTimeFormat !== 'function') {
   1736               return;
   1737             }
   1738             var nodes = document.querySelectorAll('[data-timestamp]');
   1739             var displayFormatter = new Intl.DateTimeFormat(undefined, {
   1740               hour: 'numeric',
   1741               minute: '2-digit',
   1742               timeZoneName: 'short'
   1743             });
   1744             var titleFormatter = new Intl.DateTimeFormat(undefined, {
   1745               year: 'numeric',
   1746               month: 'short',
   1747               day: 'numeric',
   1748               hour: 'numeric',
   1749               minute: '2-digit',
   1750               second: '2-digit',
   1751               timeZoneName: 'long'
   1752             });
   1753             var monthNames = [
   1754               'Jan.',
   1755               'Feb.',
   1756               'Mar.',
   1757               'Apr.',
   1758               'May',
   1759               'Jun.',
   1760               'Jul.',
   1761               'Aug.',
   1762               'Sep.',
   1763               'Oct.',
   1764               'Nov.',
   1765               'Dec.'
   1766             ];
   1767             Array.prototype.forEach.call(nodes, function(node) {
   1768               var raw = node.getAttribute('data-timestamp');
   1769               if (!raw) {
   1770                 return;
   1771               }
   1772               var timestamp = Number(raw);
   1773               if (!isFinite(timestamp)) {
   1774                 return;
   1775               }
   1776               var date = new Date(timestamp * 1000);
   1777               if (isNaN(date.getTime())) {
   1778                 return;
   1779               }
   1780               var shortText = displayFormatter.format(date);
   1781               var month = monthNames[date.getMonth()] || '';
   1782               var day = String(date.getDate());
   1783               var formattedDate = month
   1784                 ? month + ' ' + day + ', ' + date.getFullYear()
   1785                 : day + ', ' + date.getFullYear();
   1786               var combined = formattedDate + ' · ' + shortText;
   1787               node.textContent = combined;
   1788               node.setAttribute('title', titleFormatter.format(date));
   1789               node.setAttribute('datetime', date.toISOString());
   1790             });
   1791           }());
   1792         </script>
   1793 "#;
   1794 
   1795 pub const DAMUS_PLATFORM_SCRIPT: &str = r#"
   1796         <script>
   1797           (function() {
   1798             'use strict';
   1799             var PLATFORM_MAP = {
   1800               ios: {
   1801                 url: 'https://apps.apple.com/us/app/damus/id1628663131',
   1802                 target: '_blank',
   1803                 rel: 'noopener noreferrer'
   1804               },
   1805               android: {
   1806                 url: 'https://damus.io/android/',
   1807                 target: '_blank',
   1808                 rel: 'noopener noreferrer'
   1809               },
   1810               desktop: {
   1811                 url: 'https://damus.io/notedeck/',
   1812                 target: '_blank',
   1813                 rel: 'noopener noreferrer'
   1814               }
   1815             };
   1816 
   1817             var PLATFORM_LABELS = {
   1818               ios: 'iOS',
   1819               android: 'Android',
   1820               desktop: 'Desktop'
   1821             };
   1822 
   1823             function detectPlatform() {
   1824               var ua = navigator.userAgent || '';
   1825               var platform = navigator.platform || '';
   1826               if (/android/i.test(ua)) {
   1827                 return 'android';
   1828               }
   1829               if (/iPad|iPhone|iPod/.test(ua) || (/Macintosh/.test(ua) && 'ontouchend' in document)) {
   1830                 return 'ios';
   1831               }
   1832               if (/Mac/.test(platform) || /Win/.test(platform) || /Linux/.test(platform)) {
   1833                 return 'desktop';
   1834               }
   1835               return null;
   1836             }
   1837 
   1838             var platform = detectPlatform();
   1839             var mapping = platform && PLATFORM_MAP[platform];
   1840             var anchors = document.querySelectorAll('[data-damus-cta]');
   1841 
   1842             Array.prototype.forEach.call(anchors, function(anchor) {
   1843               var fallbackUrl = anchor.getAttribute('data-default-url') || anchor.getAttribute('href') || '';
   1844               var fallbackTarget = anchor.getAttribute('data-default-target') || anchor.getAttribute('target') || '';
   1845               var selected = mapping || { url: fallbackUrl, target: fallbackTarget };
   1846 
   1847               if (selected.url) {
   1848                 anchor.setAttribute('href', selected.url);
   1849               }
   1850 
   1851               if (selected.target) {
   1852                 anchor.setAttribute('target', selected.target);
   1853               } else {
   1854                 anchor.removeAttribute('target');
   1855               }
   1856 
   1857               if (mapping && mapping.rel) {
   1858                 anchor.setAttribute('rel', mapping.rel);
   1859               } else if (!selected.target) {
   1860                 anchor.removeAttribute('rel');
   1861               }
   1862 
   1863               if (platform && mapping) {
   1864                 anchor.setAttribute('data-damus-platform', platform);
   1865                 var label = PLATFORM_LABELS[platform] || platform;
   1866                 anchor.setAttribute('aria-label', 'Open in Damus (' + label + ')');
   1867               }
   1868             });
   1869           }());
   1870         </script>
   1871 "#;
   1872 
   1873 fn pfp_url_attr(profile: Option<NdbProfile<'_>>, base_url: &str) -> String {
   1874     let pfp_url_raw = profile
   1875         .and_then(|profile| profile.picture())
   1876         .map(str::trim)
   1877         .filter(|url| !url.is_empty())
   1878         .map(|s| s.to_string())
   1879         .unwrap_or_else(|| format!("{base_url}/img/no-profile.svg"));
   1880     html_escape::encode_double_quoted_attribute(&pfp_url_raw).into_owned()
   1881 }
   1882 
   1883 fn profile_not_found() -> Result<http::Response<http_body_util::Full<bytes::Bytes>>, http::Error> {
   1884     let mut data = Vec::new();
   1885     let _ = write!(data, "Profile not found :(");
   1886     Response::builder()
   1887         .header(header::CONTENT_TYPE, "text/html")
   1888         .status(StatusCode::NOT_FOUND)
   1889         .body(Full::new(Bytes::from(data)))
   1890 }
   1891 
   1892 pub fn serve_profile_html(
   1893     app: &Notecrumbs,
   1894     nip: &Nip19,
   1895     profile_rd: Option<&ProfileRenderData>,
   1896     _r: Request<hyper::body::Incoming>,
   1897 ) -> Result<Response<Full<Bytes>>, Error> {
   1898     let profile_key = match profile_rd {
   1899         None | Some(ProfileRenderData::Missing(_)) => {
   1900             return Ok(profile_not_found()?);
   1901         }
   1902 
   1903         Some(ProfileRenderData::Profile(profile_key)) => *profile_key,
   1904     };
   1905 
   1906     let txn = Transaction::new(&app.ndb)?;
   1907 
   1908     let profile_rec = match app.ndb.get_profile_by_key(&txn, profile_key) {
   1909         Ok(profile_rec) => profile_rec,
   1910         Err(_) => {
   1911             return Ok(profile_not_found()?);
   1912         }
   1913     };
   1914 
   1915     let profile_record = profile_rec.record();
   1916     let profile_data = profile_record.profile();
   1917 
   1918     let name_fallback = "nostrich";
   1919     let username_raw = profile_data
   1920         .and_then(|profile| profile.name())
   1921         .map(str::trim)
   1922         .filter(|name| !name.is_empty())
   1923         .unwrap_or(name_fallback);
   1924     let display_name_raw = profile_data
   1925         .and_then(|profile| profile.display_name())
   1926         .map(str::trim)
   1927         .filter(|display| !display.is_empty())
   1928         .unwrap_or(username_raw);
   1929     let about_raw = profile_data
   1930         .and_then(|profile| profile.about())
   1931         .map(str::trim)
   1932         .filter(|about| !about.is_empty())
   1933         .unwrap_or("");
   1934     let base_url = get_base_url();
   1935 
   1936     let display_name_html = html_escape::encode_text(display_name_raw).into_owned();
   1937     let username_html = html_escape::encode_text(username_raw).into_owned();
   1938 
   1939     let pfp_url_raw = profile_data
   1940         .and_then(|profile| profile.picture())
   1941         .map(str::trim)
   1942         .filter(|url| !url.is_empty())
   1943         .unwrap_or("https://damus.io/img/no-profile.svg");
   1944     let pfp_attr = html_escape::encode_double_quoted_attribute(pfp_url_raw).into_owned();
   1945 
   1946     let mut relay_entries = Vec::new();
   1947     let profile_note_key = NoteKey::new(profile_record.note_key());
   1948 
   1949     let Ok(profile_note) = app.ndb.get_note_by_key(&txn, profile_note_key) else {
   1950         let mut data = Vec::new();
   1951         let _ = write!(data, "Profile not found :(");
   1952         return Ok(Response::builder()
   1953             .header(header::CONTENT_TYPE, "text/html")
   1954             .status(StatusCode::NOT_FOUND)
   1955             .body(Full::new(Bytes::from(data)))?);
   1956     };
   1957 
   1958     /* relays */
   1959     if let Ok(results) = app.ndb.query(
   1960         &txn,
   1961         &[Filter::new()
   1962             .authors([profile_note.pubkey()])
   1963             .kinds([10002])
   1964             .limit(10)
   1965             .build()],
   1966         10,
   1967     ) {
   1968         let mut latest_event = None;
   1969         let mut latest_created_at = 0u64;
   1970 
   1971         for result in &results {
   1972             let created_at = result.note.created_at();
   1973             if created_at >= latest_created_at {
   1974                 latest_created_at = created_at;
   1975                 latest_event = Some(&result.note);
   1976             }
   1977         }
   1978 
   1979         if let Some(relay_note) = latest_event {
   1980             for tag in relay_note.tags() {
   1981                 let mut iter = tag.into_iter();
   1982                 let Some(tag_kind) = iter.next().and_then(|item| item.variant().str()) else {
   1983                     continue;
   1984                 };
   1985                 if tag_kind != "r" {
   1986                     continue;
   1987                 }
   1988 
   1989                 let Some(url) = iter.next().and_then(|item| item.variant().str()) else {
   1990                     continue;
   1991                 };
   1992                 let marker = iter.next().and_then(|item| item.variant().str());
   1993                 merge_relay_entry(&mut relay_entries, url, marker);
   1994             }
   1995         }
   1996     }
   1997 
   1998     let mut meta_rows = String::new();
   1999 
   2000     let profile_bech32 = nip.to_bech32().unwrap_or_default();
   2001     let npub_href = format!("nostr:{profile_bech32}");
   2002     let npub_href_attr = html_escape::encode_double_quoted_attribute(&npub_href).into_owned();
   2003     let _ = write!(
   2004         meta_rows,
   2005         r#"<div class="damus-profile-meta-row damus-profile-meta-row--npub"><span class="damus-meta-icon" aria-hidden="true">{icon}</span><a href="{href}">{value}</a><span class="damus-sr-only">npub</span></div>"#,
   2006         icon = ICON_KEY_CIRCLE,
   2007         href = npub_href_attr,
   2008         value = profile_bech32
   2009     );
   2010 
   2011     if let Some(nip05) = profile_data
   2012         .and_then(|profile| profile.nip05())
   2013         .map(str::trim)
   2014         .filter(|value| !value.is_empty())
   2015     {
   2016         let nip05_html = html_escape::encode_text(nip05).into_owned();
   2017         let _ = write!(
   2018             meta_rows,
   2019             r#"<div class="damus-profile-meta-row damus-profile-meta-row--nip05"><span class="damus-meta-icon" aria-hidden="true">{icon}</span><span>{value}</span><span class="damus-sr-only">nip05</span></div>"#,
   2020             icon = ICON_CONTACT_CIRCLE,
   2021             value = nip05_html
   2022         );
   2023     }
   2024 
   2025     if let Some(website) = profile_data
   2026         .and_then(|profile| profile.website())
   2027         .map(str::trim)
   2028         .filter(|value| !value.is_empty())
   2029     {
   2030         let href = if website.starts_with("http://") || website.starts_with("https://") {
   2031             website.to_owned()
   2032         } else {
   2033             format!("https://{website}")
   2034         };
   2035         let href_attr = html_escape::encode_double_quoted_attribute(&href).into_owned();
   2036         let text_html = html_escape::encode_text(website).into_owned();
   2037         let _ = write!(
   2038             meta_rows,
   2039             r#"<div class="damus-profile-meta-row damus-profile-meta-row--website"><span class="damus-meta-icon" aria-hidden="true">{icon}</span><a href="{href}" target="_blank" rel="noopener noreferrer">{value}</a><span class="damus-sr-only">website</span></div>"#,
   2040             icon = ICON_LINK_CIRCLE,
   2041             href = href_attr,
   2042             value = text_html
   2043         );
   2044     }
   2045 
   2046     if let Some(lud16) = profile_data
   2047         .and_then(|profile| profile.lud16())
   2048         .map(str::trim)
   2049         .filter(|value| !value.is_empty())
   2050     {
   2051         let lud16_html = html_escape::encode_text(lud16).into_owned();
   2052         let _ = write!(
   2053             meta_rows,
   2054             r#"<div class="damus-profile-meta-row damus-profile-meta-row--lnurl"><span class="damus-meta-icon" aria-hidden="true">{icon}</span><span>{value}</span><span class="damus-sr-only">lnurl</span></div>"#,
   2055             icon = ICON_BITCOIN,
   2056             value = lud16_html
   2057         );
   2058     }
   2059 
   2060     let profile_meta_html = if meta_rows.is_empty() {
   2061         String::new()
   2062     } else {
   2063         format!(
   2064             r#"<div class="damus-profile-meta">{rows}</div>"#,
   2065             rows = meta_rows
   2066         )
   2067     };
   2068 
   2069     let profile = Profile::from_record(
   2070         PublicKey::from_slice(profile_note.pubkey()).unwrap(),
   2071         Some(profile_rec),
   2072     );
   2073     let mut recent_notes_html = String::new();
   2074 
   2075     let notes_filter = Filter::new()
   2076         .authors([profile_note.pubkey()])
   2077         .kinds([1])
   2078         .limit(PROFILE_FEED_RECENT_LIMIT as u64)
   2079         .build();
   2080 
   2081     match app
   2082         .ndb
   2083         .query(&txn, &[notes_filter], PROFILE_FEED_RECENT_LIMIT as i32)
   2084     {
   2085         Ok(mut note_results) => {
   2086             if note_results.is_empty() {
   2087                 recent_notes_html.push_str(
   2088                     r#"<section class="damus-section"><h2 class="damus-section-title">Recent Notes</h2><div class="damus-card"><p class="damus-supporting muted">No recent notes yet.</p></div></section>"#,
   2089                 );
   2090             } else {
   2091                 note_results.sort_by_key(|result| result.note.created_at());
   2092                 note_results.reverse();
   2093                 recent_notes_html
   2094                     .push_str(r#"<section class="damus-section"><h2 class="damus-section-title">Recent Notes</h2>"#);
   2095                 for result in note_results.into_iter().take(PROFILE_FEED_RECENT_LIMIT) {
   2096                     let note_html = build_note_content_html(
   2097                         app,
   2098                         &result.note,
   2099                         &txn,
   2100                         &base_url,
   2101                         &profile,
   2102                         &crate::nip19::nip19_relays(nip),
   2103                     );
   2104                     recent_notes_html.push_str(&note_html);
   2105                 }
   2106                 recent_notes_html.push_str("</section>");
   2107             }
   2108         }
   2109         Err(err) => {
   2110             warn!("failed to query recent notes: {err}");
   2111         }
   2112     }
   2113 
   2114     let relay_section_html = if relay_entries.is_empty() {
   2115         String::from(r#"<div class="damus-relays muted">No relay list published yet.</div>"#)
   2116     } else {
   2117         let relay_count = relay_entries.len();
   2118         let relay_count_label = format!("Relays ({relay_count})");
   2119         let relay_count_html = html_escape::encode_text(&relay_count_label).into_owned();
   2120 
   2121         let mut list_markup = String::new();
   2122         for entry in &relay_entries {
   2123             let url_text = html_escape::encode_text(&entry.url).into_owned();
   2124             let role_text = match (entry.read, entry.write) {
   2125                 (true, true) => "read & write",
   2126                 (true, false) => "read",
   2127                 (false, true) => "write",
   2128                 _ => "unspecified",
   2129             };
   2130             let role_html = html_escape::encode_text(role_text).into_owned();
   2131             let _ = write!(
   2132                 list_markup,
   2133                 r#"<li>{url}<span class="damus-relay-role"> – {role}</span></li>"#,
   2134                 url = url_text,
   2135                 role = role_html
   2136             );
   2137         }
   2138 
   2139         format!(
   2140             r#"<details class="damus-relays">
   2141                 <summary>{count}</summary>
   2142                 <ul class="damus-relay-list">
   2143                     {items}
   2144                 </ul>
   2145             </details>"#,
   2146             count = relay_count_html,
   2147             items = list_markup
   2148         )
   2149     };
   2150 
   2151     let base_url = get_base_url();
   2152     let bech32 = nip.to_bech32().unwrap_or_default();
   2153     let canonical_url = format!("{base_url}/{bech32}");
   2154 
   2155     let fallback_image_url = format!("{base_url}/{bech32}.png");
   2156     let og_image = if pfp_url_raw.is_empty() {
   2157         fallback_image_url.clone()
   2158     } else {
   2159         pfp_url_raw.to_string()
   2160     };
   2161 
   2162     let mut og_description_raw = if about_raw.is_empty() {
   2163         format!("{} on nostr", display_name_raw)
   2164     } else {
   2165         about_raw.to_string()
   2166     };
   2167 
   2168     if og_description_raw.is_empty() {
   2169         og_description_raw = display_name_raw.to_string();
   2170     }
   2171 
   2172     let og_image_url_raw = if og_image.trim().is_empty() {
   2173         fallback_image_url
   2174     } else {
   2175         og_image.clone()
   2176     };
   2177 
   2178     let page_title_text = format!("{} on nostr", display_name_raw);
   2179     let og_image_alt_text = format!("{}: {}", display_name_raw, og_description_raw);
   2180 
   2181     let page_title_html = html_escape::encode_text(&page_title_text).into_owned();
   2182     let og_description_attr =
   2183         html_escape::encode_double_quoted_attribute(&og_description_raw).into_owned();
   2184     let og_image_attr = html_escape::encode_double_quoted_attribute(&og_image_url_raw).into_owned();
   2185     let og_title_attr = html_escape::encode_double_quoted_attribute(&page_title_text).into_owned();
   2186     let og_image_alt_attr =
   2187         html_escape::encode_double_quoted_attribute(&og_image_alt_text).into_owned();
   2188     let canonical_url_attr =
   2189         html_escape::encode_double_quoted_attribute(&canonical_url).into_owned();
   2190 
   2191     let about_html = if about_raw.is_empty() {
   2192         String::new()
   2193     } else {
   2194         let about_text = html_escape::encode_text(about_raw)
   2195             .into_owned()
   2196             .replace("\n", "<br/>");
   2197         format!(r#"<p class="damus-profile-about">{}</p>"#, about_text)
   2198     };
   2199 
   2200     let main_content_html = format!(
   2201         r#"<article class="damus-card damus-profile-card">
   2202               <header class="damus-profile-header">
   2203                 <img src="{pfp}" alt="{display} profile picture" class="damus-note-avatar" />
   2204                 <div class="damus-profile-names">
   2205                   <div class="damus-note-author">{display}</div>
   2206                   <div class="damus-profile-handle">@{username}</div>
   2207                 </div>
   2208               </header>
   2209               {about}
   2210               {meta}
   2211               {relays}
   2212             </article>
   2213             {recent_notes}"#,
   2214         pfp = pfp_attr.as_str(),
   2215         display = display_name_html.as_str(),
   2216         username = username_html,
   2217         about = about_html,
   2218         meta = profile_meta_html,
   2219         relays = relay_section_html,
   2220         recent_notes = recent_notes_html,
   2221     );
   2222 
   2223     let mut data = Vec::new();
   2224     let scripts = format!("{LOCAL_TIME_SCRIPT}{DAMUS_PLATFORM_SCRIPT}");
   2225 
   2226     let page = format!(
   2227         "<!DOCTYPE html>\n\
   2228 <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=7\" 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",
   2229         page_title = page_title_html,
   2230         og_description = og_description_attr,
   2231         og_image = og_image_attr,
   2232         og_image_alt = og_image_alt_attr,
   2233         og_title = og_title_attr,
   2234         canonical_url = canonical_url_attr,
   2235         main_content = main_content_html,
   2236         bech32 = bech32,
   2237         scripts = scripts,
   2238     );
   2239 
   2240     let _ = data.write(page.as_bytes());
   2241 
   2242     Ok(Response::builder()
   2243         .header(header::CONTENT_TYPE, "text/html")
   2244         .status(StatusCode::OK)
   2245         .body(Full::new(Bytes::from(data)))?)
   2246 }
   2247 
   2248 pub fn serve_homepage(_r: Request<hyper::body::Incoming>) -> Result<Response<Full<Bytes>>, Error> {
   2249     let base_url = get_base_url();
   2250 
   2251     let page_title = "Damus — notecrumbs frontend";
   2252     let description =
   2253         "Explore Nostr profiles and notes with the Damus-inspired notecrumbs frontend.";
   2254     let og_image_url = format!("{}/assets/default_pfp.jpg", base_url);
   2255 
   2256     let canonical_url_attr = html_escape::encode_double_quoted_attribute(&base_url).into_owned();
   2257     let description_attr = html_escape::encode_double_quoted_attribute(description).into_owned();
   2258     let og_image_attr = html_escape::encode_double_quoted_attribute(&og_image_url).into_owned();
   2259     let og_title_attr = html_escape::encode_double_quoted_attribute(page_title).into_owned();
   2260     let page_title_html = html_escape::encode_text(page_title).into_owned();
   2261 
   2262     let profile_example = format!("{}/npub1example", base_url);
   2263     let note_example = format!("{}/note1example", base_url);
   2264     let profile_example_html = html_escape::encode_text(&profile_example).into_owned();
   2265     let note_example_html = html_escape::encode_text(&note_example).into_owned();
   2266     let png_example_html = html_escape::encode_text(&format!("{}.png", note_example)).into_owned();
   2267     let json_example_html =
   2268         html_escape::encode_text(&format!("{}.json", profile_example)).into_owned();
   2269 
   2270     let mut data = Vec::new();
   2271     let _ = write!(
   2272         data,
   2273         r##"<!DOCTYPE html>
   2274 <html lang="en">
   2275   <head>
   2276     <meta charset="UTF-8" />
   2277     <title>{page_title}</title>
   2278     <meta name="viewport" content="width=device-width, initial-scale=1" />
   2279     <meta name="description" content="{description}" />
   2280     <link rel="preload" href="/fonts/PoetsenOne-Regular.ttf" as="font" type="font/ttf" crossorigin />
   2281     <link rel="stylesheet" href="/damus.css?v=7" type="text/css" />
   2282     <meta property="og:title" content="{og_title}" />
   2283     <meta property="og:description" content="{description}" />
   2284     <meta property="og:type" content="website" />
   2285     <meta property="og:url" content="{canonical_url}" />
   2286     <meta property="og:image" content="{og_image}" />
   2287     <meta property="og:site_name" content="Damus" />
   2288     <meta name="twitter:card" content="summary_large_image" />
   2289     <meta name="twitter:title" content="{og_title}" />
   2290     <meta name="twitter:description" content="{description}" />
   2291     <meta name="twitter:image" content="{og_image}" />
   2292     <meta name="theme-color" content="#bd66ff" />
   2293   </head>
   2294   <body>
   2295     <div class="damus-app">
   2296       <header class="damus-header">
   2297         <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>
   2298         <div class="damus-header-actions">
   2299           <a class="damus-link" href="https://damus.io" target="_blank" rel="noopener noreferrer">damus.io</a>
   2300           <a class="damus-cta" data-damus-cta data-default-url="https://damus.io" data-default-target="_blank" rel="noopener noreferrer" href="https://damus.io">Open in Damus</a>
   2301         </div>
   2302       </header>
   2303       <main class="damus-main">
   2304         <section class="damus-card">
   2305           <h1>Damus</h1>
   2306           <p class="damus-supporting">
   2307             New to Nostr? You're in the right place. This interface captures the Damus aesthetic while running locally on notecrumbs.
   2308           </p>
   2309           <p class="damus-supporting">
   2310             Paste any Nostr bech32 identifier after the slash—for example <code>{profile_example}</code>—to render a profile or note instantly.
   2311           </p>
   2312         </section>
   2313         <section class="damus-card" id="details">
   2314           <h2 class="damus-section-title">Quick paths</h2>
   2315           <ul>
   2316             <li><code>{profile_example}</code> — profile preview.</li>
   2317             <li><code>{note_example}</code> — note/article preview.</li>
   2318             <li><code>{png_example}</code> — PNG share card.</li>
   2319             <li><code>{json_example}</code> — raw profile data.</li>
   2320           </ul>
   2321         </section>
   2322         <section class="damus-card">
   2323           <p class="damus-supporting">
   2324             Rendering is powered by <a href="https://github.com/damus-io/notecrumbs" target="_blank" rel="noopener noreferrer">notecrumbs</a>.
   2325             Explore the official Damus apps and community at <a href="https://damus.io" target="_blank" rel="noopener noreferrer">damus.io</a>.
   2326           </p>
   2327         </section>
   2328       </main>
   2329       <footer class="damus-footer">
   2330         <span>Theme inspired by the Damus experience.</span>
   2331         <span>Bring your own keys &amp; relays.</span>
   2332       </footer>
   2333     </div>
   2334 {platform_script}
   2335   </body>
   2336 </html>
   2337 "##,
   2338         page_title = page_title_html,
   2339         description = description_attr,
   2340         og_title = og_title_attr,
   2341         canonical_url = canonical_url_attr,
   2342         og_image = og_image_attr,
   2343         profile_example = profile_example_html,
   2344         note_example = note_example_html,
   2345         png_example = png_example_html,
   2346         json_example = json_example_html,
   2347         platform_script = DAMUS_PLATFORM_SCRIPT,
   2348     );
   2349 
   2350     Ok(Response::builder()
   2351         .header(header::CONTENT_TYPE, "text/html")
   2352         .status(StatusCode::OK)
   2353         .body(Full::new(Bytes::from(data)))?)
   2354 }
   2355 
   2356 fn get_base_url() -> String {
   2357     std::env::var("NOTECRUMBS_BASE_URL").unwrap_or_else(|_| "https://damus.io".to_string())
   2358 }
   2359 
   2360 pub fn serve_note_html(
   2361     app: &Notecrumbs,
   2362     nip19: &Nip19,
   2363     note_rd: &NoteAndProfileRenderData,
   2364     _r: Request<hyper::body::Incoming>,
   2365 ) -> Result<Response<Full<Bytes>>, Error> {
   2366     let mut data = Vec::new();
   2367 
   2368     let txn = Transaction::new(&app.ndb)?;
   2369 
   2370     let note = match note_rd.note_rd.lookup(&txn, &app.ndb) {
   2371         Ok(note) => note,
   2372         Err(_) => return Err(Error::NotFound),
   2373     };
   2374 
   2375     let profile_record = note_rd
   2376         .profile_rd
   2377         .as_ref()
   2378         .and_then(|profile_rd| match profile_rd {
   2379             ProfileRenderData::Missing(pk) => app.ndb.get_profile_by_pubkey(&txn, pk).ok(),
   2380             ProfileRenderData::Profile(key) => app.ndb.get_profile_by_key(&txn, *key).ok(),
   2381         });
   2382 
   2383     let profile_data = profile_record
   2384         .as_ref()
   2385         .and_then(|record| record.record().profile());
   2386 
   2387     let profile_name_raw = profile_data
   2388         .and_then(|profile| profile.name())
   2389         .unwrap_or("nostrich");
   2390 
   2391     let profile = Profile::from_record(
   2392         nostr_sdk::PublicKey::from_slice(note.pubkey()).unwrap(),
   2393         profile_record,
   2394     );
   2395 
   2396     // Generate bech32 with source relay hints for better discoverability.
   2397     // This applies to all event types (notes, articles, highlights).
   2398     // Falls back to original nip19 encoding if relay-enhanced encoding fails.
   2399     let note_bech32 = match crate::nip19::bech32_with_relays(nip19, &note_rd.source_relays) {
   2400         Some(bech32) => bech32,
   2401         None => {
   2402             warn!(
   2403                 "failed to encode bech32 with relays for nip19: {:?}, falling back to original",
   2404                 nip19
   2405             );
   2406             metrics::counter!("bech32_encode_fallback_total", 1);
   2407             nip19
   2408                 .to_bech32()
   2409                 .map_err(|e| Error::Generic(format!("failed to encode nip19: {}", e)))?
   2410         }
   2411     };
   2412     let base_url = get_base_url();
   2413     let canonical_url = format!("{}/{}", base_url, note_bech32);
   2414     let fallback_image_url = format!("{}/{}.png", base_url, note_bech32);
   2415 
   2416     let mut display_title_raw = profile_name_raw.to_string();
   2417     let mut og_description_raw = collapse_whitespace(abbreviate(note.content(), 64));
   2418     let mut og_image_url_raw = fallback_image_url.clone();
   2419     let mut timestamp_value = note.created_at();
   2420     let mut og_type = "website";
   2421 
   2422     let main_content_html = if matches!(note.kind(), 30023 | 30024) {
   2423         og_type = "article";
   2424 
   2425         let ArticleMetadata {
   2426             title,
   2427             image,
   2428             summary,
   2429             published_at,
   2430             topics,
   2431         } = extract_article_metadata(&note);
   2432 
   2433         if let Some(title) = title
   2434             .as_deref()
   2435             .map(|value| value.trim())
   2436             .filter(|value| !value.is_empty())
   2437         {
   2438             display_title_raw = title.to_owned();
   2439         }
   2440 
   2441         if let Some(published_at) = published_at {
   2442             timestamp_value = published_at;
   2443         }
   2444 
   2445         let summary_source = summary
   2446             .as_deref()
   2447             .map(|value| value.trim())
   2448             .filter(|value| !value.is_empty())
   2449             .map(|value| value.to_owned())
   2450             .unwrap_or_else(|| abbreviate(note.content(), 240).to_string());
   2451 
   2452         if let Some(ref image_url) = image {
   2453             if !image_url.trim().is_empty() {
   2454                 og_image_url_raw = image_url.trim().to_owned();
   2455             }
   2456         }
   2457 
   2458         og_description_raw = collapse_whitespace(&summary_source);
   2459 
   2460         let article_title_html = html_escape::encode_text(&display_title_raw).into_owned();
   2461         let summary_display_html = if summary_source.is_empty() {
   2462             None
   2463         } else {
   2464             Some(html_escape::encode_text(&summary_source).into_owned())
   2465         };
   2466         let article_body_html = render_markdown(note.content());
   2467 
   2468         build_article_content_html(
   2469             &profile,
   2470             timestamp_value,
   2471             &article_title_html,
   2472             image.as_deref(),
   2473             summary_display_html.as_deref(),
   2474             &article_body_html,
   2475             &topics,
   2476             note.kind() == 30024, // is_draft
   2477             &base_url,
   2478         )
   2479     } else if note.kind() == 9802 {
   2480         // NIP-84: Highlights
   2481         let highlight_meta = extract_highlight_metadata(&note);
   2482 
   2483         display_title_raw = format!("Highlight by {}", profile_name_raw);
   2484         og_description_raw = collapse_whitespace(abbreviate(note.content(), 200));
   2485 
   2486         let highlight_text_html = html_escape::encode_text(note.content()).replace("\n", "<br/>");
   2487 
   2488         // Only show context if it meaningfully differs from the highlight text.
   2489         // Some clients add/remove trailing punctuation, so we normalize before comparing.
   2490         let content_normalized = normalize_for_comparison(note.content());
   2491         let context_html = highlight_meta
   2492             .context
   2493             .as_deref()
   2494             .filter(|ctx| normalize_for_comparison(ctx) != content_normalized)
   2495             .map(|ctx| html_escape::encode_text(ctx).into_owned());
   2496 
   2497         let comment_html = highlight_meta
   2498             .comment
   2499             .as_deref()
   2500             .map(|c| html_escape::encode_text(c).into_owned());
   2501 
   2502         let source_markup = build_highlight_source_markup(&app.ndb, &txn, &highlight_meta);
   2503 
   2504         build_highlight_content_html(
   2505             &profile,
   2506             &base_url,
   2507             timestamp_value,
   2508             &highlight_text_html,
   2509             context_html.as_deref(),
   2510             comment_html.as_deref(),
   2511             &source_markup,
   2512         )
   2513     } else {
   2514         // Regular notes (kind 1, etc.)
   2515         // Use source relays from fetch if available, otherwise fall back to nip19 relay hints
   2516         let relays = if note_rd.source_relays.is_empty() {
   2517             crate::nip19::nip19_relays(nip19)
   2518         } else {
   2519             note_rd.source_relays.clone()
   2520         };
   2521         build_note_content_html(app, &note, &txn, &base_url, &profile, &relays)
   2522     };
   2523 
   2524     if og_description_raw.is_empty() {
   2525         og_description_raw = display_title_raw.clone();
   2526     }
   2527 
   2528     if og_image_url_raw.trim().is_empty() {
   2529         og_image_url_raw = fallback_image_url;
   2530     }
   2531 
   2532     let page_title_text = format!("{} on nostr", display_title_raw);
   2533     let og_image_alt_text = format!("{}: {}", display_title_raw, og_description_raw);
   2534 
   2535     let page_title_html = html_escape::encode_text(&page_title_text).into_owned();
   2536     let og_description_attr =
   2537         html_escape::encode_double_quoted_attribute(&og_description_raw).into_owned();
   2538     let og_image_attr = html_escape::encode_double_quoted_attribute(&og_image_url_raw).into_owned();
   2539     let og_title_attr = html_escape::encode_double_quoted_attribute(&page_title_text).into_owned();
   2540     let og_image_alt_attr =
   2541         html_escape::encode_double_quoted_attribute(&og_image_alt_text).into_owned();
   2542     let canonical_url_attr =
   2543         html_escape::encode_double_quoted_attribute(&canonical_url).into_owned();
   2544     let scripts = format!("{LOCAL_TIME_SCRIPT}{DAMUS_PLATFORM_SCRIPT}");
   2545 
   2546     let _ = write!(
   2547         data,
   2548         r##"<!DOCTYPE html>
   2549 <html lang="en">
   2550   <head>
   2551     <meta charset="UTF-8" />
   2552     <title>{page_title}</title>
   2553     <meta name="viewport" content="width=device-width, initial-scale=1" />
   2554     <meta name="description" content="{og_description}" />
   2555     <link rel="preload" href="/fonts/PoetsenOne-Regular.ttf" as="font" type="font/ttf" crossorigin />
   2556     <link rel="stylesheet" href="/damus.css?v=7" type="text/css" />
   2557     <meta property="og:title" content="{og_title}" />
   2558     <meta property="og:description" content="{og_description}" />
   2559     <meta property="og:type" content="{og_type}" />
   2560     <meta property="og:url" content="{canonical_url}" />
   2561     <meta property="og:image" content="{og_image}" />
   2562     <meta property="og:image:alt" content="{og_image_alt}" />
   2563     <meta property="og:image:height" content="600" />
   2564     <meta property="og:image:width" content="1200" />
   2565     <meta property="og:image:type" content="image/png" />
   2566     <meta property="og:site_name" content="Damus" />
   2567     <meta name="twitter:card" content="summary_large_image" />
   2568     <meta name="twitter:title" content="{og_title}" />
   2569     <meta name="twitter:description" content="{og_description}" />
   2570     <meta name="twitter:image" content="{og_image}" />
   2571     <meta name="theme-color" content="#bd66ff" />
   2572   </head>
   2573   <body>
   2574     <div class="damus-app">
   2575       <header class="damus-header">
   2576         <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>
   2577         <div class="damus-header-actions">
   2578           <a class="damus-cta" data-damus-cta data-default-url="nostr:{bech32}" href="nostr:{bech32}">Open in Damus</a>
   2579         </div>
   2580       </header>
   2581       <main class="damus-main">
   2582         {main_content}
   2583       </main>
   2584       <footer class="damus-footer">
   2585         <a href="https://github.com/damus-io/notecrumbs" target="_blank" rel="noopener noreferrer">Rendered by notecrumbs</a>
   2586       </footer>
   2587     </div>
   2588 {scripts}
   2589   </body>
   2590 </html>
   2591 "##,
   2592         page_title = page_title_html,
   2593         og_description = og_description_attr,
   2594         og_image = og_image_attr,
   2595         og_image_alt = og_image_alt_attr,
   2596         og_title = og_title_attr,
   2597         canonical_url = canonical_url_attr,
   2598         og_type = og_type,
   2599         main_content = main_content_html,
   2600         bech32 = note_bech32,
   2601         scripts = scripts,
   2602     );
   2603 
   2604     Ok(Response::builder()
   2605         .header(header::CONTENT_TYPE, "text/html")
   2606         .status(StatusCode::OK)
   2607         .body(Full::new(Bytes::from(data)))?)
   2608 }