initial html note renderer

This commit is contained in:
William Casarin 2024-01-02 09:13:09 -08:00
parent 4e996ee480
commit 0d826fe5d9
4 changed files with 207 additions and 110 deletions

36
src/abbrev.rs Normal file
View File

@ -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]
}

165
src/html.rs Normal file
View File

@ -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)))?)
}

View File

@ -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),
}
}

View File

@ -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())),