Skip to main content

max / makenotwork

8.4 KB · 233 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 pagination = Pagination::new(page_query.page.unwrap_or(1).max(1), total, per_page);
48 let offset = pagination.offset(per_page);
49
50 let db_posts = mt_db::queries::list_posts_in_thread_paginated(
51 &state.db, thread_uuid, per_page, offset,
52 )
53 .await
54 .map_err(|e| {
55 tracing::error!(error = ?e, "db error listing posts");
56 StatusCode::INTERNAL_SERVER_ERROR.into_response()
57 })?;
58
59 // Look up user's role in this community (if logged in)
60 let role = if let Some(ref user) = session_user {
61 get_role(&state.db, user.user_id, thread_data.community_id).await?
62 } else {
63 None
64 };
65
66 let mod_status = is_mod_or_owner(&role);
67
68 // Check tracking status and update read position
69 let is_tracked = if let Some(ref user) = session_user {
70 mt_db::queries::is_thread_tracked(&state.db, user.user_id, thread_uuid)
71 .await
72 .unwrap_or(false)
73 } else {
74 false
75 };
76
77 // Batch-fetch footnotes and endorsements for all posts on this page
78 let post_ids: Vec<uuid::Uuid> = db_posts.iter().map(|p| p.id).collect();
79 let all_footnotes = mt_db::queries::list_footnotes_for_posts(&state.db, &post_ids)
80 .await
81 .map_err(|e| {
82 tracing::error!(error = ?e, "db error fetching footnotes");
83 StatusCode::INTERNAL_SERVER_ERROR.into_response()
84 })?;
85
86 let all_endorsements = mt_db::queries::list_endorsements_for_posts(&state.db, &post_ids)
87 .await
88 .map_err(|e| {
89 tracing::error!(error = ?e, "db error fetching endorsements");
90 StatusCode::INTERNAL_SERVER_ERROR.into_response()
91 })?;
92
93 // Group endorsements: counts per post + set of posts current user endorsed
94 let mut endorsement_counts: HashMap<String, u32> = HashMap::new();
95 let mut user_endorsed: std::collections::HashSet<String> = std::collections::HashSet::new();
96 for e in &all_endorsements {
97 *endorsement_counts.entry(e.post_id.to_string()).or_insert(0) += 1;
98 if session_user.as_ref().is_some_and(|u| u.user_id == e.endorser_id) {
99 user_endorsed.insert(e.post_id.to_string());
100 }
101 }
102
103 // Group footnotes by post_id
104 let mut footnotes_by_post: HashMap<String, Vec<FootnoteViewRow>> = HashMap::new();
105 for f in all_footnotes {
106 footnotes_by_post
107 .entry(f.post_id.to_string())
108 .or_default()
109 .push(FootnoteViewRow {
110 author_name: f.author_name,
111 body_html: f.body_html,
112 timestamp: mt_core::time_format::relative_timestamp(f.created_at),
113 });
114 }
115
116 // Batch-fetch link previews
117 let all_link_previews = mt_db::queries::list_link_previews_for_posts(&state.db, &post_ids)
118 .await
119 .map_err(|e| {
120 tracing::error!(error = ?e, "db error fetching link previews");
121 StatusCode::INTERNAL_SERVER_ERROR.into_response()
122 })?;
123
124 let mut link_previews_by_post: HashMap<String, Vec<LinkPreviewViewRow>> = HashMap::new();
125 for lp in all_link_previews {
126 link_previews_by_post
127 .entry(lp.post_id.to_string())
128 .or_default()
129 .push(LinkPreviewViewRow {
130 url: lp.url,
131 title: lp.title,
132 description: lp.description,
133 });
134 }
135
136 // Build quote author map for attribution rendering
137 let mut quote_authors: HashMap<uuid::Uuid, docengine::QuoteAuthor> = HashMap::new();
138 for p in &db_posts {
139 quote_authors.insert(p.id, docengine::QuoteAuthor {
140 username: p.author_username.clone(),
141 display_name: p.author_name.clone(),
142 is_removed: p.removed_at.is_some(),
143 });
144 }
145
146 let posts: Vec<PostRow> = db_posts
147 .into_iter()
148 .enumerate()
149 .map(|(i, p)| {
150 let is_removed = p.removed_at.is_some();
151 let can_add_footnote = !is_removed
152 && session_user.as_ref().is_some_and(|u| u.user_id == p.author_id);
153 let can_remove = !is_removed && mod_status;
154
155 let body_html = if is_removed {
156 String::from("<p><em>[removed by moderator]</em></p>")
157 } else {
158 docengine::post_process_quotes(&p.body_html, &quote_authors)
159 };
160
161 let post_id_str = p.id.to_string();
162 let footnotes = footnotes_by_post.remove(&post_id_str).unwrap_or_default();
163 let link_previews = link_previews_by_post.remove(&post_id_str).unwrap_or_default();
164 let endorsement_count = endorsement_counts.get(&post_id_str).copied().unwrap_or(0);
165 let is_endorsed = user_endorsed.contains(&post_id_str);
166 let can_endorse = !is_removed
167 && session_user.as_ref().is_some_and(|u| u.user_id != p.author_id);
168 let can_flag = !is_removed
169 && session_user.as_ref().is_some_and(|u| u.user_id != p.author_id);
170
171 // Signatures are gated on current Fan+ status: a lapsed Fan+ user's
172 // saved signature is hidden until they renew. Same for the + badge.
173 let author_signature_html = if p.author_is_fan_plus {
174 p.author_signature_html
175 } else {
176 None
177 };
178
179 PostRow {
180 id: post_id_str,
181 author_name: p.author_name,
182 author_username: p.author_username,
183 timestamp: mt_core::time_format::post_timestamp(p.created_at),
184 body_html,
185 is_op: i == 0 && offset == 0,
186 is_removed,
187 can_add_footnote,
188 can_remove,
189 can_flag,
190 footnotes,
191 link_previews,
192 endorsement_count,
193 is_endorsed,
194 can_endorse,
195 author_has_plus_badge: p.author_is_fan_plus,
196 author_signature_html,
197 }
198 })
199 .collect();
200
201 // If tracked, update read position to the last post on the current page
202 if is_tracked
203 && let Some(ref user) = session_user
204 && let Some(last_post) = posts.last()
205 && let Ok(last_post_id) = uuid::Uuid::parse_str(&last_post.id)
206 {
207 let _ = mt_db::mutations::update_read_position(
208 &state.db, user.user_id, thread_uuid, last_post_id,
209 ).await;
210 }
211
212 let session_user = session_user.as_ref().map(|u| template_user(u, state.config.platform_admin_id));
213
214 Ok(ThreadTemplate {
215 csrf_token,
216 session_user,
217 mnw_base_url: state.config.mnw_base_url.clone(),
218 community_name: thread_data.community_name,
219 community_slug: thread_data.community_slug,
220 category_name: thread_data.category_name,
221 category_slug: thread_data.category_slug,
222 thread_id: thread_data.id.to_string(),
223 thread_title: thread_data.title,
224 locked: thread_data.locked,
225 pinned: thread_data.pinned,
226 is_mod: mod_status,
227 can_mod_thread: mod_status,
228 is_tracked,
229 posts,
230 pagination,
231 })
232 }
233