notecrumbs

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

commit 0d826fe5d928b490b4643406bda914d7062b16e8
parent 4e996ee4807793f35691d8ace8fcc62883f41774
Author: William Casarin <jb55@jb55.com>
Date:   Tue,  2 Jan 2024 09:13:09 -0800

initial html note renderer

Diffstat:
Asrc/abbrev.rs | 36++++++++++++++++++++++++++++++++++++
Asrc/html.rs | 165+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/main.rs | 78+++---------------------------------------------------------------------------
Msrc/render.rs | 38+++-----------------------------------
4 files changed, 207 insertions(+), 110 deletions(-)

diff --git a/src/abbrev.rs b/src/abbrev.rs @@ -0,0 +1,36 @@ +#[inline] +fn floor_char_boundary(s: &str, index: usize) -> usize { + if index >= s.len() { + s.len() + } else { + let lower_bound = index.saturating_sub(3); + let new_index = s.as_bytes()[lower_bound..=index] + .iter() + .rposition(|b| is_utf8_char_boundary(*b)); + + // SAFETY: we know that the character boundary will be within four bytes + unsafe { lower_bound + new_index.unwrap_unchecked() } + } +} + +#[inline] +fn is_utf8_char_boundary(c: u8) -> bool { + // This is bit magic equivalent to: b < 128 || b >= 192 + (c as i8) >= -0x40 +} + +const ABBREV_SIZE: usize = 10; + +pub fn abbrev_str(name: &str) -> String { + if name.len() > ABBREV_SIZE { + let closest = floor_char_boundary(name, ABBREV_SIZE); + format!("{}...", &name[..closest]) + } else { + name.to_owned() + } +} + +pub fn abbreviate<'a>(text: &'a str, len: usize) -> &'a str { + let closest = floor_char_boundary(text, len); + &text[..closest] +} diff --git a/src/html.rs b/src/html.rs @@ -0,0 +1,165 @@ +use crate::Error; +use crate::{ + abbrev::{abbrev_str, abbreviate}, + render, Notecrumbs, +}; +use html_escape; +use http_body_util::Full; +use hyper::{ + body::Bytes, header, server::conn::http1, service::service_fn, Request, Response, StatusCode, +}; +use hyper_util::rt::TokioIo; +use log::error; +use nostr_sdk::prelude::{Nip19, ToBech32}; +use nostrdb::{BlockType, Blocks, Mention, Ndb, Note, Transaction}; +use std::io::Write; + +pub fn render_note_content(body: &mut Vec<u8>, ndb: &Ndb, note: &Note, blocks: &Blocks) { + for block in blocks.iter(note) { + let blocktype = block.blocktype(); + + match block.blocktype() { + BlockType::Url => { + let url = html_escape::encode_text(block.as_str()); + write!(body, r#"<a href="{}">{}</a>"#, url, url); + } + + BlockType::Hashtag => { + let hashtag = html_escape::encode_text(block.as_str()); + write!(body, r#"<span class="hashtag">#{}</span>"#, hashtag); + } + + BlockType::Text => { + let text = html_escape::encode_text(block.as_str()); + write!(body, r"{}", text); + } + + BlockType::Invoice => { + write!(body, r"{}", block.as_str()); + } + + BlockType::MentionIndex => { + write!(body, r"@nostrich"); + } + + BlockType::MentionBech32 => { + let pk = match block.as_mention().unwrap() { + Mention::Event(_) + | Mention::Note(_) + | Mention::Profile(_) + | Mention::Pubkey(_) + | Mention::Secret(_) + | Mention::Addr(_) => { + write!( + body, + r#"<a href="/{}">@{}</a>"#, + block.as_str(), + &abbrev_str(block.as_str()) + ); + } + + Mention::Relay(relay) => { + write!( + body, + r#"<a href="/{}">{}</a>"#, + block.as_str(), + &abbrev_str(relay.as_str()) + ); + } + }; + } + }; + } +} + +pub fn serve_note_html( + app: &Notecrumbs, + nip19: &Nip19, + note_data: &render::NoteRenderData, + r: Request<hyper::body::Incoming>, +) -> Result<Response<Full<Bytes>>, Error> { + let mut data = Vec::new(); + + // indices + // + // 0: name + // 1: abbreviated description + // 2: hostname + // 3: bech32 entity + // 4: Full content + + let hostname = "https://damus.io"; + let abbrev_content = html_escape::encode_text(abbreviate(&note_data.note.content, 64)); + let profile_name = html_escape::encode_text(&note_data.profile.name); + + write!( + data, + r#" + <html> + <head> + <title>{0} on nostr</title> + <meta name="viewport" content="width=device-width, initial-scale=1"> + <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: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 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}" /> + + </head> + <body> + <h3>Note!</h3> + <div class="note"> + <div class="note-content">"#, + profile_name, + abbrev_content, + hostname, + nip19.to_bech32().unwrap() + )?; + + let ok = (|| -> Result<(), nostrdb::Error> { + let txn = Transaction::new(&app.ndb)?; + let note_id = note_data.note.id.ok_or(nostrdb::Error::NotFound)?; + 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, &app.ndb, &note, &blocks); + + Ok(()) + })(); + + if let Err(err) = ok { + error!("error rendering html: {}", err); + write!( + data, + "{}", + html_escape::encode_text(&note_data.note.content) + ); + } + + write!( + data, + " + </div> + </div> + </body> + </html> + " + ); + + Ok(Response::builder() + .header(header::CONTENT_TYPE, "text/html") + .status(StatusCode::OK) + .body(Full::new(Bytes::from(data)))?) +} diff --git a/src/main.rs b/src/main.rs @@ -1,6 +1,5 @@ use std::net::SocketAddr; -use html_escape; use http_body_util::Full; use hyper::body::Bytes; use hyper::header; @@ -21,9 +20,11 @@ use std::time::Duration; use lru::LruCache; +mod abbrev; mod error; mod fonts; mod gradient; +mod html; mod nip19; mod pfp; mod render; @@ -129,11 +130,6 @@ fn is_utf8_char_boundary(c: u8) -> bool { (c as i8) >= -0x40 } -fn abbreviate<'a>(text: &'a str, len: usize) -> &'a str { - let closest = floor_char_boundary(text, len); - &text[..closest] -} - fn serve_profile_html( app: &Notecrumbs, nip: &Nip19, @@ -149,74 +145,6 @@ fn serve_profile_html( .body(Full::new(Bytes::from(data)))?) } -fn serve_note_html( - app: &Notecrumbs, - nip19: &Nip19, - note: &render::NoteRenderData, - r: Request<hyper::body::Incoming>, -) -> Result<Response<Full<Bytes>>, Error> { - let mut data = Vec::new(); - - // indices - // - // 0: name - // 1: abbreviated description - // 2: hostname - // 3: bech32 entity - // 4: Full content - - let hostname = "https://damus.io"; - let abbrev_content = html_escape::encode_text(abbreviate(&note.note.content, 64)); - let content = html_escape::encode_text(&note.note.content); - let profile_name = html_escape::encode_text(&note.profile.name); - - write!( - data, - r#" - <html> - <head> - <title>{0} on nostr</title> - <meta name="viewport" content="width=device-width, initial-scale=1"> - <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: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 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}" /> - - </head> - <body> - <h3>Note!</h3> - <div class="note"> - <div class="note-content">{4}</div> - </div> - </body> - </html> - "#, - profile_name, - abbrev_content, - hostname, - nip19.to_bech32().unwrap(), - content - )?; - - Ok(Response::builder() - .header(header::CONTENT_TYPE, "text/html") - .status(StatusCode::OK) - .body(Full::new(Bytes::from(data)))?) -} - async fn serve( app: &Notecrumbs, r: Request<hyper::body::Incoming>, @@ -258,7 +186,7 @@ async fn serve( .body(Full::new(Bytes::from(data)))?) } else { match render_data { - RenderData::Note(note_rd) => serve_note_html(app, &nip19, &note_rd, r), + RenderData::Note(note_rd) => html::serve_note_html(app, &nip19, &note_rd, r), RenderData::Profile(profile_rd) => serve_profile_html(app, &nip19, &profile_rd, r), } } diff --git a/src/render.rs b/src/render.rs @@ -1,4 +1,4 @@ -use crate::{fonts, Error, Notecrumbs}; +use crate::{abbrev::abbrev_str, fonts, Error, Notecrumbs}; use egui::epaint::Shadow; use egui::{ pos2, @@ -313,38 +313,6 @@ fn push_job_text(job: &mut LayoutJob, s: &str, color: Color32) { ) } -#[inline] -pub fn floor_char_boundary(s: &str, index: usize) -> usize { - if index >= s.len() { - s.len() - } else { - let lower_bound = index.saturating_sub(3); - let new_index = s.as_bytes()[lower_bound..=index] - .iter() - .rposition(|b| is_utf8_char_boundary(*b)); - - // SAFETY: we know that the character boundary will be within four bytes - unsafe { lower_bound + new_index.unwrap_unchecked() } - } -} - -#[inline] -fn is_utf8_char_boundary(c: u8) -> bool { - // This is bit magic equivalent to: b < 128 || b >= 192 - (c as i8) >= -0x40 -} - -const ABBREV_SIZE: usize = 10; - -fn abbrev_str(name: &str) -> String { - if name.len() > ABBREV_SIZE { - let closest = floor_char_boundary(name, ABBREV_SIZE); - format!("{}...", &name[..closest]) - } else { - name.to_owned() - } -} - fn push_job_user_mention( job: &mut LayoutJob, ndb: &Ndb, @@ -393,12 +361,12 @@ fn wrapped_body_blocks( BlockType::MentionBech32 => { let pk = match block.as_mention().unwrap() { - Mention::Event(ev) => push_job_text( + Mention::Event(_ev) => push_job_text( &mut job, &format!("@{}", &abbrev_str(block.as_str())), PURPLE, ), - Mention::Note(ev) => { + Mention::Note(_ev) => { push_job_text( &mut job, &format!("@{}", &abbrev_str(block.as_str())),