use std::error::Error;
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 crate::emoji::*;
use crate::nostru;
use crate::ts;
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 fn geminize_note(event: &Event) -> Vec<(Block, Option<Uri>)> {
let mut vec: Vec<(Block, Option<Uri>)> = Vec::new();
let mut links: Vec<(String, Uri)> = Vec::new();
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_hex()))
else {
continue;
};
links.push((
format!(
"{} {}",
EMOJI_KEY,
pubkey.to_bech32().unwrap()
),
uri,
));
}
Nip21::Profile(profile) => {
let Ok(uri) = Uri::from_str(&format!(
"/p/{}",
profile.public_key.to_hex()
)) else {
continue;
};
links.push((
format!(
"{} {}",
EMOJI_KEY,
profile.public_key.to_bech32().unwrap()
),
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 Ok(orig_uri) = Uri::from_str(url.as_str()) else {
continue;
};
match scheme {
"ws" | "wss" | "ftp" | "http" | "https" | "gemini"
| "nsite" => {
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: NoteRenderMode,
) -> Result<Vec<Block>, Box<dyn Error>> {
let mut blocks = vec![];
let mut p_name: String = String::new();
let mut header = String::new();
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).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"));
};
header.push_str(EMOJI_SUN);
if let Some(meta) = metadata {
if let Some(name) = meta.name
&& !name.is_empty()
{
header.push_str(name.as_str());
p_name = name.clone();
}
if let Some(display_name) = meta.display_name
&& !display_name.is_empty()
{
header.push_str(format!(" ({})", display_name).as_str())
}
} else {
header.push_str(&event.pubkey.to_bech32().unwrap());
}
header.push_str(format!(" {} {} ago", EMOJI_CLOCK, ts_ago).as_str());
blocks.push(h2(header));
match event.kind {
Kind::TextNote => {
for (block, _uri) in geminize_note(event) {
blocks.push(block);
}
}
Kind::LongFormTextNote => {
blocks.extend(blockify(md2gemtext::convert(&event.content)));
}
_ => (),
}
if mode != NoteRenderMode::Tiny {
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(),
),
));
}
}
}
let prouri = 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_hex()))
.build()?;
let reply_uri = Uri::builder()
.path_and_query(format!("/note/{}/reply", event.id.to_hex()))
.build()?;
let repost_uri = Uri::builder()
.path_and_query(format!("/note/{}/repost", event.id.to_hex()))
.build()?;
let react_uri = Uri::builder()
.path_and_query(format!("/note/{}/react", event.id.to_hex()))
.build()?;
blocks.push(Block::Link(Link::new(
prouri,
Some(
Content::new(format!("{} {} (profile)", EMOJI_ROBOT, p_name))
.unwrap_or(invalid_content()),
),
)));
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(t!("repost").into())));
blocks.push(lnk(
react_uri,
Some(format!("{} {}", emoji_by_code("smile"), t!("react"))),
));
let rsummary = nostru::reactions_summary(&reactions);
if !rsummary.is_empty() {
blocks.push(Block::Text(
Content::new(rsummary).unwrap_or(invalid_content()),
));
}
Ok(blocks)
}
pub async fn render_notes(
events: Events,
client: &Client,
idx_start: Option<usize>,
idx_end: Option<usize>,
mode: NoteRenderMode,
) -> 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)
}