Skip to main content

max / multithreaded

8.2 KB · 225 lines History Blame Raw
1 //! Thread view handler — post listing with footnotes, endorsements, link previews, tracking.
2
3 use axum::{
4 extract::{Path, Query},
5 http::StatusCode,
6 response::{IntoResponse, Response},
7 };
8 use tower_sessions::Session;
9
10 use crate::auth::MaybeUser;
11 use crate::csrf;
12 use crate::templates::*;
13 use crate::AppState;
14
15 use std::collections::HashMap;
16
17 use super::super::{
18 check_community_access, get_community, get_role, get_thread, is_mod_or_owner,
19 parse_uuid, template_user, PageQuery,
20 };
21
22 #[tracing::instrument(skip_all)]
23 pub(in crate::routes) async fn thread(
24 axum::extract::State(state): axum::extract::State<AppState>,
25 Path((slug, _category, thread_id)): Path<(String, String, String)>,
26 Query(page_query): Query<PageQuery>,
27 session: Session,
28 MaybeUser(session_user): MaybeUser,
29 ) -> Result<impl IntoResponse, Response> {
30 let csrf_token = Some(csrf::get_or_create_token(&session).await);
31
32 let thread_data = get_thread(&state.db, &thread_id).await?;
33 let community = get_community(&state.db, &slug).await?;
34
35 check_community_access(&state.db, &community, session_user.as_ref().map(|u| u.user_id)).await?;
36
37 let per_page: i64 = 50;
38
39 let thread_uuid = parse_uuid(&thread_id)?;
40 let total = mt_db::queries::count_posts_in_thread(&state.db, thread_uuid)
41 .await
42 .map_err(|e| {
43 tracing::error!(error = ?e, "db error counting posts");
44 StatusCode::INTERNAL_SERVER_ERROR.into_response()
45 })?;
46
47 let total_pages = ((total as f64) / (per_page as f64)).ceil() as u32;
48 let total_pages = total_pages.max(1);
49 let page = page_query.page.unwrap_or(1).max(1).min(total_pages);
50 let offset = (page as i64 - 1) * per_page;
51
52 let db_posts = mt_db::queries::list_posts_in_thread_paginated(
53 &state.db, thread_uuid, per_page, offset,
54 )
55 .await
56 .map_err(|e| {
57 tracing::error!(error = ?e, "db error listing posts");
58 StatusCode::INTERNAL_SERVER_ERROR.into_response()
59 })?;
60
61 // Look up user's role in this community (if logged in)
62 let role = if let Some(ref user) = session_user {
63 get_role(&state.db, user.user_id, thread_data.community_id).await?
64 } else {
65 None
66 };
67
68 let mod_status = is_mod_or_owner(&role);
69
70 // Check tracking status and update read position
71 let is_tracked = if let Some(ref user) = session_user {
72 mt_db::queries::is_thread_tracked(&state.db, user.user_id, thread_uuid)
73 .await
74 .unwrap_or(false)
75 } else {
76 false
77 };
78
79 // Batch-fetch footnotes and endorsements for all posts on this page
80 let post_ids: Vec<uuid::Uuid> = db_posts.iter().map(|p| p.id).collect();
81 let all_footnotes = mt_db::queries::list_footnotes_for_posts(&state.db, &post_ids)
82 .await
83 .map_err(|e| {
84 tracing::error!(error = ?e, "db error fetching footnotes");
85 StatusCode::INTERNAL_SERVER_ERROR.into_response()
86 })?;
87
88 let all_endorsements = mt_db::queries::list_endorsements_for_posts(&state.db, &post_ids)
89 .await
90 .map_err(|e| {
91 tracing::error!(error = ?e, "db error fetching endorsements");
92 StatusCode::INTERNAL_SERVER_ERROR.into_response()
93 })?;
94
95 // Group endorsements: counts per post + set of posts current user endorsed
96 let mut endorsement_counts: HashMap<String, u32> = HashMap::new();
97 let mut user_endorsed: std::collections::HashSet<String> = std::collections::HashSet::new();
98 for e in &all_endorsements {
99 *endorsement_counts.entry(e.post_id.to_string()).or_insert(0) += 1;
100 if session_user.as_ref().is_some_and(|u| u.user_id == e.endorser_id) {
101 user_endorsed.insert(e.post_id.to_string());
102 }
103 }
104
105 // Group footnotes by post_id
106 let mut footnotes_by_post: HashMap<String, Vec<FootnoteViewRow>> = HashMap::new();
107 for f in all_footnotes {
108 footnotes_by_post
109 .entry(f.post_id.to_string())
110 .or_default()
111 .push(FootnoteViewRow {
112 author_name: f.author_name,
113 body_html: f.body_html,
114 timestamp: mt_core::time_format::relative_timestamp(f.created_at),
115 });
116 }
117
118 // Batch-fetch link previews
119 let all_link_previews = mt_db::queries::list_link_previews_for_posts(&state.db, &post_ids)
120 .await
121 .map_err(|e| {
122 tracing::error!(error = ?e, "db error fetching link previews");
123 StatusCode::INTERNAL_SERVER_ERROR.into_response()
124 })?;
125
126 let mut link_previews_by_post: HashMap<String, Vec<LinkPreviewViewRow>> = HashMap::new();
127 for lp in all_link_previews {
128 link_previews_by_post
129 .entry(lp.post_id.to_string())
130 .or_default()
131 .push(LinkPreviewViewRow {
132 url: lp.url,
133 title: lp.title,
134 description: lp.description,
135 });
136 }
137
138 // Build quote author map for attribution rendering
139 let mut quote_authors: HashMap<uuid::Uuid, docengine::QuoteAuthor> = HashMap::new();
140 for p in &db_posts {
141 quote_authors.insert(p.id, docengine::QuoteAuthor {
142 username: p.author_username.clone(),
143 display_name: p.author_name.clone(),
144 is_removed: p.removed_at.is_some(),
145 });
146 }
147
148 let posts: Vec<PostRow> = db_posts
149 .into_iter()
150 .enumerate()
151 .map(|(i, p)| {
152 let is_removed = p.removed_at.is_some();
153 let can_add_footnote = !is_removed
154 && session_user.as_ref().is_some_and(|u| u.user_id == p.author_id);
155 let can_remove = !is_removed && mod_status;
156
157 let body_html = if is_removed {
158 String::from("<p><em>[removed by moderator]</em></p>")
159 } else {
160 docengine::post_process_quotes(&p.body_html, &quote_authors)
161 };
162
163 let post_id_str = p.id.to_string();
164 let footnotes = footnotes_by_post.remove(&post_id_str).unwrap_or_default();
165 let link_previews = link_previews_by_post.remove(&post_id_str).unwrap_or_default();
166 let endorsement_count = endorsement_counts.get(&post_id_str).copied().unwrap_or(0);
167 let is_endorsed = user_endorsed.contains(&post_id_str);
168 let can_endorse = !is_removed
169 && session_user.as_ref().is_some_and(|u| u.user_id != p.author_id);
170 let can_flag = !is_removed
171 && session_user.as_ref().is_some_and(|u| u.user_id != p.author_id);
172
173 PostRow {
174 id: post_id_str,
175 author_name: p.author_name,
176 author_username: p.author_username,
177 timestamp: mt_core::time_format::post_timestamp(p.created_at),
178 body_html,
179 is_op: i == 0 && offset == 0,
180 is_removed,
181 can_add_footnote,
182 can_remove,
183 can_flag,
184 footnotes,
185 link_previews,
186 endorsement_count,
187 is_endorsed,
188 can_endorse,
189 }
190 })
191 .collect();
192
193 // If tracked, update read position to the last post on the current page
194 if is_tracked
195 && let Some(ref user) = session_user
196 && let Some(last_post) = posts.last()
197 && let Ok(last_post_id) = uuid::Uuid::parse_str(&last_post.id)
198 {
199 let _ = mt_db::mutations::update_read_position(
200 &state.db, user.user_id, thread_uuid, last_post_id,
201 ).await;
202 }
203
204 let session_user = session_user.as_ref().map(|u| template_user(u, state.config.platform_admin_id));
205
206 Ok(ThreadTemplate {
207 csrf_token,
208 session_user,
209 mnw_base_url: state.config.mnw_base_url.clone(),
210 community_name: thread_data.community_name,
211 community_slug: thread_data.community_slug,
212 category_name: thread_data.category_name,
213 category_slug: thread_data.category_slug,
214 thread_id: thread_data.id.to_string(),
215 thread_title: thread_data.title,
216 locked: thread_data.locked,
217 pinned: thread_data.pinned,
218 is_mod: mod_status,
219 can_mod_thread: mod_status,
220 is_tracked,
221 posts,
222 pagination: Pagination::new(page, total, per_page),
223 })
224 }
225