caracal 0.2.0

Nostr client for Gemini
use std::error::Error;
use std::str::FromStr;

use http::Uri;

use gnostr_types::{ContentSegment, ShatteredContent};
use mdiu::{Block, Preformatted, Content, Level, Link};
use nostr_sdk::prelude::*;
use regex::Regex;
use urlencoding::encode;
use gemini::{Doc, gemtext::Level as GtLevel};

use crate::nostru;
use crate::ts;

use rust_i18n::t;
rust_i18n::i18n!("locales");

pub fn h1(text: String) -> Block {
    Block::Heading(Level::One, Content::new(text).unwrap())
}

pub fn h2(text: String) -> Block {
    Block::Heading(Level::Two, Content::new(text).unwrap())
}

pub fn h3(text: String) -> Block {
    Block::Heading(Level::Three, Content::new(text).unwrap())
}

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())))
    } else {
        Block::Link(Link::new(uri, None))
    }
}

/// Turn gemtext into a list of mdiu [`Block`]
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((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((
                            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" => {
                        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() {
                    // If there's an hashtag at the beginning of the line, remove it
                    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 fn render_note(
    event: &Event,
    metadata: Option<Metadata>,
    mode: NoteRenderMode,
) -> Result<Vec<Block>, Box<dyn Error>> {
    /*
     * Renders a TextNote event as gemtext
     *
     * Returns a vector of mdiu gemtext blocks
     *
     */

    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_u64());

    if metadata.is_some() {
        let meta = metadata.unwrap();

        match meta.name {
            Some(ref name) => {
                header.push_str(name.as_str());
                p_name = name.clone();
            }
            None => header.push('?'),
        }

        if let Some(ref dname) = meta.display_name {
            header.push_str(format!(" ({})", dname).as_str())
        }
    } else {
        header = event.id.to_hex();
    }

    header.push_str(format!(" ({} ago)", ts_ago).as_str());

    blocks.push(h3(header));

    match event.kind {
        Kind::TextNote => {
            for (block, _uri) in geminize_note(event) {
                blocks.push(block);
            }
        }
        Kind::LongFormTextNote => {
            // Convert markdown to gemtext and map to mdiu blocks
            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!("Tag: {}", 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)", p_name)).unwrap()),
    )));

    blocks.push(lnk(thread_uri, Some(t!("thread").into())));
    blocks.push(lnk(reply_uri, Some(t!("reply").into())));
    blocks.push(lnk(repost_uri, Some(t!("repost").into())));
    blocks.push(lnk(react_uri, Some(t!("react").into())));

    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(event, usermeta, mode) {
            Ok(mut blocks) => {
                gemb.append(&mut blocks);
            }
            Err(_err) => {
                gemb.push(Block::Text(
                    Content::new("Cannot process note").unwrap(),
                ));
            }
        }
    }

    Ok(gemb)
}