use std::error::Error;
use std::ffi::OsStr;
use std::path::Path;
use std::str::FromStr;
use http::Uri;
use gemini::{Doc, gemtext::Level as GtLevel};
use gnostr_types::{ContentSegment, ShatteredContent};
use mdiu::{Block, Content, Level, Link, Preformatted};
use nostr_sdk::prelude::*;
use regex::Regex;
use urlencoding::encode;
use super::options::NoteRenderOptions;
use crate::emoji::*;
use crate::nostru;
use crate::ts;
use crate::util::extract_first_words;
use rust_i18n::t;
rust_i18n::i18n!("locales");
pub fn invalid_content() -> Content {
Content::new("Invalid content").unwrap()
}
pub fn h1(text: String) -> Block {
Block::Heading(Level::One, Content::new(text).unwrap_or(invalid_content()))
}
pub fn h2(text: String) -> Block {
Block::Heading(Level::Two, Content::new(text).unwrap_or(invalid_content()))
}
pub fn h3(text: String) -> Block {
Block::Heading(
Level::Three,
Content::new(text).unwrap_or(invalid_content()),
)
}
pub fn lnk(uri: Uri, opt_label: Option<String>) -> Block {
if let Some(text) = opt_label {
Block::Link(Link::new(
uri,
Some(Content::new(text).unwrap_or(invalid_content())),
))
} else {
Block::Link(Link::new(uri, None))
}
}
pub fn blockify(gemtext: String) -> Vec<Block> {
let mut blocks = Vec::new();
if let Ok(builder) = gemini::parse::parse_gemtext(gemtext) {
for element in builder {
let block = match element {
Doc::Text(text) => {
let Ok(content) = Content::new(text) else {
continue;
};
Block::Text(content)
}
Doc::Preformatted { alt, text } => {
Block::Preformatted(Preformatted::new(
text,
alt.and_then(|atext| Content::new(atext).ok()),
))
}
Doc::Heading(level, text) => {
let lvl = match level {
GtLevel::One => Level::One,
GtLevel::Two => Level::Two,
GtLevel::Three => Level::Three,
};
let Ok(content) = Content::new(text) else {
continue;
};
Block::Heading(lvl, content)
}
Doc::Quote(quote) => {
let Ok(content) = Content::new(quote) else {
continue;
};
Block::Quote(content)
}
Doc::Link { to, name } => {
let Ok(uri) = Uri::from_str(&to) else {
continue;
};
Block::Link(Link::new(
uri,
name.and_then(|n| Content::new(n).ok()),
))
}
Doc::ListItem(text) => {
let Ok(content) = Content::new(text) else {
continue;
};
Block::ListItem(content)
}
Doc::Blank => Block::Empty,
};
blocks.push(block);
}
}
blocks
}
pub async fn pubkey_label(pubkey: &PublicKey, client: &Client) -> String {
if let Some(meta) = nostru::get_user_metadata(client, *pubkey).await {
if let Some(name) = meta.name
&& !name.is_empty()
{
name
} else if let Some(dname) = meta.display_name
&& !dname.is_empty()
{
dname
} else if let Some(nip05) = meta.nip05
&& !nip05.is_empty()
{
nip05
} else {
pubkey.to_bech32().unwrap()
}
} else {
if client
.subscribe(
Filter::new()
.kind(Kind::Metadata)
.kind(Kind::RelayList)
.limit(5)
.author(*pubkey),
None,
)
.await
.is_err()
{
eprintln!("Subscription error (pubkey_label)");
}
pubkey.to_bech32().unwrap()
}
}
pub async fn geminize_note(
event: &Event,
client: &Client,
) -> Vec<(Block, Option<Uri>)> {
let mut vec: Vec<(Block, Option<Uri>)> = Vec::new();
let mut links: Vec<(String, Uri)> = Vec::new();
let mut link_no = 0;
let ht_re = Regex::new(r"^#").unwrap();
let shattered_content = ShatteredContent::new(event.content.clone());
for segment in shattered_content.segments.iter() {
match segment {
ContentSegment::NostrUrl(nostr_url) => {
let Ok(n21) = Nip21::parse(&format!("{nostr_url}")) else {
eprintln!("Invalid nip21 url: {nostr_url}");
continue;
};
match n21 {
Nip21::EventId(event_id) => {
let bech32 = event_id.to_bech32().unwrap();
let Ok(uri) =
Uri::from_str(&format!("/note/{}/thread", bech32))
else {
continue;
};
links.push((
t!("read_ref_note_with_bech", event_bech = bech32)
.to_string(),
uri,
));
}
Nip21::Event(event) => {
let bech32 = event.event_id.to_bech32().unwrap();
let Ok(uri) =
Uri::from_str(&format!("/note/{}/thread", bech32))
else {
continue;
};
links.push((
t!("read_ref_note_with_bech", event_bech = bech32)
.to_string(),
uri,
));
}
Nip21::Pubkey(pubkey) => {
let Ok(uri) = Uri::from_str(&format!(
"/p/{}",
pubkey.to_bech32().unwrap()
)) else {
continue;
};
links.push((
format!(
"{} {}",
EMOJI_KEY,
pubkey_label(&pubkey, client).await
),
uri,
));
}
Nip21::Profile(profile) => {
let Ok(uri) = Uri::from_str(&format!(
"/p/{}",
profile.public_key.to_bech32().unwrap()
)) else {
continue;
};
links.push((
format!(
"{} {}",
EMOJI_KEY,
pubkey_label(&profile.public_key, client).await
),
uri,
));
}
_ => (),
}
}
ContentSegment::Hyperlink(span) => {
let Some(chunk) = shattered_content.slice(span) else {
continue;
};
let Ok(url) = Url::parse(chunk) else {
continue;
};
let scheme = url.scheme();
let filename = Path::new(url.path())
.file_name()
.unwrap_or(OsStr::new("/"));
let mime_type = mime_guess::from_path(filename)
.first()
.unwrap_or(mime::TEXT_PLAIN);
let Ok(orig_uri) = Uri::from_str(url.as_str()) else {
eprintln!("Invalid URL: {url}");
continue;
};
match scheme {
"http" | "https" => {
let mcat = mime_type.type_().as_str();
let Ok(get_uri) = Uri::builder()
.path_and_query(format!(
"/www/get/{}?{}",
filename.display(),
encode(url.as_ref())
))
.build()
else {
continue;
};
match mcat {
"image" => {
links.push((
format!(
"{} {} ({})",
EMOJI_FILE,
t!("attached_image"),
link_no
),
get_uri,
));
}
"video" => {
links.push((
format!(
"{} {} ({})",
EMOJI_FILE,
t!("attached_video"),
link_no
),
get_uri,
));
}
"audio" => {
links.push((
format!(
"{} {} ({})",
EMOJI_FILE,
t!("attached_audio"),
link_no
),
get_uri,
));
}
_ => {
links.push((
orig_uri.clone().to_string(),
orig_uri.clone(),
));
}
}
link_no += 1;
}
"ws" | "wss" | "ftp" | "gemini" | "nsite" | "payto" => {
links.push((
orig_uri.clone().to_string(),
orig_uri.clone(),
));
}
scheme => println!("Unhandled URL scheme {scheme}"),
}
}
ContentSegment::Plain(span) => {
let Some(chunk) = shattered_content.slice(span) else {
continue;
};
for line in chunk.lines() {
let cline = ht_re.replace(line, "");
let Ok(content) = Content::new(cline) else {
continue;
};
vec.push((Block::Text(content), None));
}
}
_ => (),
}
for (label, link) in &links {
vec.push((lnk(link.clone(), Some(label.clone())), None));
}
links.clear();
}
vec
}
#[derive(Copy, Clone, PartialEq)]
pub enum NoteRenderMode {
Tiny,
Minimal,
Normal,
Verbose,
}
pub async fn render_note(
client: &Client,
event: &Event,
metadata: Option<Metadata>,
mode: NoteRenderOptions,
) -> Result<Vec<Block>, Box<dyn Error>> {
let mut blocks = vec![];
let mut header = String::new();
let mut profile_header = String::new();
let profile_uri = Uri::builder()
.path_and_query(format!("/p/{}", event.pubkey.to_bech32()?))
.build()?;
let thread_uri = Uri::builder()
.path_and_query(format!(
"/note/{}/thread",
event.id.to_bech32().unwrap()
))
.build()?;
let reply_uri = Uri::builder()
.path_and_query(format!(
"/note/{}/reply",
event.id.to_bech32().unwrap()
))
.build()?;
let repost_uri = Uri::builder()
.path_and_query(format!(
"/note/{}/repost",
event.id.to_bech32().unwrap()
))
.build()?;
let react_uri = Uri::builder()
.path_and_query(format!(
"/note/{}/react_select",
event.id.to_bech32().unwrap()
))
.build()?;
let bookmark_uri = Uri::builder()
.path_and_query(format!(
"/note/{}/bookmark",
event.id.to_bech32().unwrap()
))
.build()?;
let delete_uri = Uri::builder()
.path_and_query(format!(
"/note/{}/delete",
event.id.to_bech32().unwrap()
))
.build()?;
let ts_diff = Timestamp::now() - event.created_at;
let ts_ago = ts::time_ago(ts_diff.as_secs());
if let Err(err) = client
.subscribe(
Filter::new()
.kind(Kind::Reaction)
.kind(Kind::Repost)
.event(event.id),
None,
)
.await
{
eprintln!("Reactions sub failed: {err}");
}
let rfilter = Filter::new().kind(Kind::Reaction).event(event.id);
let Ok(reactions) = client.database().query(rfilter).await else {
return Err(Box::from("Cannot fetch reactions"));
};
let Ok(reposts) = client
.database()
.query(Filter::new().kind(Kind::Repost).event(event.id))
.await
else {
return Err(Box::from("Cannot fetch reposts"));
};
if let Some(sentence) = extract_first_words(event, 10) {
header.push_str(EMOJI_SUN);
header.push_str(&sentence);
} else {
header.push_str(&format!("{} {}", EMOJI_SUN, "Note"));
}
profile_header.push_str(EMOJI_SUN);
if let Some(meta) = metadata {
if let Some(name) = meta.name
&& !name.is_empty()
{
profile_header.push_str(name.as_str());
}
if let Some(display_name) = meta.display_name
&& !display_name.is_empty()
{
profile_header.push_str(format!(" ({})", display_name).as_str())
}
} else {
profile_header.push_str(&event.pubkey.to_bech32().unwrap());
}
profile_header
.push_str(format!(" {} {} ago", EMOJI_CLOCK, ts_ago).as_str());
blocks.push(h2(header));
if !reposts.is_empty()
&& let Ok(content) = Content::new(nostru::reposts_counter(&reposts))
{
blocks.push(Block::Text(content));
}
if !reactions.is_empty()
&& let Ok(content) = Content::new(nostru::reactions_summary(&reactions))
{
blocks.push(Block::Text(content));
}
blocks.push(lnk(profile_uri, Some(profile_header)));
blocks.push(Block::Empty);
match event.kind {
Kind::TextNote => {
for (block, _uri) in geminize_note(event, client).await {
blocks.push(block);
}
}
Kind::LongFormTextNote => {
blocks.extend(blockify(md2gemtext::convert(&event.content)));
}
_ => (),
}
blocks.push(Block::Empty);
if mode.contains(NoteRenderOptions::SHOW_HASHTAGS) {
for htag in event.tags.hashtags() {
if let Ok(uri) = Uri::builder()
.path_and_query(format!("/hashtags/{}/notes", encode(htag)))
.build()
{
blocks.push(lnk(
uri,
Some(
format!(
"{} Hashtag: {}",
EMOJI_LARGE_ORANGE_DIAMOND, htag
)
.to_string(),
),
));
}
}
for pubkey in event.tags.public_keys() {
if let Ok(uri) = Uri::builder()
.path_and_query(format!("/p/{}", pubkey))
.build()
{
blocks.push(lnk(
uri,
Some(
format!(
"{} {}: {}",
EMOJI_KEY,
t!("public_key"),
pubkey_label(pubkey, client).await
)
.to_string(),
),
));
}
}
}
for event_id in event.tags.event_ids() {
if let Ok(uri) = Uri::builder()
.path_and_query(format!(
"/note/{}/thread",
event_id.to_bech32().unwrap()
))
.build()
{
blocks.push(lnk(
uri,
Some(
format!(
"{} {}",
EMOJI_LARGE_BLUE_SQUARE,
t!("mentioned_note")
)
.to_string(),
),
));
}
}
blocks.push(lnk(
thread_uri,
Some(format!("{} {}", emo_thread(), t!("thread"))),
));
blocks.push(lnk(
reply_uri,
Some(format!(
"{} {}",
emoji_by_code("speech_balloon"),
t!("reply")
)),
));
blocks.push(lnk(
repost_uri,
Some(format!("{} {}", EMOJI_MEGAPHONE, t!("repost"))),
));
if mode.contains(NoteRenderOptions::PUBLIC_BOOKMARK) {
blocks.push(lnk(
bookmark_uri,
Some(format!("{} {}", EMOJI_BOOKMARK, t!("bookmark_public"))),
));
}
if mode.contains(NoteRenderOptions::DELETE) {
blocks.push(lnk(
delete_uri,
Some(format!("{} {}", EMOJI_WASTEBASKET, t!("delete_note"))),
));
}
blocks.push(lnk(
react_uri,
Some(format!("{} {}", emoji_by_code("smile"), t!("react"))),
));
blocks.push(Block::Empty);
Ok(blocks)
}
pub async fn render_notes(
events: Events,
client: &Client,
idx_start: Option<usize>,
idx_end: Option<usize>,
mode: NoteRenderOptions,
) -> Result<Vec<Block>, Box<dyn Error>> {
let mut gemb = vec![];
let evcount = events.len();
let start = idx_start.unwrap_or_default();
let end = match idx_end {
Some(idx) => {
if evcount < idx {
evcount
} else {
idx
}
}
None => evcount,
};
for event in &events.to_vec()[start..end] {
let usermeta = nostru::get_user_metadata(client, event.pubkey).await;
match render_note(client, event, usermeta, mode).await {
Ok(mut blocks) => {
gemb.append(&mut blocks);
}
Err(_err) => {
gemb.push(Block::Text(
Content::new("Cannot process note").unwrap(),
));
}
}
}
Ok(gemb)
}