//! Private messages (templates/inbox.tpl.php + inboxuser.tpl.php). use askama::Template; use axum::{ extract::{Path, State}, response::{IntoResponse, Redirect}, }; use serde::Deserialize; use crate::{ app::{AppResult, AppState, Ctx, Inner}, browse::{blocked_ids, render}, layout::Layout, util, }; struct Conversation { id: i64, name: String, name_enc: String, avatar: String, images: i64, website: Option, title: String, border: &'static str, } #[derive(Template)] #[template(path = "inbox.html")] struct InboxPage { l: Layout, user_not_found: bool, user_blocked: bool, convos: Vec, } struct Message { from_name: String, from_enc: String, date: String, body: String, border: String, } #[derive(Template)] #[template(path = "inboxuser.html")] struct ConversationPage { l: Layout, border: &'static str, id: i64, name: String, name_enc: String, avatar: String, images: i64, website: Option, messages: Vec, } #[derive(sqlx::FromRow)] struct ConvoRow { id: i64, name: String, avatar: String, images: i64, website: String, title: Option, unread: i64, } async fn inbox_page(st: &Inner, ctx: &Ctx, user_not_found: bool, user_blocked: bool) -> AppResult { let uid = ctx.uid(); let rows: Vec = sqlx::query_as( "SELECT u.id, u.name, u.avatar, u.images, u.website, \ (SELECT title_m FROM messages WHERE from_m = m.from_m AND to_m = ?1 ORDER BY created DESC, id DESC LIMIT 1) AS title, \ (SELECT COUNT(*) FROM messages WHERE from_m = m.from_m AND to_m = ?1 AND read_m = 0) AS unread \ FROM messages m JOIN users u ON u.id = m.from_m WHERE m.to_m = ?1 \ GROUP BY m.from_m ORDER BY MAX(m.created) DESC", ) .bind(uid) .fetch_all(&st.db) .await?; let blocked = blocked_ids(st, uid).await?; let read_border = if ctx.white() { " border:1px solid #d2d9e7; " } else { "border:1px solid #333;" }; let convos = rows .into_iter() .filter(|r| !blocked.contains(&r.id)) .map(|r| { let title = r.title.unwrap_or_default(); Conversation { id: r.id, name_enc: util::path_enc(&r.name), name: r.name, avatar: r.avatar, images: r.images, website: util::safe_website(&r.website), title: if title.chars().count() > 1 { util::autolink_text(&util::strip_tags(&title)) } else { String::new() }, border: if r.unread > 0 { " border:1px solid #FFD600; " } else { read_border }, } }) .collect(); render(InboxPage { l: Layout::new(st, ctx).await?, user_not_found, user_blocked, convos }) } pub async fn inbox(State(st): State, ctx: Ctx) -> AppResult { if ctx.user.is_none() { return Ok(Redirect::to("/login").into_response()); } inbox_page(&st, &ctx, false, false).await } pub async fn conversation(State(st): State, ctx: Ctx, Path(name): Path) -> AppResult { let Some(me) = &ctx.user else { return Ok(Redirect::to("/login").into_response()) }; if !util::valid_username(&name) { return Ok(Redirect::to("/").into_response()); } let other: Option<(i64, String, String, i64, String)> = sqlx::query_as("SELECT id, name, avatar, images, website FROM users WHERE name = ?") .bind(&name) .fetch_optional(&st.db) .await?; let Some((oid, oname, avatar, images, website)) = other else { return Ok(Redirect::to("/").into_response()) }; let rows: Vec<(i64, i64, String, i64, i64, Option)> = sqlx::query_as( "SELECT m.from_m, m.to_m, m.message_m, m.read_m, m.created, u.name FROM messages m LEFT JOIN users u ON u.id = m.from_m \ WHERE (m.to_m = ?1 AND m.from_m = ?2) OR (m.to_m = ?2 AND m.from_m = ?1) ORDER BY m.created DESC, m.id DESC", ) .bind(oid) .bind(me.id) .fetch_all(&st.db) .await?; sqlx::query("UPDATE messages SET read_m = 1 WHERE to_m = ? AND from_m = ?") .bind(me.id) .bind(oid) .execute(&st.db) .await?; let border = if ctx.white() { " border:1px solid #d2d9e7; " } else { "border:1px dotted #333;" }; let messages = rows .into_iter() .map(|(_, to, body, read, created, from_name)| { let from_name = from_name.unwrap_or_default(); Message { from_enc: util::path_enc(&from_name), from_name, date: util::date("d. M Y H:i", created), body: util::autolink_text(&body), border: if read == 0 && to == me.id { "border:1px solid #FFD600".into() } else { border.into() }, } }) .collect(); render(ConversationPage { l: Layout::new(&st, &ctx).await?, border, id: oid, name_enc: util::path_enc(&oname), name: oname, avatar, images, website: util::safe_website(&website), messages, }) } #[derive(Deserialize, Default)] #[serde(default)] pub struct MessageForm { title: Option, content: Option, #[serde(rename = "_csrf")] csrf: String, } pub async fn send(State(st): State, ctx: Ctx, Path(name): Path, axum::Form(f): axum::Form) -> AppResult { let Some(me) = ctx.user.clone() else { return Ok(Redirect::to("/login").into_response()) }; ctx.check_csrf(&f.csrf)?; if !util::valid_username(&name) { return Ok(Redirect::to("/").into_response()); } let target: Option<(i64, String)> = sqlx::query_as("SELECT id, name FROM users WHERE name = ?") .bind(&name) .fetch_optional(&st.db) .await?; let Some((tid, tname)) = target else { return inbox_page(&st, &ctx, true, false).await }; let blocked_me: Option = sqlx::query_scalar("SELECT 1 FROM blocked WHERE user_id = ? AND blocked_id = ?") .bind(tid) .bind(me.id) .fetch_optional(&st.db) .await?; if blocked_me.is_some() { return inbox_page(&st, &ctx, false, true).await; } let content = f.content.unwrap_or_default(); let title = f.title.unwrap_or_default(); if !content.trim().is_empty() || !title.trim().is_empty() { st.limiter.check("message", &me.id.to_string(), 20, 60)?; sqlx::query("INSERT INTO messages (from_m, to_m, created, message_m, title_m) VALUES (?, ?, ?, ?, ?)") .bind(me.id) .bind(tid) .bind(util::now()) .bind(content.chars().take(20_000).collect::()) .bind(title.chars().take(500).collect::()) .execute(&st.db) .await?; } Ok(Redirect::to(&format!("/inbox/{}", util::path_enc(&tname))).into_response()) }