notecrumbs

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

html.rs (52866B)


      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_sdk::prelude::{Nip19, PublicKey, ToBech32};
     11 use nostrdb::{BlockType, Blocks, Filter, Mention, Ndb, Note, NoteKey, Transaction};
     12 use pulldown_cmark::{html, Options, Parser};
     13 use std::fmt::Write as _;
     14 use std::io::Write;
     15 use std::str::FromStr;
     16 use tracing::warn;
     17 
     18 #[derive(Debug, Clone, PartialEq, Eq)]
     19 struct RelayEntry {
     20     url: String,
     21     read: bool,
     22     write: bool,
     23 }
     24 
     25 fn merge_relay_entry(relays: &mut Vec<RelayEntry>, url: &str, marker: Option<&str>) {
     26     let cleaned_url = url.trim();
     27     if cleaned_url.is_empty() {
     28         return;
     29     }
     30 
     31     let (read, write) = marker
     32         .map(|value| value.trim().to_ascii_lowercase())
     33         .map(|value| match value.as_str() {
     34             "read" => (true, false),
     35             "write" => (false, true),
     36             _ => (true, true),
     37         })
     38         .unwrap_or((true, true));
     39 
     40     if let Some(existing) = relays.iter_mut().find(|entry| entry.url == cleaned_url) {
     41         existing.read |= read;
     42         existing.write |= write;
     43         return;
     44     }
     45 
     46     relays.push(RelayEntry {
     47         url: cleaned_url.to_string(),
     48         read,
     49         write,
     50     });
     51 }
     52 
     53 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>"#;
     54 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>"#;
     55 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>"#;
     56 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>"#;
     57 fn blocktype_name(blocktype: &BlockType) -> &'static str {
     58     match blocktype {
     59         BlockType::MentionBech32 => "mention",
     60         BlockType::Hashtag => "hashtag",
     61         BlockType::Url => "url",
     62         BlockType::Text => "text",
     63         BlockType::MentionIndex => "indexed_mention",
     64         BlockType::Invoice => "invoice",
     65     }
     66 }
     67 
     68 #[derive(Default)]
     69 struct ArticleMetadata {
     70     title: Option<String>,
     71     image: Option<String>,
     72     summary: Option<String>,
     73     published_at: Option<u64>,
     74     topics: Vec<String>,
     75 }
     76 
     77 fn collapse_whitespace<S: AsRef<str>>(input: S) -> String {
     78     let mut result = String::with_capacity(input.as_ref().len());
     79     let mut last_space = false;
     80     for ch in input.as_ref().chars() {
     81         if ch.is_whitespace() {
     82             if !last_space && !result.is_empty() {
     83                 result.push(' ');
     84                 last_space = true;
     85             }
     86         } else {
     87             result.push(ch);
     88             last_space = false;
     89         }
     90     }
     91 
     92     result.trim().to_string()
     93 }
     94 
     95 fn extract_article_metadata(note: &Note) -> ArticleMetadata {
     96     let mut meta = ArticleMetadata::default();
     97 
     98     for tag in note.tags() {
     99         let mut iter = tag.into_iter();
    100         let Some(tag_kind) = iter.next().and_then(|nstr| nstr.variant().str()) else {
    101             continue;
    102         };
    103 
    104         match tag_kind {
    105             "title" => {
    106                 if let Some(value) = iter.next().and_then(|nstr| nstr.variant().str()) {
    107                     meta.title = Some(value.to_owned());
    108                 }
    109             }
    110             "image" => {
    111                 if let Some(value) = iter.next().and_then(|nstr| nstr.variant().str()) {
    112                     meta.image = Some(value.to_owned());
    113                 }
    114             }
    115             "summary" => {
    116                 if let Some(value) = iter.next().and_then(|nstr| nstr.variant().str()) {
    117                     meta.summary = Some(value.to_owned());
    118                 }
    119             }
    120             "published_at" => {
    121                 if let Some(value) = iter.next().and_then(|nstr| nstr.variant().str()) {
    122                     if let Ok(ts) = u64::from_str(value) {
    123                         meta.published_at = Some(ts);
    124                     }
    125                 }
    126             }
    127             "t" => {
    128                 for topic in iter {
    129                     if let Some(value) = topic.variant().str() {
    130                         if !value.is_empty()
    131                             && !meta
    132                                 .topics
    133                                 .iter()
    134                                 .any(|existing| existing.eq_ignore_ascii_case(value))
    135                         {
    136                             meta.topics.push(value.to_owned());
    137                         }
    138                         if meta.topics.len() >= 10 {
    139                             break;
    140                         }
    141                     }
    142                 }
    143             }
    144             _ => {}
    145         }
    146     }
    147 
    148     meta
    149 }
    150 
    151 fn render_markdown(markdown: &str) -> String {
    152     let mut options = Options::empty();
    153     options.insert(Options::ENABLE_TABLES);
    154     options.insert(Options::ENABLE_FOOTNOTES);
    155     options.insert(Options::ENABLE_STRIKETHROUGH);
    156     options.insert(Options::ENABLE_TASKLISTS);
    157 
    158     let parser = Parser::new_ext(markdown, options);
    159     let mut html_buf = String::new();
    160     html::push_html(&mut html_buf, parser);
    161 
    162     HtmlSanitizer::default().clean(&html_buf).to_string()
    163 }
    164 
    165 pub fn serve_note_json(
    166     ndb: &Ndb,
    167     note_rd: &NoteAndProfileRenderData,
    168 ) -> Result<Response<Full<Bytes>>, Error> {
    169     let mut body: Vec<u8> = vec![];
    170 
    171     let txn = Transaction::new(ndb)?;
    172 
    173     let note = match note_rd.note_rd.lookup(&txn, ndb) {
    174         Ok(note) => note,
    175         Err(_) => return Err(Error::NotFound),
    176     };
    177 
    178     let note_key = match note.key() {
    179         Some(note_key) => note_key,
    180         None => return Err(Error::NotFound),
    181     };
    182 
    183     write!(body, "{{\"note\":{},\"parsed_content\":[", &note.json()?)?;
    184 
    185     if let Ok(blocks) = ndb.get_blocks_by_key(&txn, note_key) {
    186         for (i, block) in blocks.iter(&note).enumerate() {
    187             if i != 0 {
    188                 write!(body, ",")?;
    189             }
    190             write!(
    191                 body,
    192                 "{{\"{}\":{}}}",
    193                 blocktype_name(&block.blocktype()),
    194                 serde_json::to_string(block.as_str())?
    195             )?;
    196         }
    197     };
    198 
    199     write!(body, "]")?;
    200 
    201     if let Ok(results) = ndb.query(
    202         &txn,
    203         &[Filter::new()
    204             .authors([note.pubkey()])
    205             .kinds([0])
    206             .limit(1)
    207             .build()],
    208         1,
    209     ) {
    210         if let Some(profile_note) = results.first() {
    211             write!(body, ",\"profile\":{}", profile_note.note.json()?)?;
    212         }
    213     }
    214 
    215     writeln!(body, "}}")?;
    216 
    217     Ok(Response::builder()
    218         .header(header::CONTENT_TYPE, "application/json; charset=utf-8")
    219         .status(StatusCode::OK)
    220         .body(Full::new(Bytes::from(body)))?)
    221 }
    222 
    223 fn ends_with(haystack: &str, needle: &str) -> bool {
    224     haystack.len() >= needle.len()
    225         && haystack[haystack.len() - needle.len()..].eq_ignore_ascii_case(needle)
    226 }
    227 
    228 fn is_image(url: &str) -> bool {
    229     const IMAGES: [&str; 10] = [
    230         "jpg", "jpeg", "png", "gif", "webp", "svg", "avif", "bmp", "ico", "apng",
    231     ];
    232 
    233     // Strip query string and fragment: ?foo=1#bar
    234     let base = url
    235         .split_once('?')
    236         .map(|(s, _)| s)
    237         .unwrap_or(url)
    238         .split_once('#')
    239         .map(|(s, _)| s)
    240         .unwrap_or(url);
    241 
    242     IMAGES.iter().any(|ext| ends_with(base, ext))
    243 }
    244 
    245 pub fn render_note_content(body: &mut Vec<u8>, note: &Note, blocks: &Blocks) {
    246     for block in blocks.iter(note) {
    247         match block.blocktype() {
    248             BlockType::Url => {
    249                 let url = html_escape::encode_text(block.as_str());
    250                 if is_image(&url) {
    251                     let _ = write!(body, r#"<img src="{}">"#, url);
    252                 } else {
    253                     let _ = write!(body, r#"<a href="{}">{}</a>"#, url, url);
    254                 }
    255             }
    256 
    257             BlockType::Hashtag => {
    258                 let hashtag = html_escape::encode_text(block.as_str());
    259                 let _ = write!(body, r#"<span class="hashtag">#{}</span>"#, hashtag);
    260             }
    261 
    262             BlockType::Text => {
    263                 let text = html_escape::encode_text(block.as_str()).replace("\n", "<br/>");
    264                 let _ = write!(body, r"{}", text);
    265             }
    266 
    267             BlockType::Invoice => {
    268                 let _ = write!(body, r"{}", block.as_str());
    269             }
    270 
    271             BlockType::MentionIndex => {
    272                 let _ = write!(body, r"@nostrich");
    273             }
    274 
    275             BlockType::MentionBech32 => {
    276                 match block.as_mention().unwrap() {
    277                     Mention::Event(_)
    278                     | Mention::Note(_)
    279                     | Mention::Profile(_)
    280                     | Mention::Pubkey(_)
    281                     | Mention::Secret(_)
    282                     | Mention::Addr(_) => {
    283                         let _ = write!(
    284                             body,
    285                             r#"<a href="/{}">@{}</a>"#,
    286                             block.as_str(),
    287                             &abbrev_str(block.as_str())
    288                         );
    289                     }
    290 
    291                     Mention::Relay(relay) => {
    292                         let _ = write!(
    293                             body,
    294                             r#"<a href="/{}">{}</a>"#,
    295                             block.as_str(),
    296                             &abbrev_str(relay.as_str())
    297                         );
    298                     }
    299                 };
    300             }
    301         };
    302     }
    303 }
    304 
    305 fn build_note_content_html(
    306     app: &Notecrumbs,
    307     note: &Note,
    308     txn: &Transaction,
    309     author_display: &str,
    310     pfp_url: &str,
    311     timestamp_value: u64,
    312 ) -> String {
    313     let mut body_buf = Vec::new();
    314     if let Some(blocks) = note
    315         .key()
    316         .and_then(|nk| app.ndb.get_blocks_by_key(txn, nk).ok())
    317     {
    318         render_note_content(&mut body_buf, note, &blocks);
    319     } else {
    320         let _ = write!(body_buf, "{}", html_escape::encode_text(note.content()));
    321     }
    322 
    323     let note_body = String::from_utf8(body_buf).unwrap_or_default();
    324     let pfp_attr = html_escape::encode_double_quoted_attribute(pfp_url);
    325     let timestamp_attr = timestamp_value.to_string();
    326 
    327     format!(
    328         r#"<article class="damus-card damus-note">
    329             <header class="damus-note-header">
    330                <img src="{pfp}" class="damus-note-avatar" alt="{author} profile picture" />
    331                <div>
    332                  <div class="damus-note-author">{author}</div>
    333                  <time class="damus-note-time" data-timestamp="{ts}" datetime="{ts}" title="{ts}">{ts}</time>
    334                </div>
    335             </header>
    336             <div class="damus-note-body">{body}</div>
    337         </article>"#,
    338         pfp = pfp_attr,
    339         author = author_display,
    340         ts = timestamp_attr,
    341         body = note_body
    342     )
    343 }
    344 
    345 fn build_article_content_html(
    346     author_display: &str,
    347     pfp_url: &str,
    348     timestamp_value: u64,
    349     article_title_html: &str,
    350     hero_image: Option<&str>,
    351     summary_html: Option<&str>,
    352     article_body_html: &str,
    353     topics: &[String],
    354 ) -> String {
    355     let pfp_attr = html_escape::encode_double_quoted_attribute(pfp_url);
    356     let timestamp_attr = timestamp_value.to_string();
    357 
    358     let hero_markup = hero_image
    359         .filter(|url| !url.is_empty())
    360         .map(|url| {
    361             let url_attr = html_escape::encode_double_quoted_attribute(url);
    362             format!(
    363                 r#"<img src="{url}" class="damus-article-hero" alt="Article header image" />"#,
    364                 url = url_attr
    365             )
    366         })
    367         .unwrap_or_default();
    368 
    369     let summary_markup = summary_html
    370         .map(|summary| format!(r#"<p class="damus-article-summary">{}</p>"#, summary))
    371         .unwrap_or_default();
    372 
    373     let mut topics_markup = String::new();
    374     if !topics.is_empty() {
    375         topics_markup.push_str(r#"<div class="damus-article-topics">"#);
    376         for topic in topics {
    377             if topic.is_empty() {
    378                 continue;
    379             }
    380             let topic_text = html_escape::encode_text(topic);
    381             let _ = write!(
    382                 topics_markup,
    383                 r#"<span class="damus-article-topic">#{}</span>"#,
    384                 topic_text
    385             );
    386         }
    387         topics_markup.push_str("</div>");
    388     }
    389 
    390     format!(
    391         r#"<article class="damus-card damus-note">
    392             <header class="damus-note-header">
    393                <img src="{pfp}" class="damus-note-avatar" alt="{author} profile picture" />
    394                <div>
    395                  <div class="damus-note-author">{author}</div>
    396                  <time class="damus-note-time" data-timestamp="{ts}" datetime="{ts}" title="{ts}">{ts}</time>
    397                </div>
    398             </header>
    399             <h1 class="damus-article-title">{title}</h1>
    400             {hero}
    401             {summary}
    402             {topics}
    403             <div class="damus-note-body">{body}</div>
    404         </article>"#,
    405         pfp = pfp_attr,
    406         author = author_display,
    407         ts = timestamp_attr,
    408         title = article_title_html,
    409         hero = hero_markup,
    410         summary = summary_markup,
    411         topics = topics_markup,
    412         body = article_body_html
    413     )
    414 }
    415 
    416 const LOCAL_TIME_SCRIPT: &str = r#"
    417         <script>
    418           (function() {
    419             'use strict';
    420             if (!('Intl' in window) || typeof Intl.DateTimeFormat !== 'function') {
    421               return;
    422             }
    423             var nodes = document.querySelectorAll('[data-timestamp]');
    424             var displayFormatter = new Intl.DateTimeFormat(undefined, {
    425               hour: 'numeric',
    426               minute: '2-digit',
    427               timeZoneName: 'short'
    428             });
    429             var titleFormatter = new Intl.DateTimeFormat(undefined, {
    430               year: 'numeric',
    431               month: 'short',
    432               day: 'numeric',
    433               hour: 'numeric',
    434               minute: '2-digit',
    435               second: '2-digit',
    436               timeZoneName: 'long'
    437             });
    438             var monthNames = [
    439               'Jan.',
    440               'Feb.',
    441               'Mar.',
    442               'Apr.',
    443               'May',
    444               'Jun.',
    445               'Jul.',
    446               'Aug.',
    447               'Sep.',
    448               'Oct.',
    449               'Nov.',
    450               'Dec.'
    451             ];
    452             Array.prototype.forEach.call(nodes, function(node) {
    453               var raw = node.getAttribute('data-timestamp');
    454               if (!raw) {
    455                 return;
    456               }
    457               var timestamp = Number(raw);
    458               if (!isFinite(timestamp)) {
    459                 return;
    460               }
    461               var date = new Date(timestamp * 1000);
    462               if (isNaN(date.getTime())) {
    463                 return;
    464               }
    465               var shortText = displayFormatter.format(date);
    466               var month = monthNames[date.getMonth()] || '';
    467               var day = String(date.getDate());
    468               var formattedDate = month
    469                 ? month + ' ' + day + ', ' + date.getFullYear()
    470                 : day + ', ' + date.getFullYear();
    471               var combined = formattedDate + ' · ' + shortText;
    472               node.textContent = combined;
    473               node.setAttribute('title', titleFormatter.format(date));
    474               node.setAttribute('datetime', date.toISOString());
    475             });
    476           }());
    477         </script>
    478 "#;
    479 
    480 pub const DAMUS_PLATFORM_SCRIPT: &str = r#"
    481         <script>
    482           (function() {
    483             'use strict';
    484             var PLATFORM_MAP = {
    485               ios: {
    486                 url: 'https://apps.apple.com/us/app/damus/id1628663131',
    487                 target: '_blank',
    488                 rel: 'noopener noreferrer'
    489               },
    490               android: {
    491                 url: 'https://damus.io/android/',
    492                 target: '_blank',
    493                 rel: 'noopener noreferrer'
    494               },
    495               desktop: {
    496                 url: 'https://damus.io/notedeck/',
    497                 target: '_blank',
    498                 rel: 'noopener noreferrer'
    499               }
    500             };
    501 
    502             var PLATFORM_LABELS = {
    503               ios: 'iOS',
    504               android: 'Android',
    505               desktop: 'Desktop'
    506             };
    507 
    508             function detectPlatform() {
    509               var ua = navigator.userAgent || '';
    510               var platform = navigator.platform || '';
    511               if (/android/i.test(ua)) {
    512                 return 'android';
    513               }
    514               if (/iPad|iPhone|iPod/.test(ua) || (/Macintosh/.test(ua) && 'ontouchend' in document)) {
    515                 return 'ios';
    516               }
    517               if (/Mac/.test(platform) || /Win/.test(platform) || /Linux/.test(platform)) {
    518                 return 'desktop';
    519               }
    520               return null;
    521             }
    522 
    523             var platform = detectPlatform();
    524             var mapping = platform && PLATFORM_MAP[platform];
    525             var anchors = document.querySelectorAll('[data-damus-cta]');
    526 
    527             Array.prototype.forEach.call(anchors, function(anchor) {
    528               var fallbackUrl = anchor.getAttribute('data-default-url') || anchor.getAttribute('href') || '';
    529               var fallbackTarget = anchor.getAttribute('data-default-target') || anchor.getAttribute('target') || '';
    530               var selected = mapping || { url: fallbackUrl, target: fallbackTarget };
    531 
    532               if (selected.url) {
    533                 anchor.setAttribute('href', selected.url);
    534               }
    535 
    536               if (selected.target) {
    537                 anchor.setAttribute('target', selected.target);
    538               } else {
    539                 anchor.removeAttribute('target');
    540               }
    541 
    542               if (mapping && mapping.rel) {
    543                 anchor.setAttribute('rel', mapping.rel);
    544               } else if (!selected.target) {
    545                 anchor.removeAttribute('rel');
    546               }
    547 
    548               if (platform && mapping) {
    549                 anchor.setAttribute('data-damus-platform', platform);
    550                 var label = PLATFORM_LABELS[platform] || platform;
    551                 anchor.setAttribute('aria-label', 'Open in Damus (' + label + ')');
    552               }
    553             });
    554           }());
    555         </script>
    556 "#;
    557 
    558 pub fn serve_profile_html(
    559     app: &Notecrumbs,
    560     nip: &Nip19,
    561     profile_rd: Option<&ProfileRenderData>,
    562     r: Request<hyper::body::Incoming>,
    563 ) -> Result<Response<Full<Bytes>>, Error> {
    564     let profile_key = match profile_rd {
    565         None | Some(ProfileRenderData::Missing(_)) => {
    566             let mut data = Vec::new();
    567             let _ = write!(data, "Profile not found :(");
    568             return Ok(Response::builder()
    569                 .header(header::CONTENT_TYPE, "text/html")
    570                 .status(StatusCode::NOT_FOUND)
    571                 .body(Full::new(Bytes::from(data)))?);
    572         }
    573 
    574         Some(ProfileRenderData::Profile(profile_key)) => *profile_key,
    575     };
    576 
    577     let txn = Transaction::new(&app.ndb)?;
    578 
    579     let profile_rec = match app.ndb.get_profile_by_key(&txn, profile_key) {
    580         Ok(profile_rec) => profile_rec,
    581         Err(_) => {
    582             let mut data = Vec::new();
    583             let _ = write!(data, "Profile not found :(");
    584             return Ok(Response::builder()
    585                 .header(header::CONTENT_TYPE, "text/html")
    586                 .status(StatusCode::NOT_FOUND)
    587                 .body(Full::new(Bytes::from(data)))?);
    588         }
    589     };
    590 
    591     let profile_record = profile_rec.record();
    592     let profile_data = profile_record.profile();
    593 
    594     let name_fallback = "nostrich";
    595     let username_raw = profile_data
    596         .and_then(|profile| profile.name())
    597         .map(str::trim)
    598         .filter(|name| !name.is_empty())
    599         .unwrap_or(name_fallback);
    600     let display_name_raw = profile_data
    601         .and_then(|profile| profile.display_name())
    602         .map(str::trim)
    603         .filter(|display| !display.is_empty())
    604         .unwrap_or(username_raw);
    605     let about_raw = profile_data
    606         .and_then(|profile| profile.about())
    607         .map(str::trim)
    608         .filter(|about| !about.is_empty())
    609         .unwrap_or("");
    610     let pfp_url_raw = profile_data
    611         .and_then(|profile| profile.picture())
    612         .map(str::trim)
    613         .filter(|url| !url.is_empty())
    614         .unwrap_or("https://damus.io/img/no-profile.svg");
    615 
    616     let display_name_html = html_escape::encode_text(display_name_raw).into_owned();
    617     let username_html = html_escape::encode_text(username_raw).into_owned();
    618     let pfp_attr = html_escape::encode_double_quoted_attribute(pfp_url_raw).into_owned();
    619 
    620     let mut relay_entries = Vec::new();
    621     let mut profile_pubkey: Option<[u8; 32]> = None;
    622     let profile_note_key = NoteKey::new(profile_record.note_key());
    623     if let Ok(profile_note) = app.ndb.get_note_by_key(&txn, profile_note_key) {
    624         let pubkey = *profile_note.pubkey();
    625         profile_pubkey = Some(pubkey);
    626         if let Ok(results) = app.ndb.query(
    627             &txn,
    628             &[Filter::new()
    629                 .authors([&pubkey])
    630                 .kinds([10002])
    631                 .limit(10)
    632                 .build()],
    633             10,
    634         ) {
    635             let mut latest_event = None;
    636             let mut latest_created_at = 0u64;
    637 
    638             for result in &results {
    639                 let created_at = result.note.created_at();
    640                 if created_at >= latest_created_at {
    641                     latest_created_at = created_at;
    642                     latest_event = Some(&result.note);
    643                 }
    644             }
    645 
    646             if let Some(relay_note) = latest_event {
    647                 for tag in relay_note.tags() {
    648                     let mut iter = tag.into_iter();
    649                     let Some(tag_kind) = iter.next().and_then(|item| item.variant().str()) else {
    650                         continue;
    651                     };
    652                     if tag_kind != "r" {
    653                         continue;
    654                     }
    655 
    656                     let Some(url) = iter.next().and_then(|item| item.variant().str()) else {
    657                         continue;
    658                     };
    659                     let marker = iter.next().and_then(|item| item.variant().str());
    660                     merge_relay_entry(&mut relay_entries, url, marker);
    661                 }
    662             }
    663         }
    664     }
    665 
    666     let mut meta_rows = String::new();
    667     if let Some(pubkey) = profile_pubkey.as_ref() {
    668         if let Ok(pk) = PublicKey::from_slice(pubkey) {
    669             if let Ok(npub) = pk.to_bech32() {
    670                 let npub_text = html_escape::encode_text(&npub).into_owned();
    671                 let npub_href = format!("nostr:{npub}");
    672                 let npub_href_attr =
    673                     html_escape::encode_double_quoted_attribute(&npub_href).into_owned();
    674                 let _ = write!(
    675                     meta_rows,
    676                     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>"#,
    677                     icon = ICON_KEY_CIRCLE,
    678                     href = npub_href_attr,
    679                     value = npub_text
    680                 );
    681             }
    682         }
    683     }
    684 
    685     if let Some(nip05) = profile_data
    686         .and_then(|profile| profile.nip05())
    687         .map(str::trim)
    688         .filter(|value| !value.is_empty())
    689     {
    690         let nip05_html = html_escape::encode_text(nip05).into_owned();
    691         let _ = write!(
    692             meta_rows,
    693             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>"#,
    694             icon = ICON_CONTACT_CIRCLE,
    695             value = nip05_html
    696         );
    697     }
    698 
    699     if let Some(website) = profile_data
    700         .and_then(|profile| profile.website())
    701         .map(str::trim)
    702         .filter(|value| !value.is_empty())
    703     {
    704         let href = if website.starts_with("http://") || website.starts_with("https://") {
    705             website.to_owned()
    706         } else {
    707             format!("https://{website}")
    708         };
    709         let href_attr = html_escape::encode_double_quoted_attribute(&href).into_owned();
    710         let text_html = html_escape::encode_text(website).into_owned();
    711         let _ = write!(
    712             meta_rows,
    713             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>"#,
    714             icon = ICON_LINK_CIRCLE,
    715             href = href_attr,
    716             value = text_html
    717         );
    718     }
    719 
    720     if let Some(lud16) = profile_data
    721         .and_then(|profile| profile.lud16())
    722         .map(str::trim)
    723         .filter(|value| !value.is_empty())
    724     {
    725         let lud16_html = html_escape::encode_text(lud16).into_owned();
    726         let _ = write!(
    727             meta_rows,
    728             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>"#,
    729             icon = ICON_BITCOIN,
    730             value = lud16_html
    731         );
    732     }
    733 
    734     let profile_meta_html = if meta_rows.is_empty() {
    735         String::new()
    736     } else {
    737         format!(
    738             r#"<div class="damus-profile-meta">{rows}</div>"#,
    739             rows = meta_rows
    740         )
    741     };
    742 
    743     let mut recent_notes_html = String::new();
    744     if let Some(pubkey) = profile_pubkey.as_ref() {
    745         let notes_filter = Filter::new()
    746             .authors([pubkey])
    747             .kinds([1])
    748             .limit(PROFILE_FEED_RECENT_LIMIT as u64)
    749             .build();
    750 
    751         match app
    752             .ndb
    753             .query(&txn, &[notes_filter], PROFILE_FEED_RECENT_LIMIT as i32)
    754         {
    755             Ok(mut note_results) => {
    756                 if note_results.is_empty() {
    757                     recent_notes_html.push_str(
    758                         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>"#,
    759                     );
    760                 } else {
    761                     note_results.sort_by_key(|result| result.note.created_at());
    762                     note_results.reverse();
    763                     recent_notes_html
    764                         .push_str(r#"<section class="damus-section"><h2 class="damus-section-title">Recent Notes</h2>"#);
    765                     for result in note_results.into_iter().take(PROFILE_FEED_RECENT_LIMIT) {
    766                         let timestamp_attr = result.note.created_at().to_string();
    767                         let note_body =
    768                             if let Ok(blocks) = app.ndb.get_blocks_by_key(&txn, result.note_key) {
    769                                 let mut buf = Vec::new();
    770                                 render_note_content(&mut buf, &result.note, &blocks);
    771                                 String::from_utf8(buf).unwrap_or_default()
    772                             } else {
    773                                 html_escape::encode_text(result.note.content()).into_owned()
    774                             };
    775 
    776                         let _ = write!(
    777                             recent_notes_html,
    778                             r#"<article class="damus-card damus-note">
    779                                   <header class="damus-note-header">
    780                                     <img src="{pfp}" class="damus-note-avatar" alt="{display} profile picture" />
    781                                     <div>
    782                                       <div class="damus-note-author">{display}</div>
    783                                       <time class="damus-note-time" data-timestamp="{ts}" datetime="{ts}" title="{ts}">{ts}</time>
    784                                     </div>
    785                                   </header>
    786                                   <div class="damus-note-body">{body}</div>
    787                                 </article>"#,
    788                             pfp = pfp_attr.as_str(),
    789                             display = display_name_html.as_str(),
    790                             ts = timestamp_attr,
    791                             body = note_body
    792                         );
    793                     }
    794                     recent_notes_html.push_str("</section>");
    795                 }
    796             }
    797             Err(err) => {
    798                 warn!("failed to query recent notes: {err}");
    799             }
    800         }
    801     }
    802 
    803     let relay_section_html = if relay_entries.is_empty() {
    804         String::from(r#"<div class="damus-relays muted">No relay list published yet.</div>"#)
    805     } else {
    806         let relay_count = relay_entries.len();
    807         let relay_count_label = format!("Relays ({relay_count})");
    808         let relay_count_html = html_escape::encode_text(&relay_count_label).into_owned();
    809 
    810         let mut list_markup = String::new();
    811         for entry in &relay_entries {
    812             let url_text = html_escape::encode_text(&entry.url).into_owned();
    813             let role_text = match (entry.read, entry.write) {
    814                 (true, true) => "read & write",
    815                 (true, false) => "read",
    816                 (false, true) => "write",
    817                 _ => "unspecified",
    818             };
    819             let role_html = html_escape::encode_text(role_text).into_owned();
    820             let _ = write!(
    821                 list_markup,
    822                 r#"<li>{url}<span class="damus-relay-role"> – {role}</span></li>"#,
    823                 url = url_text,
    824                 role = role_html
    825             );
    826         }
    827 
    828         format!(
    829             r#"<details class="damus-relays">
    830                 <summary>{count}</summary>
    831                 <ul class="damus-relay-list">
    832                     {items}
    833                 </ul>
    834             </details>"#,
    835             count = relay_count_html,
    836             items = list_markup
    837         )
    838     };
    839 
    840     let host = r
    841         .headers()
    842         .get(header::HOST)
    843         .and_then(|value| value.to_str().ok())
    844         .unwrap_or("localhost:3000");
    845     let base_url = format!("http://{host}");
    846     let bech32 = nip.to_bech32().unwrap_or_default();
    847     let canonical_url = format!("{base_url}/{bech32}");
    848 
    849     let fallback_image_url = format!("{base_url}/{bech32}.png");
    850     let og_image = if pfp_url_raw.is_empty() {
    851         fallback_image_url.clone()
    852     } else {
    853         pfp_url_raw.to_string()
    854     };
    855 
    856     let mut og_description_raw = if about_raw.is_empty() {
    857         format!("{} on nostr", display_name_raw)
    858     } else {
    859         about_raw.to_string()
    860     };
    861 
    862     if og_description_raw.is_empty() {
    863         og_description_raw = display_name_raw.to_string();
    864     }
    865 
    866     let og_image_url_raw = if og_image.trim().is_empty() {
    867         fallback_image_url
    868     } else {
    869         og_image.clone()
    870     };
    871 
    872     let page_title_text = format!("{} on nostr", display_name_raw);
    873     let og_image_alt_text = format!("{}: {}", display_name_raw, og_description_raw);
    874 
    875     let page_title_html = html_escape::encode_text(&page_title_text).into_owned();
    876     let og_description_attr =
    877         html_escape::encode_double_quoted_attribute(&og_description_raw).into_owned();
    878     let og_image_attr = html_escape::encode_double_quoted_attribute(&og_image_url_raw).into_owned();
    879     let og_title_attr = html_escape::encode_double_quoted_attribute(&page_title_text).into_owned();
    880     let og_image_alt_attr =
    881         html_escape::encode_double_quoted_attribute(&og_image_alt_text).into_owned();
    882     let canonical_url_attr =
    883         html_escape::encode_double_quoted_attribute(&canonical_url).into_owned();
    884 
    885     let about_html = if about_raw.is_empty() {
    886         String::new()
    887     } else {
    888         let about_text = html_escape::encode_text(about_raw)
    889             .into_owned()
    890             .replace("\n", "<br/>");
    891         format!(r#"<p class="damus-profile-about">{}</p>"#, about_text)
    892     };
    893 
    894     let main_content_html = format!(
    895         r#"<article class="damus-card damus-profile-card">
    896               <header class="damus-profile-header">
    897                 <img src="{pfp}" alt="{display} profile picture" class="damus-note-avatar" />
    898                 <div class="damus-profile-names">
    899                   <div class="damus-note-author">{display}</div>
    900                   <div class="damus-profile-handle">@{username}</div>
    901                 </div>
    902               </header>
    903               {about}
    904               {meta}
    905               {relays}
    906             </article>
    907             {recent_notes}"#,
    908         pfp = pfp_attr.as_str(),
    909         display = display_name_html.as_str(),
    910         username = username_html,
    911         about = about_html,
    912         meta = profile_meta_html,
    913         relays = relay_section_html,
    914         recent_notes = recent_notes_html,
    915     );
    916 
    917     let mut data = Vec::new();
    918     let scripts = format!("{LOCAL_TIME_SCRIPT}{DAMUS_PLATFORM_SCRIPT}");
    919 
    920     let page = format!(
    921         "<!DOCTYPE html>\n\
    922 <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\" 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\" 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",
    923         page_title = page_title_html,
    924         og_description = og_description_attr,
    925         og_image = og_image_attr,
    926         og_image_alt = og_image_alt_attr,
    927         og_title = og_title_attr,
    928         canonical_url = canonical_url_attr,
    929         main_content = main_content_html,
    930         bech32 = bech32,
    931         scripts = scripts,
    932     );
    933 
    934     let _ = data.write(page.as_bytes());
    935 
    936     Ok(Response::builder()
    937         .header(header::CONTENT_TYPE, "text/html")
    938         .status(StatusCode::OK)
    939         .body(Full::new(Bytes::from(data)))?)
    940 }
    941 
    942 pub fn serve_homepage(r: Request<hyper::body::Incoming>) -> Result<Response<Full<Bytes>>, Error> {
    943     let host = r
    944         .headers()
    945         .get(header::HOST)
    946         .and_then(|value| value.to_str().ok())
    947         .unwrap_or("localhost:3000");
    948     let base_url = format!("http://{}", host);
    949 
    950     let page_title = "Damus — notecrumbs frontend";
    951     let description =
    952         "Explore Nostr profiles and notes with the Damus-inspired notecrumbs frontend.";
    953     let og_image_url = format!("{}/assets/default_pfp.jpg", base_url);
    954 
    955     let canonical_url_attr = html_escape::encode_double_quoted_attribute(&base_url).into_owned();
    956     let description_attr = html_escape::encode_double_quoted_attribute(description).into_owned();
    957     let og_image_attr = html_escape::encode_double_quoted_attribute(&og_image_url).into_owned();
    958     let og_title_attr = html_escape::encode_double_quoted_attribute(page_title).into_owned();
    959     let page_title_html = html_escape::encode_text(page_title).into_owned();
    960 
    961     let profile_example = format!("{}/npub1example", base_url);
    962     let note_example = format!("{}/note1example", base_url);
    963     let profile_example_html = html_escape::encode_text(&profile_example).into_owned();
    964     let note_example_html = html_escape::encode_text(&note_example).into_owned();
    965     let png_example_html = html_escape::encode_text(&format!("{}.png", note_example)).into_owned();
    966     let json_example_html =
    967         html_escape::encode_text(&format!("{}.json", profile_example)).into_owned();
    968 
    969     let mut data = Vec::new();
    970     let _ = write!(
    971         data,
    972         r##"<!DOCTYPE html>
    973 <html lang="en">
    974   <head>
    975     <meta charset="UTF-8" />
    976     <title>{page_title}</title>
    977     <meta name="viewport" content="width=device-width, initial-scale=1" />
    978     <meta name="description" content="{description}" />
    979     <link rel="preload" href="/fonts/PoetsenOne-Regular.ttf" as="font" type="font/ttf" crossorigin />
    980     <link rel="stylesheet" href="/damus.css" type="text/css" />
    981     <meta property="og:title" content="{og_title}" />
    982     <meta property="og:description" content="{description}" />
    983     <meta property="og:type" content="website" />
    984     <meta property="og:url" content="{canonical_url}" />
    985     <meta property="og:image" content="{og_image}" />
    986     <meta property="og:site_name" content="Damus" />
    987     <meta name="twitter:card" content="summary_large_image" />
    988     <meta name="twitter:title" content="{og_title}" />
    989     <meta name="twitter:description" content="{description}" />
    990     <meta name="twitter:image" content="{og_image}" />
    991     <meta name="theme-color" content="#bd66ff" />
    992   </head>
    993   <body>
    994     <div class="damus-app">
    995       <header class="damus-header">
    996         <a class="damus-logo-link" href="https://damus.io" target="_blank" rel="noopener noreferrer"><img class="damus-logo-image" src="/assets/logo_icon.png" alt="Damus" width="40" height="40" /></a>
    997         <div class="damus-header-actions">
    998           <a class="damus-link" href="https://damus.io" target="_blank" rel="noopener noreferrer">damus.io</a>
    999           <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>
   1000         </div>
   1001       </header>
   1002       <main class="damus-main">
   1003         <section class="damus-card">
   1004           <h1>Damus</h1>
   1005           <p class="damus-supporting">
   1006             New to Nostr? You're in the right place. This interface captures the Damus aesthetic while running locally on notecrumbs.
   1007           </p>
   1008           <p class="damus-supporting">
   1009             Paste any Nostr bech32 identifier after the slash—for example <code>{profile_example}</code>—to render a profile or note instantly.
   1010           </p>
   1011         </section>
   1012         <section class="damus-card" id="details">
   1013           <h2 class="damus-section-title">Quick paths</h2>
   1014           <ul>
   1015             <li><code>{profile_example}</code> — profile preview.</li>
   1016             <li><code>{note_example}</code> — note/article preview.</li>
   1017             <li><code>{png_example}</code> — PNG share card.</li>
   1018             <li><code>{json_example}</code> — raw profile data.</li>
   1019           </ul>
   1020         </section>
   1021         <section class="damus-card">
   1022           <p class="damus-supporting">
   1023             Rendering is powered by <a href="https://github.com/damus-io/notecrumbs" target="_blank" rel="noopener noreferrer">notecrumbs</a>.
   1024             Explore the official Damus apps and community at <a href="https://damus.io" target="_blank" rel="noopener noreferrer">damus.io</a>.
   1025           </p>
   1026         </section>
   1027       </main>
   1028       <footer class="damus-footer">
   1029         <span>Theme inspired by the Damus experience.</span>
   1030         <span>Bring your own keys &amp; relays.</span>
   1031       </footer>
   1032     </div>
   1033 {platform_script}
   1034   </body>
   1035 </html>
   1036 "##,
   1037         page_title = page_title_html,
   1038         description = description_attr,
   1039         og_title = og_title_attr,
   1040         canonical_url = canonical_url_attr,
   1041         og_image = og_image_attr,
   1042         profile_example = profile_example_html,
   1043         note_example = note_example_html,
   1044         png_example = png_example_html,
   1045         json_example = json_example_html,
   1046         platform_script = DAMUS_PLATFORM_SCRIPT,
   1047     );
   1048 
   1049     Ok(Response::builder()
   1050         .header(header::CONTENT_TYPE, "text/html")
   1051         .status(StatusCode::OK)
   1052         .body(Full::new(Bytes::from(data)))?)
   1053 }
   1054 
   1055 pub fn serve_note_html(
   1056     app: &Notecrumbs,
   1057     nip19: &Nip19,
   1058     note_rd: &NoteAndProfileRenderData,
   1059     r: Request<hyper::body::Incoming>,
   1060 ) -> Result<Response<Full<Bytes>>, Error> {
   1061     let mut data = Vec::new();
   1062 
   1063     let txn = Transaction::new(&app.ndb)?;
   1064 
   1065     let note = match note_rd.note_rd.lookup(&txn, &app.ndb) {
   1066         Ok(note) => note,
   1067         Err(_) => return Err(Error::NotFound),
   1068     };
   1069 
   1070     let profile_record = note_rd
   1071         .profile_rd
   1072         .as_ref()
   1073         .and_then(|profile_rd| match profile_rd {
   1074             ProfileRenderData::Missing(pk) => app.ndb.get_profile_by_pubkey(&txn, pk).ok(),
   1075             ProfileRenderData::Profile(key) => app.ndb.get_profile_by_key(&txn, *key).ok(),
   1076         });
   1077 
   1078     let profile_data = profile_record
   1079         .as_ref()
   1080         .and_then(|record| record.record().profile());
   1081 
   1082     let profile_name_raw = profile_data
   1083         .and_then(|profile| profile.name())
   1084         .unwrap_or("nostrich");
   1085     let profile_name_html = html_escape::encode_text(profile_name_raw).into_owned();
   1086 
   1087     let default_pfp_url = "/assets/default_pfp.jpg";
   1088     let pfp_url_raw = profile_data
   1089         .and_then(|profile| profile.picture())
   1090         .unwrap_or(default_pfp_url);
   1091 
   1092     let host = r
   1093         .headers()
   1094         .get(header::HOST)
   1095         .and_then(|value| value.to_str().ok())
   1096         .unwrap_or("localhost:3000");
   1097     let base_url = format!("http://{}", host);
   1098     let bech32 = nip19.to_bech32().unwrap();
   1099     let canonical_url = format!("{}/{}", base_url, bech32);
   1100     let fallback_image_url = format!("{}/{}.png", base_url, bech32);
   1101 
   1102     let mut display_title_raw = profile_name_raw.to_string();
   1103     let mut og_description_raw = collapse_whitespace(abbreviate(note.content(), 64));
   1104     let mut og_image_url_raw = fallback_image_url.clone();
   1105     let mut timestamp_value = note.created_at();
   1106     let mut og_type = "website";
   1107     let author_display_html = profile_name_html.clone();
   1108 
   1109     let main_content_html = if matches!(note.kind(), 30023 | 30024) {
   1110         og_type = "article";
   1111 
   1112         let ArticleMetadata {
   1113             title,
   1114             image,
   1115             summary,
   1116             published_at,
   1117             topics,
   1118         } = extract_article_metadata(&note);
   1119 
   1120         if let Some(title) = title
   1121             .as_deref()
   1122             .map(|value| value.trim())
   1123             .filter(|value| !value.is_empty())
   1124         {
   1125             display_title_raw = title.to_owned();
   1126         }
   1127 
   1128         if let Some(published_at) = published_at {
   1129             timestamp_value = published_at;
   1130         }
   1131 
   1132         let summary_source = summary
   1133             .as_deref()
   1134             .map(|value| value.trim())
   1135             .filter(|value| !value.is_empty())
   1136             .map(|value| value.to_owned())
   1137             .unwrap_or_else(|| abbreviate(note.content(), 240).to_string());
   1138 
   1139         if let Some(ref image_url) = image {
   1140             if !image_url.trim().is_empty() {
   1141                 og_image_url_raw = image_url.trim().to_owned();
   1142             }
   1143         }
   1144 
   1145         og_description_raw = collapse_whitespace(&summary_source);
   1146 
   1147         let article_title_html = html_escape::encode_text(&display_title_raw).into_owned();
   1148         let summary_display_html = if summary_source.is_empty() {
   1149             None
   1150         } else {
   1151             Some(html_escape::encode_text(&summary_source).into_owned())
   1152         };
   1153         let article_body_html = render_markdown(note.content());
   1154 
   1155         build_article_content_html(
   1156             author_display_html.as_str(),
   1157             pfp_url_raw,
   1158             timestamp_value,
   1159             &article_title_html,
   1160             image.as_deref(),
   1161             summary_display_html.as_deref(),
   1162             &article_body_html,
   1163             &topics,
   1164         )
   1165     } else {
   1166         build_note_content_html(
   1167             app,
   1168             &note,
   1169             &txn,
   1170             author_display_html.as_str(),
   1171             pfp_url_raw,
   1172             timestamp_value,
   1173         )
   1174     };
   1175 
   1176     if og_description_raw.is_empty() {
   1177         og_description_raw = display_title_raw.clone();
   1178     }
   1179 
   1180     if og_image_url_raw.trim().is_empty() {
   1181         og_image_url_raw = fallback_image_url;
   1182     }
   1183 
   1184     let page_title_text = format!("{} on nostr", display_title_raw);
   1185     let og_image_alt_text = format!("{}: {}", display_title_raw, og_description_raw);
   1186 
   1187     let page_title_html = html_escape::encode_text(&page_title_text).into_owned();
   1188     let og_description_attr =
   1189         html_escape::encode_double_quoted_attribute(&og_description_raw).into_owned();
   1190     let og_image_attr = html_escape::encode_double_quoted_attribute(&og_image_url_raw).into_owned();
   1191     let og_title_attr = html_escape::encode_double_quoted_attribute(&page_title_text).into_owned();
   1192     let og_image_alt_attr =
   1193         html_escape::encode_double_quoted_attribute(&og_image_alt_text).into_owned();
   1194     let canonical_url_attr =
   1195         html_escape::encode_double_quoted_attribute(&canonical_url).into_owned();
   1196     let scripts = format!("{LOCAL_TIME_SCRIPT}{DAMUS_PLATFORM_SCRIPT}");
   1197 
   1198     let _ = write!(
   1199         data,
   1200         r##"<!DOCTYPE html>
   1201 <html lang="en">
   1202   <head>
   1203     <meta charset="UTF-8" />
   1204     <title>{page_title}</title>
   1205     <meta name="viewport" content="width=device-width, initial-scale=1" />
   1206     <meta name="description" content="{og_description}" />
   1207     <link rel="preload" href="/fonts/PoetsenOne-Regular.ttf" as="font" type="font/ttf" crossorigin />
   1208     <link rel="stylesheet" href="/damus.css" type="text/css" />
   1209     <meta property="og:title" content="{og_title}" />
   1210     <meta property="og:description" content="{og_description}" />
   1211     <meta property="og:type" content="{og_type}" />
   1212     <meta property="og:url" content="{canonical_url}" />
   1213     <meta property="og:image" content="{og_image}" />
   1214     <meta property="og:image:alt" content="{og_image_alt}" />
   1215     <meta property="og:image:height" content="600" />
   1216     <meta property="og:image:width" content="1200" />
   1217     <meta property="og:image:type" content="image/png" />
   1218     <meta property="og:site_name" content="Damus" />
   1219     <meta name="twitter:card" content="summary_large_image" />
   1220     <meta name="twitter:title" content="{og_title}" />
   1221     <meta name="twitter:description" content="{og_description}" />
   1222     <meta name="twitter:image" content="{og_image}" />
   1223     <meta name="theme-color" content="#bd66ff" />
   1224   </head>
   1225   <body>
   1226     <div class="damus-app">
   1227       <header class="damus-header">
   1228         <a class="damus-logo-link" href="https://damus.io" target="_blank" rel="noopener noreferrer"><img class="damus-logo-image" src="/assets/logo_icon.png" alt="Damus" width="40" height="40" /></a>
   1229         <div class="damus-header-actions">
   1230           <a class="damus-cta" data-damus-cta data-default-url="nostr:{bech32}" href="nostr:{bech32}">Open in Damus</a>
   1231         </div>
   1232       </header>
   1233       <main class="damus-main">
   1234         {main_content}
   1235       </main>
   1236       <footer class="damus-footer">
   1237         <a href="https://github.com/damus-io/notecrumbs" target="_blank" rel="noopener noreferrer">Rendered by notecrumbs</a>
   1238       </footer>
   1239     </div>
   1240 {scripts}
   1241   </body>
   1242 </html>
   1243 "##,
   1244         page_title = page_title_html,
   1245         og_description = og_description_attr,
   1246         og_image = og_image_attr,
   1247         og_image_alt = og_image_alt_attr,
   1248         og_title = og_title_attr,
   1249         canonical_url = canonical_url_attr,
   1250         og_type = og_type,
   1251         main_content = main_content_html,
   1252         bech32 = bech32,
   1253         scripts = scripts,
   1254     );
   1255 
   1256     Ok(Response::builder()
   1257         .header(header::CONTENT_TYPE, "text/html")
   1258         .status(StatusCode::OK)
   1259         .body(Full::new(Bytes::from(data)))?)
   1260 }