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\":[", ¬e.json()?)?; 184 185 if let Ok(blocks) = ndb.get_blocks_by_key(&txn, note_key) { 186 for (i, block) in blocks.iter(¬e).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(¬e_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 & 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(¬e); 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 ¬e, 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 }