Skip to main content

max / makenotwork

9.5 KB · 262 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 response::{IntoResponse, Response},
6 };
7 use tower_sessions::Session;
8
9 use crate::AppState;
10 use crate::auth::MaybeUser;
11 use crate::csrf;
12 use crate::templates::{FootnoteViewRow, LinkPreviewViewRow, Pagination, PostRow, ThreadTemplate};
13
14 use std::collections::HashMap;
15
16 use super::super::{
17 CommunityScope, PageQuery, check_community_access, db_error, is_mod_or_owner, parse_uuid,
18 template_user,
19 };
20 use mt_db::queries::ThreadWithBreadcrumb;
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 scope =
33 CommunityScope::<ThreadWithBreadcrumb>::resolve(&state.db, &slug, &thread_id).await?;
34 check_community_access(
35 &state.db,
36 &scope.community,
37 session_user.as_ref().map(|u| u.user_id),
38 )
39 .await?;
40
41 // Role lookup must precede moving the resource out of the scope (it borrows
42 // the verified community).
43 let role = if let Some(ref user) = session_user {
44 scope.role(&state.db, user.user_id).await?
45 } else {
46 None
47 };
48 let thread_data = scope.resource;
49
50 let per_page: i64 = 50;
51
52 let thread_uuid = parse_uuid(&thread_id)?;
53 let total = mt_db::queries::count_posts_in_thread(&state.db, thread_uuid)
54 .await
55 .map_err(db_error)?;
56
57 let pagination = Pagination::new(page_query.page.unwrap_or(1).max(1), total, per_page);
58 let offset = pagination.offset(per_page);
59
60 let db_posts =
61 mt_db::queries::list_posts_in_thread_paginated(&state.db, thread_uuid, per_page, offset)
62 .await
63 .map_err(db_error)?;
64
65 let mod_status = is_mod_or_owner(role);
66
67 // Check tracking status and update read position
68 let is_tracked = if let Some(ref user) = session_user {
69 mt_db::queries::is_thread_tracked(&state.db, user.user_id, thread_uuid)
70 .await
71 .unwrap_or(false)
72 } else {
73 false
74 };
75
76 // Batch-fetch footnotes, endorsements, and link previews for all posts on
77 // this page. They're independent (all keyed off the same post_ids), so run
78 // them concurrently rather than as three serial round-trips.
79 let post_ids: Vec<uuid::Uuid> = db_posts.iter().map(|p| p.id).collect();
80 let (all_footnotes, endorsement_totals, all_link_previews) = tokio::try_join!(
81 async {
82 mt_db::queries::list_footnotes_for_posts(&state.db, &post_ids)
83 .await
84 .map_err(db_error)
85 },
86 async {
87 // Aggregate in the DB rather than streaming every endorsement row: a
88 // hot post's thousands of endorsements must not load per page view.
89 mt_db::queries::count_endorsements_for_posts(&state.db, &post_ids)
90 .await
91 .map_err(db_error)
92 },
93 async {
94 mt_db::queries::list_link_previews_for_posts(&state.db, &post_ids)
95 .await
96 .map_err(db_error)
97 },
98 )?;
99
100 // Counts per post from the aggregate; the viewer's own endorsements come from
101 // a separate bounded query (only when logged in) so we never load endorser ids.
102 let mut endorsement_counts: HashMap<String, u32> = HashMap::new();
103 for e in &endorsement_totals {
104 endorsement_counts.insert(e.post_id.to_string(), e.count.max(0) as u32);
105 }
106 let mut user_endorsed: std::collections::HashSet<String> = std::collections::HashSet::new();
107 if let Some(u) = session_user.as_ref() {
108 let mine = mt_db::queries::list_user_endorsed_posts(&state.db, &post_ids, u.user_id)
109 .await
110 .map_err(db_error)?;
111 for post_id in mine {
112 user_endorsed.insert(post_id.to_string());
113 }
114 }
115
116 // Group footnotes by post_id
117 let mut footnotes_by_post: HashMap<String, Vec<FootnoteViewRow>> = HashMap::new();
118 for f in all_footnotes {
119 footnotes_by_post
120 .entry(f.post_id.to_string())
121 .or_default()
122 .push(FootnoteViewRow {
123 author_name: f.author_name,
124 body_html: f.body_html,
125 timestamp: mt_core::time_format::relative_timestamp(f.created_at),
126 });
127 }
128
129 let mut link_previews_by_post: HashMap<String, Vec<LinkPreviewViewRow>> = HashMap::new();
130 for lp in all_link_previews {
131 link_previews_by_post
132 .entry(lp.post_id.to_string())
133 .or_default()
134 .push(LinkPreviewViewRow {
135 url: lp.url,
136 title: lp.title,
137 description: lp.description,
138 });
139 }
140
141 // Build quote author map for attribution rendering
142 let mut quote_authors: HashMap<uuid::Uuid, docengine::QuoteAuthor> = HashMap::new();
143 for p in &db_posts {
144 quote_authors.insert(
145 p.id,
146 docengine::QuoteAuthor {
147 username: p.author_username.clone(),
148 display_name: p.author_name.clone(),
149 is_removed: p.removed_at.is_some(),
150 },
151 );
152 }
153
154 let posts: Vec<PostRow> = db_posts
155 .into_iter()
156 .enumerate()
157 .map(|(i, p)| {
158 let is_removed = p.removed_at.is_some();
159 let can_add_footnote = !is_removed
160 && session_user
161 .as_ref()
162 .is_some_and(|u| u.user_id == p.author_id);
163 let can_remove = !is_removed && mod_status;
164 // The inverse of `can_remove`: a removed post still renders as a
165 // tombstone, so the control to reverse it belongs on that tombstone.
166 let can_restore = is_removed && mod_status;
167
168 let body_html = if is_removed {
169 String::from("<p><em>[removed by moderator]</em></p>")
170 } else {
171 docengine::post_process_quotes(&p.body_html, &quote_authors)
172 };
173
174 let post_id_str = p.id.to_string();
175 let footnotes = footnotes_by_post.remove(&post_id_str).unwrap_or_default();
176 let link_previews = link_previews_by_post
177 .remove(&post_id_str)
178 .unwrap_or_default();
179 let endorsement_count = endorsement_counts.get(&post_id_str).copied().unwrap_or(0);
180 let is_endorsed = user_endorsed.contains(&post_id_str);
181 let can_endorse = !is_removed
182 && session_user
183 .as_ref()
184 .is_some_and(|u| u.user_id != p.author_id);
185 let can_flag = !is_removed
186 && session_user
187 .as_ref()
188 .is_some_and(|u| u.user_id != p.author_id);
189
190 // Signatures are gated on current Fan+ status: a lapsed Fan+ user's
191 // saved signature is hidden until they renew. Same for the + badge.
192 let author_signature_html = if p.author_is_fan_plus {
193 p.author_signature_html
194 } else {
195 None
196 };
197
198 PostRow {
199 id: post_id_str,
200 author_name: p.author_name,
201 author_username: p.author_username,
202 timestamp: mt_core::time_format::post_timestamp(p.created_at),
203 body_html,
204 is_op: i == 0 && offset == 0,
205 is_removed,
206 can_restore,
207 can_add_footnote,
208 can_remove,
209 can_flag,
210 footnotes,
211 link_previews,
212 endorsement_count,
213 is_endorsed,
214 can_endorse,
215 author_has_plus_badge: p.author_is_fan_plus,
216 author_signature_html,
217 }
218 })
219 .collect();
220
221 // If tracked, update read position to the last post on the current page.
222 // Spawned off the response path: this is a DB write on an unauthenticated,
223 // unthrottled GET, so awaiting it inline made every tracked viewer block the
224 // page render on a write to the request pool (fuzz-2026-07-06 write-on-GET).
225 // Best-effort, a dropped bump just re-bumps on the next view.
226 if is_tracked
227 && let Some(ref user) = session_user
228 && let Some(last_post) = posts.last()
229 && let Ok(last_post_id) = uuid::Uuid::parse_str(&last_post.id)
230 {
231 let db = state.db.clone();
232 let user_id = user.user_id;
233 tokio::spawn(async move {
234 let _ = mt_db::mutations::update_read_position(&db, user_id, thread_uuid, last_post_id)
235 .await;
236 });
237 }
238
239 let session_user = session_user
240 .as_ref()
241 .map(|u| template_user(u, state.config.platform_admin_id));
242
243 Ok(ThreadTemplate {
244 csrf_token,
245 session_user,
246 mnw_base_url: state.config.mnw_base_url.clone(),
247 community_name: thread_data.community_name,
248 community_slug: thread_data.community_slug,
249 category_name: thread_data.category_name,
250 category_slug: thread_data.category_slug,
251 thread_id: thread_data.id.to_string(),
252 thread_title: thread_data.title,
253 locked: thread_data.locked,
254 pinned: thread_data.pinned,
255 is_mod: mod_status,
256 can_mod_thread: mod_status,
257 is_tracked,
258 posts,
259 pagination,
260 })
261 }
262