notecrumbs

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

commit 94a20feb37a4103f19691a7bb772a8ca7dc2b31c
parent f58de44da21b10c6f65907b076792aa60c1a1b24
Author: alltheseas <alltheseas@users.noreply.github.com>
Date:   Tue, 21 Oct 2025 21:36:45 -0500

feat: render NIP-23 longform articles

Diffstat:
MCargo.lock | 257++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
MCargo.toml | 2++
Msrc/html.rs | 543+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------------
Msrc/nip19.rs | 1+
Msrc/render.rs | 92+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
5 files changed, 784 insertions(+), 111 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock @@ -82,6 +82,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" [[package]] +name = "ammonia" +version = "4.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17e913097e1a2124b46746c980134e8c954bc17a6a59bb3fde96f088d126dde6" +dependencies = [ + "cssparser", + "html5ever", + "maplit", + "tendril", + "url", +] + +[[package]] name = "arrayref" version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -537,6 +550,29 @@ dependencies = [ ] [[package]] +name = "cssparser" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e901edd733a1472f944a45116df3f846f54d37e67e68640ac8bb69689aca2aa" +dependencies = [ + "cssparser-macros", + "dtoa-short", + "itoa", + "phf", + "smallvec", +] + +[[package]] +name = "cssparser-macros" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331" +dependencies = [ + "quote", + "syn 2.0.92", +] + +[[package]] name = "data-encoding" version = "2.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -571,6 +607,21 @@ dependencies = [ ] [[package]] +name = "dtoa" +version = "1.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6add3b8cff394282be81f3fc1a0605db594ed69890078ca6e2cab1c408bcf04" + +[[package]] +name = "dtoa-short" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd1511a7b6a56299bd043a9c167a6d2bfb37bf84a6dfceaba651168adfb43c87" +dependencies = [ + "dtoa", +] + +[[package]] name = "ecolor" version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -780,6 +831,16 @@ dependencies = [ ] [[package]] +name = "futf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df420e2e84819663797d1ec6544b13c5be84629e7bb00dc960d6917db2987843" +dependencies = [ + "mac", + "new_debug_unreachable", +] + +[[package]] name = "futures" version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -879,6 +940,15 @@ dependencies = [ ] [[package]] +name = "getopts" +version = "0.2.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe4fbac503b8d1f88e6676011885f34b7174f46e59956bba534ba83abded4df" +dependencies = [ + "unicode-width", +] + +[[package]] name = "getrandom" version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1026,6 +1096,17 @@ dependencies = [ ] [[package]] +name = "html5ever" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55d958c2f74b664487a2035fe1dadb032c48718a03b63f3ab0b8537db8549ed4" +dependencies = [ + "log", + "markup5ever", + "match_token", +] + +[[package]] name = "http" version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1432,6 +1513,40 @@ dependencies = [ ] [[package]] +name = "mac" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" + +[[package]] +name = "maplit" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e2e65a1a2e43cfcb47a895c4c8b10d1f4a61097f9f254f183aee60cad9c651d" + +[[package]] +name = "markup5ever" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "311fe69c934650f8f19652b3946075f0fc41ad8757dbb68f1ca14e7900ecc1c3" +dependencies = [ + "log", + "tendril", + "web_atoms", +] + +[[package]] +name = "match_token" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac84fd3f360fcc43dc5f5d186f02a94192761a080e8bc58621ad4d12296a58cf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.92", +] + +[[package]] name = "memchr" version = "2.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1493,6 +1608,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "43a88da9dd148bbcdce323dd6ac47d369b4769d4a3b78c6c52389b9269f77932" [[package]] +name = "new_debug_unreachable" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" + +[[package]] name = "nohash-hasher" version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1603,6 +1724,7 @@ dependencies = [ name = "notecrumbs" version = "0.1.0" dependencies = [ + "ammonia", "bytes", "egui", "egui_extras", @@ -1618,6 +1740,7 @@ dependencies = [ "nostr", "nostr-sdk", "nostrdb", + "pulldown-cmark", "serde_json", "skia-safe", "tokio", @@ -1737,6 +1860,58 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e" [[package]] +name = "phf" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" +dependencies = [ + "phf_macros", + "phf_shared", +] + +[[package]] +name = "phf_codegen" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" +dependencies = [ + "phf_generator", + "phf_shared", +] + +[[package]] +name = "phf_generator" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" +dependencies = [ + "phf_shared", + "rand", +] + +[[package]] +name = "phf_macros" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", + "syn 2.0.92", +] + +[[package]] +name = "phf_shared" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" +dependencies = [ + "siphasher 1.0.1", +] + +[[package]] name = "pico-args" version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1788,6 +1963,12 @@ dependencies = [ ] [[package]] +name = "precomputed-hash" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" + +[[package]] name = "prettyplease" version = "0.2.25" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1807,6 +1988,18 @@ dependencies = [ ] [[package]] +name = "pulldown-cmark" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57206b407293d2bcd3af849ce869d52068623f19e1b5ff8e8778e3309439682b" +dependencies = [ + "bitflags 2.6.0", + "getopts", + "memchr", + "unicase", +] + +[[package]] name = "qoi" version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -2192,6 +2385,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "38b58827f4464d87d377d175e90bf58eb00fd8716ff0a62f80356b5e61555d0d" [[package]] +name = "siphasher" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56199f7ddabf13fe5074ce809e7d3f42b42ae711800501b5b16ea82ad029c39d" + +[[package]] name = "skia-bindings" version = "0.58.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -2267,6 +2466,31 @@ dependencies = [ ] [[package]] +name = "string_cache" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf776ba3fa74f83bf4b63c3dcbbf82173db2632ed8452cb2d891d33f459de70f" +dependencies = [ + "new_debug_unreachable", + "parking_lot", + "phf_shared", + "precomputed-hash", + "serde", +] + +[[package]] +name = "string_cache_codegen" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c711928715f1fe0fe509c53b43e993a9a557babc2d0a3567d0a3006f1ac931a0" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", +] + +[[package]] name = "subtle" version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -2278,7 +2502,7 @@ version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "22975e8a2bac6a76bb54f898a6b18764633b00e780330f0b689f65afb3975564" dependencies = [ - "siphasher", + "siphasher 0.3.11", ] [[package]] @@ -2326,6 +2550,17 @@ dependencies = [ ] [[package]] +name = "tendril" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d24a120c5fc464a3458240ee02c299ebcb9d67b5249c8848b09d639dca8d7bb0" +dependencies = [ + "futf", + "mac", + "utf-8", +] + +[[package]] name = "thiserror" version = "1.0.69" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -2659,6 +2894,12 @@ dependencies = [ ] [[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + +[[package]] name = "universal-hash" version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -2717,7 +2958,7 @@ dependencies = [ "rctree", "roxmltree", "simplecss", - "siphasher", + "siphasher 0.3.11", "strict-num", "svgtypes", ] @@ -2851,6 +3092,18 @@ dependencies = [ ] [[package]] +name = "web_atoms" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57ffde1dc01240bdf9992e3205668b235e59421fd085e8a317ed98da0178d414" +dependencies = [ + "phf", + "phf_codegen", + "string_cache", + "string_cache_codegen", +] + +[[package]] name = "webpki-roots" version = "0.26.7" source = "registry+https://github.com/rust-lang/crates.io-index" diff --git a/Cargo.toml b/Cargo.toml @@ -30,4 +30,6 @@ lru = "0.12.1" bytes = "1.5.0" http = "1.0.0" html-escape = "0.2.13" +ammonia = "4.0" +pulldown-cmark = "0.9" serde_json = "*" diff --git a/src/html.rs b/src/html.rs @@ -1,15 +1,18 @@ use crate::Error; use crate::{ abbrev::{abbrev_str, abbreviate}, - render::{NoteAndProfileRenderData, NoteRenderData, ProfileRenderData}, + render::{NoteAndProfileRenderData, ProfileRenderData}, Notecrumbs, }; +use ammonia::Builder as HtmlSanitizer; use http_body_util::Full; use hyper::{body::Bytes, header, Request, Response, StatusCode}; use nostr_sdk::prelude::{Nip19, ToBech32}; use nostrdb::{BlockType, Blocks, Filter, Mention, Ndb, Note, Transaction}; +use pulldown_cmark::{html, Options, Parser}; +use std::fmt::Write as _; use std::io::Write; -use tracing::{error, warn}; +use std::str::FromStr; fn blocktype_name(blocktype: &BlockType) -> &'static str { match blocktype { @@ -22,27 +25,119 @@ fn blocktype_name(blocktype: &BlockType) -> &'static str { } } +#[derive(Default)] +struct ArticleMetadata { + title: Option<String>, + image: Option<String>, + summary: Option<String>, + published_at: Option<u64>, + topics: Vec<String>, +} + +fn collapse_whitespace<S: AsRef<str>>(input: S) -> String { + let mut result = String::with_capacity(input.as_ref().len()); + let mut last_space = false; + for ch in input.as_ref().chars() { + if ch.is_whitespace() { + if !last_space && !result.is_empty() { + result.push(' '); + last_space = true; + } + } else { + result.push(ch); + last_space = false; + } + } + + result.trim().to_string() +} + +fn extract_article_metadata(note: &Note) -> ArticleMetadata { + let mut meta = ArticleMetadata::default(); + + for tag in note.tags().iter() { + let mut iter = tag.clone().into_iter(); + let Some(tag_kind) = iter.next().and_then(|nstr| nstr.variant().str()) else { + continue; + }; + + match tag_kind { + "title" => { + if let Some(value) = iter.next().and_then(|nstr| nstr.variant().str()) { + meta.title = Some(value.to_owned()); + } + } + "image" => { + if let Some(value) = iter.next().and_then(|nstr| nstr.variant().str()) { + meta.image = Some(value.to_owned()); + } + } + "summary" => { + if let Some(value) = iter.next().and_then(|nstr| nstr.variant().str()) { + meta.summary = Some(value.to_owned()); + } + } + "published_at" => { + if let Some(value) = iter.next().and_then(|nstr| nstr.variant().str()) { + if let Ok(ts) = u64::from_str(value) { + meta.published_at = Some(ts); + } + } + } + "t" => { + for topic in iter { + if let Some(value) = topic.variant().str() { + if !value.is_empty() + && !meta + .topics + .iter() + .any(|existing| existing.eq_ignore_ascii_case(value)) + { + meta.topics.push(value.to_owned()); + } + if meta.topics.len() >= 10 { + break; + } + } + } + } + _ => {} + } + } + + meta +} + +fn render_markdown(markdown: &str) -> String { + let mut options = Options::empty(); + options.insert(Options::ENABLE_TABLES); + options.insert(Options::ENABLE_FOOTNOTES); + options.insert(Options::ENABLE_STRIKETHROUGH); + options.insert(Options::ENABLE_TASKLISTS); + + let parser = Parser::new_ext(markdown, options); + let mut html_buf = String::new(); + html::push_html(&mut html_buf, parser); + + HtmlSanitizer::default().clean(&html_buf).to_string() +} + pub fn serve_note_json( ndb: &Ndb, note_rd: &NoteAndProfileRenderData, ) -> Result<Response<Full<Bytes>>, Error> { let mut body: Vec<u8> = vec![]; - let note_key = match note_rd.note_rd { - NoteRenderData::Note(note_key) => note_key, - NoteRenderData::Missing(note_id) => { - warn!("missing note_id {}", hex::encode(note_id)); - return Err(Error::NotFound); - } - }; - let txn = Transaction::new(ndb)?; - let note = if let Ok(note) = ndb.get_note_by_key(&txn, note_key) { - note - } else { - // 404 - return Err(Error::NotFound); + let note = match note_rd.note_rd.lookup(&txn, ndb) { + Ok(note) => note, + Err(_) => return Err(Error::NotFound), + }; + + let note_key = match note.key() { + Some(note_key) => note_key, + None => return Err(Error::NotFound), }; write!(body, "{{\"note\":{},\"parsed_content\":[", &note.json()?)?; @@ -141,6 +236,179 @@ pub fn render_note_content(body: &mut Vec<u8>, note: &Note, blocks: &Blocks) { } } +fn build_note_content_html( + app: &Notecrumbs, + note: &Note, + txn: &Transaction, + author_display: &str, + pfp_url: &str, + timestamp_value: u64, +) -> String { + let mut body_buf = Vec::new(); + if let Some(blocks) = note + .key() + .and_then(|nk| app.ndb.get_blocks_by_key(txn, nk).ok()) + { + render_note_content(&mut body_buf, note, &blocks); + } else { + let _ = write!(body_buf, "{}", html_escape::encode_text(note.content())); + } + + let note_body = String::from_utf8(body_buf).unwrap_or_default(); + let pfp_attr = html_escape::encode_double_quoted_attribute(pfp_url); + let timestamp_attr = timestamp_value.to_string(); + + format!( + r#"<div class="note"> + <div class="note-header"> + <img src="{pfp}" class="note-author-avatar" /> + <div class="note-author-name">{author}</div> + <div class="note-header-separator">·</div> + <time class="note-timestamp" data-timestamp="{ts}" datetime="{ts}" title="{ts}">{ts}</time> + </div> + <div class="note-content">{body}</div> + </div>"#, + pfp = pfp_attr, + author = author_display, + ts = timestamp_attr, + body = note_body + ) +} + +fn build_article_content_html( + author_display: &str, + pfp_url: &str, + timestamp_value: u64, + article_title_html: &str, + hero_image: Option<&str>, + summary_html: Option<&str>, + article_body_html: &str, + topics: &[String], +) -> String { + let pfp_attr = html_escape::encode_double_quoted_attribute(pfp_url); + let timestamp_attr = timestamp_value.to_string(); + + let hero_markup = hero_image + .filter(|url| !url.is_empty()) + .map(|url| { + let url_attr = html_escape::encode_double_quoted_attribute(url); + format!( + r#"<img src="{url}" class="article-hero" alt="Article header image" />"#, + url = url_attr + ) + }) + .unwrap_or_default(); + + let summary_markup = summary_html + .map(|summary| format!(r#"<p class="article-summary">{}</p>"#, summary)) + .unwrap_or_default(); + + let mut topics_markup = String::new(); + if !topics.is_empty() { + topics_markup.push_str(r#"<div class="article-topics">"#); + for topic in topics { + if topic.is_empty() { + continue; + } + let topic_text = html_escape::encode_text(topic); + let _ = write!( + topics_markup, + r#"<span class="article-topic">#{}</span>"#, + topic_text + ); + } + topics_markup.push_str("</div>"); + } + + format!( + r#"<div class="note article-note"> + <div class="note-header"> + <img src="{pfp}" class="note-author-avatar" /> + <div class="note-author-name">{author}</div> + <div class="note-header-separator">·</div> + <time class="note-timestamp" data-timestamp="{ts}" datetime="{ts}" title="{ts}">{ts}</time> + </div> + <h1 class="article-title">{title}</h1> + {hero} + {summary} + {topics} + <div class="article-content">{body}</div> + </div>"#, + pfp = pfp_attr, + author = author_display, + ts = timestamp_attr, + title = article_title_html, + hero = hero_markup, + summary = summary_markup, + topics = topics_markup, + body = article_body_html + ) +} + +const LOCAL_TIME_SCRIPT: &str = r#" + <script> + (function() { + 'use strict'; + if (!('Intl' in window) || typeof Intl.DateTimeFormat !== 'function') { + return; + } + var nodes = document.querySelectorAll('[data-timestamp]'); + var displayFormatter = new Intl.DateTimeFormat(undefined, { + hour: 'numeric', + minute: '2-digit', + timeZoneName: 'short' + }); + var titleFormatter = new Intl.DateTimeFormat(undefined, { + year: 'numeric', + month: 'short', + day: 'numeric', + hour: 'numeric', + minute: '2-digit', + second: '2-digit', + timeZoneName: 'long' + }); + var monthNames = [ + 'Jan.', + 'Feb.', + 'Mar.', + 'Apr.', + 'May', + 'Jun.', + 'Jul.', + 'Aug.', + 'Sep.', + 'Oct.', + 'Nov.', + 'Dec.' + ]; + Array.prototype.forEach.call(nodes, function(node) { + var raw = node.getAttribute('data-timestamp'); + if (!raw) { + return; + } + var timestamp = Number(raw); + if (!isFinite(timestamp)) { + return; + } + var date = new Date(timestamp * 1000); + if (isNaN(date.getTime())) { + return; + } + var shortText = displayFormatter.format(date); + var month = monthNames[date.getMonth()] || ''; + var day = String(date.getDate()); + var formattedDate = month + ? month + ' ' + day + ', ' + date.getFullYear() + : day + ', ' + date.getFullYear(); + var combined = formattedDate + ' · ' + shortText; + node.textContent = combined; + node.setAttribute('title', titleFormatter.format(date)); + node.setAttribute('datetime', date.toISOString()); + }); + }()); + </script> +"#; + pub fn serve_note_html( app: &Notecrumbs, nip19: &Nip19, @@ -149,78 +417,164 @@ pub fn serve_note_html( ) -> Result<Response<Full<Bytes>>, Error> { let mut data = Vec::new(); - // indices - // - // 0: name - // 1: abbreviated description - // 2: hostname - // 3: bech32 entity - // 5: formatted date - // 6: pfp url - - let note_key = match note_rd.note_rd { - NoteRenderData::Note(note_key) => note_key, - NoteRenderData::Missing(note_id) => { - warn!("missing note_id {}", hex::encode(note_id)); - return Err(Error::NotFound); - } - }; - let txn = Transaction::new(&app.ndb)?; - let note = if let Ok(note) = app.ndb.get_note_by_key(&txn, note_key) { - note - } else { - // 404 - return Err(Error::NotFound); + let note = match note_rd.note_rd.lookup(&txn, &app.ndb) { + Ok(note) => note, + Err(_) => return Err(Error::NotFound), }; - let profile = note_rd.profile_rd.as_ref().and_then(|profile_rd| { - match profile_rd { - // we probably wouldn't have it here, but we query just in case? + let profile_record = note_rd + .profile_rd + .as_ref() + .and_then(|profile_rd| match profile_rd { ProfileRenderData::Missing(pk) => app.ndb.get_profile_by_pubkey(&txn, pk).ok(), ProfileRenderData::Profile(key) => app.ndb.get_profile_by_key(&txn, *key).ok(), - } - }); + }); + + let profile_data = profile_record + .as_ref() + .and_then(|record| record.record().profile()); + + let profile_name_raw = profile_data + .and_then(|profile| profile.name()) + .unwrap_or("nostrich"); + let profile_name_html = html_escape::encode_text(profile_name_raw).into_owned(); - let hostname = "https://damus.io"; - let abbrev_content = html_escape::encode_text(abbreviate(note.content(), 64)); - let profile = profile.and_then(|pr| pr.record().profile()); let default_pfp_url = "https://damus.io/img/no-profile.svg"; - let pfp_url = profile.and_then(|p| p.picture()).unwrap_or(default_pfp_url); - let profile_name = { - let name = profile.and_then(|p| p.name()).unwrap_or("nostrich"); - html_escape::encode_text(name) - }; + let pfp_url_raw = profile_data + .and_then(|profile| profile.picture()) + .unwrap_or(default_pfp_url); + + let hostname = "https://damus.io"; let bech32 = nip19.to_bech32().unwrap(); + let canonical_url = format!("{}/{}", hostname, bech32); + let fallback_image_url = format!("{}/{}.png", hostname, bech32); + + let mut display_title_raw = profile_name_raw.to_string(); + let mut og_description_raw = collapse_whitespace(abbreviate(note.content(), 64)); + let mut og_image_url_raw = fallback_image_url.clone(); + let mut timestamp_value = note.created_at(); + let mut page_heading = "Note"; + let mut og_type = "website"; + let author_display_html = profile_name_html.clone(); + + let main_content_html = if matches!(note.kind(), 30023 | 30024) { + page_heading = "Article"; + og_type = "article"; + + let ArticleMetadata { + title, + image, + summary, + published_at, + topics, + } = extract_article_metadata(&note); + + if let Some(title) = title + .as_deref() + .map(|value| value.trim()) + .filter(|value| !value.is_empty()) + { + display_title_raw = title.to_owned(); + } + + if let Some(published_at) = published_at { + timestamp_value = published_at; + } + + let summary_source = summary + .as_deref() + .map(|value| value.trim()) + .filter(|value| !value.is_empty()) + .map(|value| value.to_owned()) + .unwrap_or_else(|| abbreviate(note.content(), 240).to_string()); + + if let Some(ref image_url) = image { + if !image_url.trim().is_empty() { + og_image_url_raw = image_url.trim().to_owned(); + } + } + + og_description_raw = collapse_whitespace(&summary_source); + + let article_title_html = html_escape::encode_text(&display_title_raw).into_owned(); + let summary_display_html = if summary_source.is_empty() { + None + } else { + Some(html_escape::encode_text(&summary_source).into_owned()) + }; + let article_body_html = render_markdown(note.content()); + + build_article_content_html( + author_display_html.as_str(), + pfp_url_raw, + timestamp_value, + &article_title_html, + image.as_deref(), + summary_display_html.as_deref(), + &article_body_html, + &topics, + ) + } else { + build_note_content_html( + app, + &note, + &txn, + author_display_html.as_str(), + pfp_url_raw, + timestamp_value, + ) + }; + + if og_description_raw.is_empty() { + og_description_raw = display_title_raw.clone(); + } + + if og_image_url_raw.trim().is_empty() { + og_image_url_raw = fallback_image_url; + } - write!( + let page_title_text = format!("{} on nostr", display_title_raw); + let og_image_alt_text = format!("{}: {}", display_title_raw, og_description_raw); + + let page_title_html = html_escape::encode_text(&page_title_text).into_owned(); + let page_heading_html = html_escape::encode_text(page_heading).into_owned(); + let og_description_attr = + html_escape::encode_double_quoted_attribute(&og_description_raw).into_owned(); + let og_image_attr = html_escape::encode_double_quoted_attribute(&og_image_url_raw).into_owned(); + let og_title_attr = html_escape::encode_double_quoted_attribute(&page_title_text).into_owned(); + let og_image_alt_attr = + html_escape::encode_double_quoted_attribute(&og_image_alt_text).into_owned(); + let canonical_url_attr = + html_escape::encode_double_quoted_attribute(&canonical_url).into_owned(); + + let _ = write!( data, r#" <html> <head> - <title>{0} on nostr</title> + <title>{page_title}</title> <link rel="stylesheet" href="https://damus.io/css/notecrumbs.css" type="text/css" /> <meta name="viewport" content="width=device-width, initial-scale=1"> - <meta name="apple-itunes-app" content="app-id=1628663131, app-argument=damus:nostr:{3}"/> + <meta name="apple-itunes-app" content="app-id=1628663131, app-argument=damus:nostr:{bech32}"/> <meta charset="UTF-8"> - - <meta property="og:description" content="{1}" /> - <meta property="og:image" content="{2}/{3}.png"/> - <meta property="og:image:alt" content="{0}: {1}" /> + <meta property="og:description" content="{og_description}" /> + <meta property="og:image" content="{og_image}"/> + <meta property="og:image:alt" content="{og_image_alt}" /> <meta property="og:image:height" content="600" /> <meta property="og:image:width" content="1200" /> <meta property="og:image:type" content="image/png" /> <meta property="og:site_name" content="Damus" /> - <meta property="og:title" content="{0} on nostr" /> - <meta property="og:url" content="{2}/{3}"/> - <meta name="og:type" content="website"/> - <meta name="twitter:image:src" content="{2}/{3}.png" /> + <meta property="og:title" content="{og_title}" /> + <meta property="og:url" content="{canonical_url}"/> + <meta property="og:type" content="{og_type}"/> + <meta name="og:type" content="{og_type}"/> + <meta name="twitter:image:src" content="{og_image}" /> <meta name="twitter:site" content="@damusapp" /> <meta name="twitter:card" content="summary_large_image" /> - <meta name="twitter:title" content="{0} on nostr" /> - <meta name="twitter:description" content="{1}" /> - + <meta name="twitter:title" content="{og_title}" /> + <meta name="twitter:description" content="{og_description}" /> </head> <body> <main> @@ -229,54 +583,14 @@ pub fn serve_note_html( <a href="https://damus.io" target="_blank"> <img src="https://damus.io/logo_icon.png" class="logo" /> </a> - <!-- - <a href="damus:nostr:note1234..." id="top-menu-open-in-damus-button" class="accent-button"> - Open in Damus - </a> - --> </div> - <h3 class="page-heading">Note</h3> + <h3 class="page-heading">{page_heading}</h3> <div class="note-container"> - <div class="note"> - <div class="note-header"> - <img src="{5}" class="note-author-avatar" /> - <div class="note-author-name">{0}</div> - <div class="note-header-separator">·</div> - <div class="note-timestamp">{4}</div> - </div> - - <div class="note-content">"#, - profile_name, - abbrev_content, - hostname, - bech32, - note.created_at(), - pfp_url, - )?; - - let ok = (|| -> Result<(), nostrdb::Error> { - let note_id = note.id(); - let note = app.ndb.get_note_by_id(&txn, note_id)?; - let blocks = app.ndb.get_blocks_by_key(&txn, note.key().unwrap())?; - - render_note_content(&mut data, &note, &blocks); - - Ok(()) - })(); - - if let Err(err) = ok { - error!("error rendering html: {}", err); - let _ = write!(data, "{}", html_escape::encode_text(&note.content())); - } - - let _ = write!( - data, - r#" - </div> - </div> + {main_content} + </div> </div> <div class="note-actions-footer"> - <a href="nostr:{}" class="muted-link">Open with default Nostr client</a> + <a href="nostr:{bech32}" class="muted-link">Open with default Nostr client</a> </div> </main> <footer> @@ -287,10 +601,21 @@ pub fn serve_note_html( © Damus Nostr Inc. </span> </footer> + {script} </body> </html> "#, - bech32 + page_title = page_title_html, + og_description = og_description_attr, + og_image = og_image_attr, + og_image_alt = og_image_alt_attr, + og_title = og_title_attr, + canonical_url = canonical_url_attr, + og_type = og_type, + page_heading = page_heading_html, + main_content = main_content_html, + bech32 = bech32, + script = LOCAL_TIME_SCRIPT, ); Ok(Response::builder() diff --git a/src/nip19.rs b/src/nip19.rs @@ -10,6 +10,7 @@ pub fn nip19_relays(nip19: &Nip19) -> Vec<RelayUrl> { .iter() .filter_map(|r| RelayUrl::parse(r).ok()) .collect(), + Nip19::Coordinate(coord) => coord.relays.clone(), Nip19::Profile(p) => p.relays.clone(), _ => vec![], } diff --git a/src/render.rs b/src/render.rs @@ -23,6 +23,11 @@ const PURPLE: Color32 = Color32::from_rgb(0xcc, 0x43, 0xc5); pub enum NoteRenderData { Missing([u8; 32]), + Address { + author: [u8; 32], + kind: u64, + identifier: String, + }, Note(NoteKey), } @@ -30,6 +35,7 @@ impl NoteRenderData { pub fn needs_note(&self) -> bool { match self { NoteRenderData::Missing(_) => true, + NoteRenderData::Address { .. } => true, NoteRenderData::Note(_) => false, } } @@ -41,6 +47,11 @@ impl NoteRenderData { ) -> std::result::Result<Note<'a>, nostrdb::Error> { match self { NoteRenderData::Missing(note_id) => ndb.get_note_by_id(txn, note_id), + NoteRenderData::Address { + author, + kind, + identifier, + } => query_note_by_address(ndb, txn, author, *kind, identifier), NoteRenderData::Note(note_key) => ndb.get_note_by_key(txn, *note_key), } } @@ -151,6 +162,13 @@ fn renderdata_to_filter(render_data: &RenderData) -> Vec<nostrdb::Filter> { Some(NoteRenderData::Missing(note_id)) => { filters.push(nostrdb::Filter::new().ids([note_id]).limit(1).build()); } + Some(NoteRenderData::Address { + author, + kind, + identifier, + }) => { + filters.push(build_address_filter(author, *kind, identifier.as_str())); + } None | Some(NoteRenderData::Note(_)) => {} } @@ -238,6 +256,46 @@ fn convert_filter(ndb_filter: &nostrdb::Filter) -> nostr::types::Filter { filter } +fn coordinate_tag(author: &[u8; 32], kind: u64, identifier: &str) -> String { + let pk_hex = hex::encode(author); + format!("{}:{}:{}", kind, pk_hex, identifier) +} + +fn build_address_filter(author: &[u8; 32], kind: u64, identifier: &str) -> nostrdb::Filter { + let author_ref: [&[u8; 32]; 1] = [author]; + let mut filter = nostrdb::Filter::new().authors(author_ref).kinds([kind]); + if !identifier.is_empty() { + let ident = identifier.to_string(); + filter = filter.tags(vec![ident], 'd'); + filter = filter.tags(vec![coordinate_tag(author, kind, identifier)], 'a'); + } + filter.limit(1).build() +} + +fn query_note_by_address<'a>( + ndb: &Ndb, + txn: &'a Transaction, + author: &[u8; 32], + kind: u64, + identifier: &str, +) -> std::result::Result<Note<'a>, nostrdb::Error> { + let mut results = ndb.query(txn, &[build_address_filter(author, kind, identifier)], 1)?; + if results.is_empty() && !identifier.is_empty() { + let coord_filter = nostrdb::Filter::new() + .authors([author]) + .kinds([kind]) + .tags(vec![coordinate_tag(author, kind, identifier)], 'a') + .limit(1) + .build(); + results = ndb.query(txn, &[coord_filter], 1)?; + } + if let Some(result) = results.first() { + ndb.get_note_by_key(txn, result.note_key) + } else { + Err(nostrdb::Error::NotFound) + } +} + pub async fn find_note( ndb: Ndb, keys: Keys, @@ -425,6 +483,40 @@ pub fn get_render_data(ndb: &Ndb, txn: &Transaction, nip19: &Nip19) -> Result<Re Ok(RenderData::note(note_rd, profile_rd)) } + Nip19::Coordinate(coordinate) => { + let author = coordinate.public_key.serialize(); + let kind: u64 = u16::from(coordinate.kind) as u64; + let identifier = coordinate.identifier.clone(); + + let note_rd = { + let filter = build_address_filter(&author, kind, identifier.as_str()); + let note_key = ndb + .query(txn, &[filter], 1) + .ok() + .and_then(|results| results.into_iter().next().map(|res| res.note_key)); + + if let Some(note_key) = note_key { + NoteRenderData::Note(note_key) + } else { + NoteRenderData::Address { + author, + kind, + identifier: identifier.clone(), + } + } + }; + + let profile_rd = { + if let Ok(profile_key) = ndb.get_profilekey_by_pubkey(txn, &author) { + Some(ProfileRenderData::Profile(profile_key)) + } else { + Some(ProfileRenderData::Missing(author)) + } + }; + + Ok(RenderData::note(note_rd, profile_rd)) + } + Nip19::Profile(nprofile) => { let pubkey = nprofile.public_key.serialize(); let profile_rd = if let Ok(profile_key) = ndb.get_profilekey_by_pubkey(txn, &pubkey) {