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:
| M | Cargo.lock | | | 257 | ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++- |
| M | Cargo.toml | | | 2 | ++ |
| M | src/html.rs | | | 543 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------------- |
| M | src/nip19.rs | | | 1 | + |
| M | src/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\":[", ¬e.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(¬e);
+
+ 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,
+ ¬e,
+ &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, ¬e, &blocks);
-
- Ok(())
- })();
-
- if let Err(err) = ok {
- error!("error rendering html: {}", err);
- let _ = write!(data, "{}", html_escape::encode_text(¬e.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) {