use std::time::Duration;
use crate::aska::WindTemplate;
use crate::emoji::*;
use crate::nostru;
use crate::rendering;
use crate::routes::response::*;
use crate::user::CaracalUser;
use crate::util::{decode_query, extract_first_words, titan_to_blossom};
use crate::routes::{
gem, resp_error_send_note, resp_note_prompt_text, resp_redirect_root,
};
use crate::urls::*;
use nostr_sdk::prelude::*;
use askama::Template;
use gnostr_types::{ContentSegment, ShatteredContent};
use hashtag::HashtagParser;
use mdiu::Block;
use rust_i18n::t;
use urlencoding::{decode, encode};
use windmark_titanesque::{
context::{Parameters, RouteContext},
response::Response,
};
use rand::distributions::{Alphanumeric, DistString};
#[derive(Template)]
#[template(path = "note.gmi", escape = "txt")]
pub struct NoteThreadTemplate {
title: Option<String>,
event: Event,
note_blocks: Vec<Block>,
replies_blocks: Option<Vec<Block>>,
}
#[derive(Template)]
#[template(path = "note_draft_edit.gmi", escape = "txt")]
pub struct NoteDraftEditTemplate<'c> {
url: Url,
draft_id: String,
draft_body: String,
draft_attachments: Vec<&'c str>,
}
#[derive(Template)]
#[template(path = "note_reaction_select.gmi", escape = "txt")]
pub struct NoteReactionSelect {
event_id: EventId,
}
pub struct NoteContext {
event: Event,
}
async fn note_context(
params: Parameters,
client: &Client,
) -> Option<NoteContext> {
let Some(evid) = params.get("event_id") else {
return None;
};
let Ok(event_id) = EventId::parse(evid) else {
return None;
};
return match client.database().event_by_id(&event_id).await {
Ok(ev) => {
return if ev.is_some() {
Some(NoteContext { event: ev.unwrap() })
} else {
None
};
}
Err(_err) => None,
};
}
fn body_hashtags(body: &str) -> Vec<Tag> {
HashtagParser::new(body)
.map(|ht| Tag::hashtag(&ht.text))
.collect()
}
pub async fn post_note(
ctx: RouteContext,
user: &'static mut CaracalUser,
) -> Response {
let pow: u8 = match ctx.parameters.get("pow") {
Some(value) => match value.to_string().parse::<u8>() {
Ok(int) => int,
Err(_error) => 0,
},
None => 0,
};
let url = ctx.url.clone();
if let Some(text) = dec_urlq(&url) {
let mut tags = Vec::new();
let shattered_content = ShatteredContent::new(text.clone());
for segment in shattered_content.segments.iter() {
if let ContentSegment::NostrUrl(nostr_url) = segment
&& let Ok(n21) = Nip21::parse(&format!("{nostr_url}"))
&& let Nip21::Pubkey(pubk) = n21
{
tags.push(Tag::public_key(pubk));
}
}
if let Some(url_hashtag) = ctx.parameters.get("hashtag") {
tags.push(Tag::hashtag(url_hashtag));
}
let eventb = EventBuilder::text_note(&text)
.tags(body_hashtags(&text))
.tags(tags);
let event = match pow {
5 | 10 | 15 | 20 | 25 | 30 | 35 | 40 => {
eventb.pow(pow).sign(&user.signer).await.unwrap()
}
0 => eventb.sign(&user.signer).await.unwrap(),
_ => todo!(),
};
if let Ok(_event_id) = user.client.send_event(&event).await {
Response::permanent_redirect("/feed")
} else {
Response::temporary_failure("Error")
}
} else {
resp_note_prompt_text()
}
}
pub async fn note_thread(
ctx: RouteContext,
user: &'static mut CaracalUser,
) -> Response {
let mut doc = vec![];
let mut rblocks = vec![];
let evid = ctx.parameters.get("event_id").unwrap();
if let Ok(event_id) = EventId::parse(evid) {
let e_filter = Filter::new()
.kind(Kind::TextNote)
.kind(Kind::LongFormTextNote)
.id(event_id);
let Ok(events) = user
.client
.fetch_combined_events(e_filter, Duration::from_secs(3))
.await
else {
return Response::temporary_failure(t!("fetch_events_failed"));
};
let Some(event) = events.first() else {
return Response::temporary_failure(t!("no_such_event"));
};
let rf_filter = Filter::new().kind(Kind::TextNote).event(event_id);
let Ok(replies_events) = user
.client
.fetch_combined_events(rf_filter, Duration::from_secs(3))
.await
else {
return Response::temporary_failure(t!(
"fetch_note_replies_failed"
));
};
let usermeta =
nostru::get_user_metadata(&user.client, event.pubkey).await;
let title = extract_first_words(event);
match rendering::render_note(
&user.client,
event,
usermeta.clone(),
rendering::NoteRenderMode::Normal,
)
.await
{
Ok(mut blocks) => {
doc.append(&mut blocks);
}
Err(_err) => {}
}
for revent in replies_events {
let rusermeta =
nostru::get_user_metadata(&user.client, revent.pubkey).await;
if let Ok(mut blks) = rendering::render_note(
&user.client,
&revent,
rusermeta.clone(),
rendering::NoteRenderMode::Normal,
)
.await
{
rblocks.append(&mut blks);
}
}
let tmpl = NoteThreadTemplate {
title,
event: event.clone(),
note_blocks: doc,
replies_blocks: Some(rblocks),
};
Response::success(WindTemplate::render(tmpl))
} else {
Response::temporary_failure(t!("invalid_event_id"))
}
}
pub async fn note_reply(
ctx: RouteContext,
user: &'static mut CaracalUser,
) -> Response {
let Some(note_ctx) = note_context(ctx.parameters, &user.client).await
else {
return resp_invalid_params();
};
let url = ctx.url.clone();
if let Some(text) = dec_urlq(&url) {
let builder =
EventBuilder::text_note_reply(&text, ¬e_ctx.event, None, None)
.tags(body_hashtags(&text));
match user.client.send_event_builder(builder).await {
Ok(_) => Response::temporary_redirect(format!(
"/note/{}/thread",
note_ctx.event.id
)),
Err(err) => Response::temporary_failure(format!("{err}")),
}
} else {
resp_note_prompt_text()
}
}
pub async fn note_repost(
ctx: RouteContext,
user: &'static mut CaracalUser,
) -> Response {
let Some(note_ctx) = note_context(ctx.parameters, &user.client).await
else {
return resp_invalid_params();
};
let Ok(event) = EventBuilder::repost(¬e_ctx.event, None)
.sign(&user.signer)
.await
else {
return resp_invalid_params();
};
let Ok(_) = user.client.send_event(&event).await else {
return resp_error_send_note();
};
resp_redirect_root()
}
pub async fn note_react_custom(
ctx: RouteContext,
user: &'static mut CaracalUser,
) -> Response {
let Some(emoji) = decode_query(&ctx) else {
return Response::input(t!("input_reaction_emoji"));
};
let Some(note_ctx) = note_context(ctx.parameters, &user.client).await
else {
return resp_invalid_params();
};
let builder = EventBuilder::reaction(¬e_ctx.event, emoji);
match user.client.send_event_builder(builder).await {
Ok(_) => Response::temporary_redirect(format!(
"/note/{}/thread",
note_ctx.event.id
)),
Err(err) => {
Response::temporary_failure(format!("Send event failed: {err}"))
}
}
}
pub async fn note_react_select(
ctx: RouteContext,
user: &'static mut CaracalUser,
) -> Response {
let Some(note_ctx) = note_context(ctx.parameters, &user.client).await
else {
return resp_invalid_params();
};
Response::success(WindTemplate::render(NoteReactionSelect {
event_id: note_ctx.event.id,
}))
}
pub async fn draft_new(
ctx: RouteContext,
_user: &'static mut CaracalUser,
) -> Response {
let id = Alphanumeric
.sample_string(&mut rand::thread_rng(), 16)
.to_string();
Response::temporary_redirect(titan_url(
ctx.url,
&format!("/draft/{}/edit", id),
))
}
pub async fn draft_edit(
ctx: RouteContext,
user: &'static mut CaracalUser,
) -> Response {
let page_url = ctx.url.clone();
let Some(draft_id) = ctx.parameters.get("draft_id") else {
return resp_invalid_params();
};
if let Some(titan) = ctx.titan_rsc {
let Ok(rsc_text) = titan.content_text() else {
return resp_error_generic();
};
let rsc_text = rsc_text
.lines()
.filter(|l| !l.starts_with("=> titan://"))
.filter(|l| !l.starts_with("=> gemini://"))
.filter(|l| !l.is_empty())
.collect::<Vec<&str>>()
.join("\n");
let _ = user.storage.draft_store(&draft_id, rsc_text.as_str());
}
let Ok(mut rtxn) = user.storage.get_read_txn() else {
return resp_invalid_params();
};
let Ok(draft) = user.storage.draft_get(&draft_id, &mut rtxn) else {
return resp_error_generic();
};
let template = NoteDraftEditTemplate {
url: page_url,
draft_id: draft_id.to_string(),
draft_body: draft.content.to_string(),
draft_attachments: draft.attachments,
};
Response::success(WindTemplate::render(template))
}
pub async fn draft_body_append(
ctx: RouteContext,
user: &'static mut CaracalUser,
) -> Response {
let Some(text) = ctx.url.query() else {
return Response::input("Text ?");
};
let Ok(text) = decode(text) else {
return resp_invalid_params();
};
let Some(draft_id) = ctx.parameters.get("draft_id") else {
return resp_invalid_params();
};
let Ok(mut rtxn) = user.storage.get_read_txn() else {
return resp_error_generic();
};
let Ok(mut content) = user.storage.draft_get_content(&draft_id, &mut rtxn)
else {
return resp_error_generic();
};
content.push_str(&format!("\n{text}\n"));
match user.storage.draft_store(&draft_id, &content) {
Ok(_) => Response::temporary_redirect(format!(
"gemini://{}/draft/{}/edit",
ctx.url.authority(),
draft_id
)),
Err(_) => Response::temporary_failure("Draft store failure"),
}
}
pub async fn draft_post(
ctx: RouteContext,
user: &'static mut CaracalUser,
) -> Response {
let Some(draft_id) = ctx.parameters.get("draft_id") else {
return resp_invalid_params();
};
let Ok(mut rtxn) = user.storage.get_read_txn() else {
return resp_invalid_params();
};
let Ok(draft) = user.storage.draft_get(&draft_id, &mut rtxn) else {
return resp_error_generic();
};
let body = draft.render_text();
let event: Event = EventBuilder::text_note(&body)
.tags(body_hashtags(&body))
.sign(&user.signer)
.await
.unwrap();
let Ok(_) = user.client.send_event(&event).await else {
return resp_error_send_note();
};
resp_redirect_root()
}
pub async fn draft_upload_file(
ctx: RouteContext,
user: &'static mut CaracalUser,
) -> Response {
let Some(draft_id) = ctx.parameters.get("draft_id") else {
return resp_invalid_params();
};
let Some(titan_file) = ctx.titan_rsc else {
return Response::temporary_failure("No file");
};
match titan_to_blossom(titan_file, user.signer.clone()).await {
Ok(desc) => {
let Ok(mut rtxn) = user.storage.get_read_txn() else {
return resp_invalid_params();
};
let Ok(mut content) =
user.storage.draft_get_content(&draft_id, &mut rtxn)
else {
return resp_error_generic();
};
content.push_str(&format!("\n{}\n", desc.url));
let _ = user.storage.draft_store(&draft_id, &content);
Response::temporary_redirect(format!(
"gemini://{}/draft/{}/edit",
ctx.url.authority(),
draft_id
))
}
Err(error) => {
Response::temporary_failure(format!("Upload error: {error}"))
}
}
}