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