max / makenotwork
5 files changed,
+53 insertions,
-10 deletions
| @@ -1163,11 +1163,19 @@ pub struct TrackedThreadRow { | |||
| 1163 | 1163 | pub tracked_at: DateTime<Utc>, | |
| 1164 | 1164 | } | |
| 1165 | 1165 | ||
| 1166 | - | /// List a user's tracked threads with unread post counts. | |
| 1166 | + | /// List a user's tracked threads with unread post counts, newest activity first. | |
| 1167 | + | /// | |
| 1168 | + | /// Paginated (`limit`/`offset`) so a user tracking hundreds of threads doesn't | |
| 1169 | + | /// render — and recompute — an unbounded page. The last-read cutoff is resolved | |
| 1170 | + | /// with a single LEFT JOIN rather than a per-row nested subquery, and the unread | |
| 1171 | + | /// count excludes mod-removed posts (`p.removed_at IS NULL`) so the badge can't | |
| 1172 | + | /// be inflated by removed content and can use the `idx_posts_not_removed` index. | |
| 1167 | 1173 | #[tracing::instrument(skip_all)] | |
| 1168 | 1174 | pub async fn list_tracked_threads( | |
| 1169 | 1175 | pool: &PgPool, | |
| 1170 | 1176 | user_id: Uuid, | |
| 1177 | + | limit: i64, | |
| 1178 | + | offset: i64, | |
| 1171 | 1179 | ) -> Result<Vec<TrackedThreadRow>, sqlx::Error> { | |
| 1172 | 1180 | sqlx::query_as::<_, TrackedThreadRow>( | |
| 1173 | 1181 | "SELECT tt.thread_id, | |
| @@ -1177,9 +1185,8 @@ pub async fn list_tracked_threads( | |||
| 1177 | 1185 | cat.slug AS category_slug, | |
| 1178 | 1186 | (SELECT COUNT(*) FROM posts p | |
| 1179 | 1187 | WHERE p.thread_id = tt.thread_id | |
| 1180 | - | AND (tt.last_read_post_id IS NULL OR p.created_at > ( | |
| 1181 | - | SELECT created_at FROM posts WHERE id = tt.last_read_post_id | |
| 1182 | - | )) | |
| 1188 | + | AND p.removed_at IS NULL | |
| 1189 | + | AND (lrp.created_at IS NULL OR p.created_at > lrp.created_at) | |
| 1183 | 1190 | ) AS unread_count, | |
| 1184 | 1191 | EXISTS ( | |
| 1185 | 1192 | SELECT 1 FROM post_mentions pm | |
| @@ -1192,14 +1199,35 @@ pub async fn list_tracked_threads( | |||
| 1192 | 1199 | JOIN threads t ON t.id = tt.thread_id | |
| 1193 | 1200 | JOIN categories cat ON cat.id = t.category_id | |
| 1194 | 1201 | JOIN communities co ON co.id = cat.community_id | |
| 1202 | + | LEFT JOIN posts lrp ON lrp.id = tt.last_read_post_id | |
| 1195 | 1203 | WHERE tt.user_id = $1 AND t.deleted_at IS NULL AND co.suspended_at IS NULL | |
| 1196 | - | ORDER BY t.last_activity_at DESC", | |
| 1204 | + | ORDER BY t.last_activity_at DESC | |
| 1205 | + | LIMIT $2 OFFSET $3", | |
| 1197 | 1206 | ) | |
| 1198 | 1207 | .bind(user_id) | |
| 1208 | + | .bind(limit) | |
| 1209 | + | .bind(offset) | |
| 1199 | 1210 | .fetch_all(pool) | |
| 1200 | 1211 | .await | |
| 1201 | 1212 | } | |
| 1202 | 1213 | ||
| 1214 | + | /// Count a user's visible tracked threads (for pagination). Mirrors the | |
| 1215 | + | /// visibility filters of `list_tracked_threads`. | |
| 1216 | + | #[tracing::instrument(skip_all)] | |
| 1217 | + | pub async fn count_tracked_threads(pool: &PgPool, user_id: Uuid) -> Result<i64, sqlx::Error> { | |
| 1218 | + | sqlx::query_scalar( | |
| 1219 | + | "SELECT COUNT(*) | |
| 1220 | + | FROM tracked_threads tt | |
| 1221 | + | JOIN threads t ON t.id = tt.thread_id | |
| 1222 | + | JOIN categories cat ON cat.id = t.category_id | |
| 1223 | + | JOIN communities co ON co.id = cat.community_id | |
| 1224 | + | WHERE tt.user_id = $1 AND t.deleted_at IS NULL AND co.suspended_at IS NULL", | |
| 1225 | + | ) | |
| 1226 | + | .bind(user_id) | |
| 1227 | + | .fetch_one(pool) | |
| 1228 | + | .await | |
| 1229 | + | } | |
| 1230 | + | ||
| 1203 | 1231 | // ============================================================================ | |
| 1204 | 1232 | // Tag queries | |
| 1205 | 1233 | // ============================================================================ |
| @@ -1,7 +1,7 @@ | |||
| 1 | 1 | //! Thread tracking handlers — track, untrack, tracked threads list. | |
| 2 | 2 | ||
| 3 | 3 | use axum::{ | |
| 4 | - | extract::Path, | |
| 4 | + | extract::{Path, Query}, | |
| 5 | 5 | http::StatusCode, | |
| 6 | 6 | response::{IntoResponse, Redirect, Response}, | |
| 7 | 7 | }; | |
| @@ -102,18 +102,30 @@ pub(super) async fn tracking_info_page( | |||
| 102 | 102 | pub(super) async fn tracked_threads_page( | |
| 103 | 103 | axum::extract::State(state): axum::extract::State<AppState>, | |
| 104 | 104 | session: Session, | |
| 105 | + | Query(query): Query<super::ForumDirectoryQuery>, | |
| 105 | 106 | MaybeUser(session_user): MaybeUser, | |
| 106 | 107 | ) -> Result<impl IntoResponse, Response> { | |
| 107 | 108 | let csrf_token = Some(csrf::get_or_create_token(&session).await); | |
| 108 | 109 | let user = session_user | |
| 109 | 110 | .ok_or_else(|| Redirect::to("/auth/login").into_response())?; | |
| 110 | 111 | ||
| 111 | - | let db_tracked = mt_db::queries::list_tracked_threads(&state.db, user.user_id) | |
| 112 | + | const PER_PAGE: i64 = 50; | |
| 113 | + | let total = mt_db::queries::count_tracked_threads(&state.db, user.user_id) | |
| 112 | 114 | .await | |
| 113 | 115 | .map_err(|e| { | |
| 114 | - | tracing::error!(error = ?e, "db error listing tracked threads"); | |
| 116 | + | tracing::error!(error = ?e, "db error counting tracked threads"); | |
| 115 | 117 | StatusCode::INTERNAL_SERVER_ERROR.into_response() | |
| 116 | 118 | })?; | |
| 119 | + | let pagination = Pagination::new(query.page.unwrap_or(1).max(1), total, PER_PAGE); | |
| 120 | + | let offset = pagination.offset(PER_PAGE); | |
| 121 | + | ||
| 122 | + | let db_tracked = | |
| 123 | + | mt_db::queries::list_tracked_threads(&state.db, user.user_id, PER_PAGE, offset) | |
| 124 | + | .await | |
| 125 | + | .map_err(|e| { | |
| 126 | + | tracing::error!(error = ?e, "db error listing tracked threads"); | |
| 127 | + | StatusCode::INTERNAL_SERVER_ERROR.into_response() | |
| 128 | + | })?; | |
| 117 | 129 | ||
| 118 | 130 | let threads = db_tracked | |
| 119 | 131 | .into_iter() | |
| @@ -133,5 +145,6 @@ pub(super) async fn tracked_threads_page( | |||
| 133 | 145 | session_user: Some(template_user(&user, state.config.platform_admin_id)), | |
| 134 | 146 | mnw_base_url: state.config.mnw_base_url.clone(), | |
| 135 | 147 | threads, | |
| 148 | + | pagination, | |
| 136 | 149 | }) | |
| 137 | 150 | } |
| @@ -478,6 +478,7 @@ pub struct TrackedThreadsTemplate { | |||
| 478 | 478 | pub session_user: Option<TemplateSessionUser>, | |
| 479 | 479 | pub mnw_base_url: std::sync::Arc<str>, | |
| 480 | 480 | pub threads: Vec<TrackedThreadViewRow>, | |
| 481 | + | pub pagination: Pagination, | |
| 481 | 482 | } | |
| 482 | 483 | ||
| 483 | 484 | // ============================================================================ |
| @@ -59,6 +59,7 @@ | |||
| 59 | 59 | {% endfor %} | |
| 60 | 60 | </tbody> | |
| 61 | 61 | </table> | |
| 62 | + | {% include "partials/pagination.html" %} | |
| 62 | 63 | {% endif %} | |
| 63 | 64 | </div> | |
| 64 | 65 | {% endblock %} |
| @@ -115,7 +115,7 @@ async fn unread_count_tracking() { | |||
| 115 | 115 | .unwrap(); | |
| 116 | 116 | ||
| 117 | 117 | // Check tracked threads — should show unread | |
| 118 | - | let tracked = mt_db::queries::list_tracked_threads(&h.db, user_id).await.unwrap(); | |
| 118 | + | let tracked = mt_db::queries::list_tracked_threads(&h.db, user_id, 50, 0).await.unwrap(); | |
| 119 | 119 | assert_eq!(tracked.len(), 1); | |
| 120 | 120 | assert!(tracked[0].unread_count > 0, "Should have unread posts"); | |
| 121 | 121 | } | |
| @@ -138,7 +138,7 @@ async fn stop_tracking_all() { | |||
| 138 | 138 | let resp = h.client.post_form("/tracked/stop-all", "").await; | |
| 139 | 139 | assert!(resp.status.is_redirection(), "Expected redirect, got {}", resp.status); | |
| 140 | 140 | ||
| 141 | - | let tracked = mt_db::queries::list_tracked_threads(&h.db, user_id).await.unwrap(); | |
| 141 | + | let tracked = mt_db::queries::list_tracked_threads(&h.db, user_id, 50, 0).await.unwrap(); | |
| 142 | 142 | assert_eq!(tracked.len(), 0, "All tracked threads should be removed"); | |
| 143 | 143 | } | |
| 144 | 144 |