//! Thread view handler — post listing with footnotes, endorsements, link previews, tracking. use axum::{ extract::{Path, Query}, http::StatusCode, response::{IntoResponse, Response}, }; use tower_sessions::Session; use crate::auth::MaybeUser; use crate::csrf; use crate::templates::*; use crate::AppState; use std::collections::HashMap; use super::super::{ check_community_access, get_community, get_role, get_thread, is_mod_or_owner, parse_uuid, template_user, PageQuery, }; #[tracing::instrument(skip_all)] pub(in crate::routes) async fn thread( axum::extract::State(state): axum::extract::State, Path((slug, _category, thread_id)): Path<(String, String, String)>, Query(page_query): Query, session: Session, MaybeUser(session_user): MaybeUser, ) -> Result { let csrf_token = Some(csrf::get_or_create_token(&session).await); let thread_data = get_thread(&state.db, &thread_id).await?; let community = get_community(&state.db, &slug).await?; check_community_access(&state.db, &community, session_user.as_ref().map(|u| u.user_id)).await?; let per_page: i64 = 50; let thread_uuid = parse_uuid(&thread_id)?; let total = mt_db::queries::count_posts_in_thread(&state.db, thread_uuid) .await .map_err(|e| { tracing::error!(error = ?e, "db error counting posts"); StatusCode::INTERNAL_SERVER_ERROR.into_response() })?; let pagination = Pagination::new(page_query.page.unwrap_or(1).max(1), total, per_page); let offset = pagination.offset(per_page); let db_posts = mt_db::queries::list_posts_in_thread_paginated( &state.db, thread_uuid, per_page, offset, ) .await .map_err(|e| { tracing::error!(error = ?e, "db error listing posts"); StatusCode::INTERNAL_SERVER_ERROR.into_response() })?; // Look up user's role in this community (if logged in) let role = if let Some(ref user) = session_user { get_role(&state.db, user.user_id, thread_data.community_id).await? } else { None }; let mod_status = is_mod_or_owner(&role); // Check tracking status and update read position let is_tracked = if let Some(ref user) = session_user { mt_db::queries::is_thread_tracked(&state.db, user.user_id, thread_uuid) .await .unwrap_or(false) } else { false }; // Batch-fetch footnotes and endorsements for all posts on this page let post_ids: Vec = db_posts.iter().map(|p| p.id).collect(); let all_footnotes = mt_db::queries::list_footnotes_for_posts(&state.db, &post_ids) .await .map_err(|e| { tracing::error!(error = ?e, "db error fetching footnotes"); StatusCode::INTERNAL_SERVER_ERROR.into_response() })?; let all_endorsements = mt_db::queries::list_endorsements_for_posts(&state.db, &post_ids) .await .map_err(|e| { tracing::error!(error = ?e, "db error fetching endorsements"); StatusCode::INTERNAL_SERVER_ERROR.into_response() })?; // Group endorsements: counts per post + set of posts current user endorsed let mut endorsement_counts: HashMap = HashMap::new(); let mut user_endorsed: std::collections::HashSet = std::collections::HashSet::new(); for e in &all_endorsements { *endorsement_counts.entry(e.post_id.to_string()).or_insert(0) += 1; if session_user.as_ref().is_some_and(|u| u.user_id == e.endorser_id) { user_endorsed.insert(e.post_id.to_string()); } } // Group footnotes by post_id let mut footnotes_by_post: HashMap> = HashMap::new(); for f in all_footnotes { footnotes_by_post .entry(f.post_id.to_string()) .or_default() .push(FootnoteViewRow { author_name: f.author_name, body_html: f.body_html, timestamp: mt_core::time_format::relative_timestamp(f.created_at), }); } // Batch-fetch link previews let all_link_previews = mt_db::queries::list_link_previews_for_posts(&state.db, &post_ids) .await .map_err(|e| { tracing::error!(error = ?e, "db error fetching link previews"); StatusCode::INTERNAL_SERVER_ERROR.into_response() })?; let mut link_previews_by_post: HashMap> = HashMap::new(); for lp in all_link_previews { link_previews_by_post .entry(lp.post_id.to_string()) .or_default() .push(LinkPreviewViewRow { url: lp.url, title: lp.title, description: lp.description, }); } // Build quote author map for attribution rendering let mut quote_authors: HashMap = HashMap::new(); for p in &db_posts { quote_authors.insert(p.id, docengine::QuoteAuthor { username: p.author_username.clone(), display_name: p.author_name.clone(), is_removed: p.removed_at.is_some(), }); } let posts: Vec = db_posts .into_iter() .enumerate() .map(|(i, p)| { let is_removed = p.removed_at.is_some(); let can_add_footnote = !is_removed && session_user.as_ref().is_some_and(|u| u.user_id == p.author_id); let can_remove = !is_removed && mod_status; let body_html = if is_removed { String::from("

[removed by moderator]

") } else { docengine::post_process_quotes(&p.body_html, "e_authors) }; let post_id_str = p.id.to_string(); let footnotes = footnotes_by_post.remove(&post_id_str).unwrap_or_default(); let link_previews = link_previews_by_post.remove(&post_id_str).unwrap_or_default(); let endorsement_count = endorsement_counts.get(&post_id_str).copied().unwrap_or(0); let is_endorsed = user_endorsed.contains(&post_id_str); let can_endorse = !is_removed && session_user.as_ref().is_some_and(|u| u.user_id != p.author_id); let can_flag = !is_removed && session_user.as_ref().is_some_and(|u| u.user_id != p.author_id); // Signatures are gated on current Fan+ status: a lapsed Fan+ user's // saved signature is hidden until they renew. Same for the + badge. let author_signature_html = if p.author_is_fan_plus { p.author_signature_html } else { None }; PostRow { id: post_id_str, author_name: p.author_name, author_username: p.author_username, timestamp: mt_core::time_format::post_timestamp(p.created_at), body_html, is_op: i == 0 && offset == 0, is_removed, can_add_footnote, can_remove, can_flag, footnotes, link_previews, endorsement_count, is_endorsed, can_endorse, author_has_plus_badge: p.author_is_fan_plus, author_signature_html, } }) .collect(); // If tracked, update read position to the last post on the current page if is_tracked && let Some(ref user) = session_user && let Some(last_post) = posts.last() && let Ok(last_post_id) = uuid::Uuid::parse_str(&last_post.id) { let _ = mt_db::mutations::update_read_position( &state.db, user.user_id, thread_uuid, last_post_id, ).await; } let session_user = session_user.as_ref().map(|u| template_user(u, state.config.platform_admin_id)); Ok(ThreadTemplate { csrf_token, session_user, mnw_base_url: state.config.mnw_base_url.clone(), community_name: thread_data.community_name, community_slug: thread_data.community_slug, category_name: thread_data.category_name, category_slug: thread_data.category_slug, thread_id: thread_data.id.to_string(), thread_title: thread_data.title, locked: thread_data.locked, pinned: thread_data.pinned, is_mod: mod_status, can_mod_thread: mod_status, is_tracked, posts, pagination, }) }