Skip to main content

max / makenotwork

18.8 KB · 472 lines History Blame Raw
1 //! The reader's Multithreaded forum memberships, described. Two screens, one
2 //! table.
3 //!
4 //! S4's second batch, and it is one conversion serving two addresses. The
5 //! library's Communities tab and the settings pane's Forums section are the same
6 //! four columns over the same data from the same upstream call, differing only
7 //! in their chrome: the library one has no heading and offers a way out when the
8 //! list is empty, the settings one has a heading and does not.
9 //!
10 //! Compare `routes::pages::public::landing::library_tab_communities` and
11 //! `routes::pages::dashboard::tabs::user::dashboard_tab_forums`, which answer
12 //! the two addresses from Askama when the screens are switched off. Those are
13 //! two templates and two handlers holding one table between them, and the copies
14 //! have already drifted: `user_forums.html` carries a `col-role` class on two
15 //! cells that `library_communities.html` does not. Here the table is written
16 //! once and the divergence has nowhere to live.
17 //!
18 //! # The blocking hop is a different animal on this screen
19 //!
20 //! Every described screen so far reached sqlx, and S3's load measurement was
21 //! taken against sub-millisecond database round trips. This one holds its
22 //! blocking thread across an **outbound HTTP call to another service**, with a
23 //! five-second timeout, so a slow or hanging Multithreaded parks a thread for up
24 //! to five seconds rather than for a query. Tokio's blocking pool defaults to
25 //! 512 threads and the Askama version pays the same latency on a runtime worker,
26 //! so this is not a regression and is not exhaustion at any plausible
27 //! concurrency. It is a different regime from the one that was measured, and the
28 //! S3 numbers should not be read as covering it.
29 //!
30 //! Both screens fail soft rather than propagating an upstream error, which is
31 //! what the Askama handlers do and is the right call for a tab: a non-success
32 //! status renders an empty list.
33
34 use quasi_declare::declare;
35 use quasi_router::screen::Tag;
36 use quasi_router::{Request, Response, RouteError};
37 use quasi_webview::Webview;
38
39 use super::Viewer;
40
41 /// The library tab's name, read by `library_tabs` as the described marker.
42 pub const LIBRARY_SCREEN: &str = "library_communities";
43
44 /// The address the library tab answers.
45 pub const LIBRARY_PATH: &str = "/library/tabs/communities";
46
47 /// The region the library's tab nav targets.
48 pub const LIBRARY_REGION: &str = "library-communities";
49
50 /// The settings section's name, read by `settings_tabs` as the described marker.
51 pub const SETTINGS_SCREEN: &str = "user_forums";
52
53 /// The address the settings section answers.
54 pub const SETTINGS_PATH: &str = "/dashboard/tabs/forums";
55
56 /// The region the settings strip draws for this section.
57 ///
58 /// Was `settings-body`, the single pane the hand-written sub-nav swapped into.
59 /// `6b24f2df` step 4 gave each section a frame; `quasi::settings_tabs` draws
60 /// this one from this constant.
61 pub const SETTINGS_REGION: &str = "settings-forums";
62
63 /// How long to wait on Multithreaded before giving up, matching both Askama
64 /// handlers.
65 const UPSTREAM_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5);
66
67 /// One community this reader belongs to, as the screens need it.
68 pub(crate) struct MembershipView {
69 community: String,
70 profile_url: String,
71 role: String,
72 posts: String,
73 joined: String,
74 }
75
76 /// The library's Communities tab.
77 ///
78 /// Renders an empty list rather than refusing when Multithreaded is not
79 /// configured at all, which is what its Askama handler does: the library shows
80 /// every tab to everyone, so the tab has to say something.
81 pub fn library_screen(viewer: &Viewer, _request: Request) -> Result<Response, RouteError> {
82 let Some(base) = configured_base(viewer) else {
83 return Ok(Response::fragment(LIBRARY_REGION, library_pane(&[], "")));
84 };
85 let memberships = fetch(viewer, viewer.reader()?, &base);
86 Ok(Response::fragment(
87 LIBRARY_REGION,
88 library_pane(&memberships, &base),
89 ))
90 }
91
92 /// The settings pane's Forums section.
93 ///
94 /// Refuses when Multithreaded is not configured, which is what its Askama
95 /// handler does and is right here: the settings nav only draws this entry when
96 /// the reader has memberships, so reaching it unconfigured is a wiring mistake
97 /// rather than an empty list.
98 pub fn settings_screen(viewer: &Viewer, _request: Request) -> Result<Response, RouteError> {
99 let base = configured_base(viewer)
100 .ok_or_else(|| RouteError::not_found("forums are not configured"))?;
101 let memberships = fetch(viewer, viewer.reader()?, &base);
102 Ok(Response::fragment(
103 SETTINGS_REGION,
104 settings_pane(&memberships, &base),
105 ))
106 }
107
108 /// The one read both panes make, for the mounts that serve them from residuals.
109 ///
110 /// Answers the memberships and where they are, which is what both panes fill
111 /// from. A pane that is not configured has no base and no memberships, and
112 /// `library_screen` already treats those as one state.
113 pub(crate) fn reading(viewer: &Viewer) -> Result<(Vec<MembershipView>, String), RouteError> {
114 let Some(base) = configured_base(viewer) else {
115 return Ok((Vec::new(), String::new()));
116 };
117 let memberships = fetch(viewer, viewer.reader()?, &base);
118 Ok((memberships, base))
119 }
120
121 /// The same read for the settings pane, which refuses when nothing is
122 /// configured rather than drawing an empty list. See [`settings_screen`].
123 pub(crate) fn settings_reading(
124 viewer: &Viewer,
125 ) -> Result<(Vec<MembershipView>, String), RouteError> {
126 let base = configured_base(viewer)
127 .ok_or_else(|| RouteError::not_found("forums are not configured"))?;
128 let memberships = fetch(viewer, viewer.reader()?, &base);
129 Ok((memberships, base))
130 }
131
132 /// Where Multithreaded lives, if it lives anywhere.
133 fn configured_base(viewer: &Viewer) -> Option<String> {
134 viewer.app.config.integrations.mt_base_url.clone()
135 }
136
137 /// Ask Multithreaded what this reader belongs to.
138 ///
139 /// Answers an empty list on every failure rather than an error. A tab that
140 /// cannot reach an optional integration should say the reader has no
141 /// memberships, not replace the settings pane with a stack trace, and both
142 /// Askama handlers already made that choice.
143 fn fetch(viewer: &Viewer, reader: &crate::auth::SessionUser, base: &str) -> Vec<MembershipView> {
144 let url = format!("{base}/api/user/{}/summary", reader.id);
145 let username = reader.username.as_ref();
146
147 // The blocking hop, and the long one. See the module header.
148 let body = viewer.block_on(async {
149 let response = crate::helpers::HTTP_CLIENT
150 .get(&url)
151 .timeout(UPSTREAM_TIMEOUT)
152 .send()
153 .await
154 .inspect_err(|error| tracing::warn!(?error, "failed to fetch MT user summary"))
155 .ok()?;
156 if !response.status().is_success() {
157 return None;
158 }
159 response
160 .json::<serde_json::Value>()
161 .await
162 .inspect_err(|error| tracing::warn!(?error, "failed to parse MT summary response"))
163 .ok()
164 });
165
166 let Some(body) = body else {
167 return Vec::new();
168 };
169 body["memberships"]
170 .as_array()
171 .map(|entries| {
172 entries
173 .iter()
174 .filter_map(|entry| {
175 let slug = entry["community_slug"].as_str()?;
176 Some(MembershipView {
177 community: entry["community_name"].as_str()?.to_owned(),
178 profile_url: format!("{base}/p/{slug}/u/{username}"),
179 role: entry["role"].as_str()?.to_owned(),
180 posts: entry["post_count"].as_i64().unwrap_or(0).to_string(),
181 joined: entry["joined_at"]
182 .as_str()
183 .and_then(|at| chrono::DateTime::parse_from_rfc3339(at).ok())
184 .map(|at| at.format("%b %d, %Y").to_string())
185 .unwrap_or_default(),
186 })
187 })
188 .collect()
189 })
190 .unwrap_or_default()
191 }
192
193 declare! {
194 /// The sentence naming where these memberships are, with the link on the name.
195 ///
196 /// `Node::Rich` rather than two nodes and a control: it is one sentence with
197 /// one word in it that goes somewhere, and splitting it into text, an act
198 /// and more text is how a sentence stops reading as a sentence in every
199 /// host.
200 ///
201 /// [`super::own_prose`]'s body, written here rather than called, because the
202 /// address has to be a hole **inside** the markdown. Handed to `own_prose`
203 /// the sentence is formatted first, so a staged shape gets one sentinel
204 /// where the whole document should be; written here the derivation parses
205 /// the link with a sentinel in its destination and the residual holds
206 /// `<a href="` around a gap. `own_prose` carries the rule.
207 #[staged]
208 shape upstream_line(base: &str) -> Node;
209
210 rich "Your memberships across [Multithreaded]({base}) forum communities." {
211 trust quasi_router::Trust::Trusted;
212 }
213 }
214
215 declare! {
216 /// Everything inside the library's tab pane.
217 #[staged]
218 pub(crate) shape library_pane(memberships: &[MembershipView], base: &str) -> Node;
219
220 region LIBRARY_REGION as Pane {
221 // The way out is offered only when there is somewhere to send them,
222 // which is the `{% if !mt_base_url.is_empty() %}` the template wrapped
223 // its button in.
224 empty "You haven't joined any forum communities yet."
225 when memberships.is_empty()
226 {
227 offering "Browse Communities" to external base unless base.is_empty();
228 }
229
230 include upstream_line(base) unless memberships.is_empty();
231 include table(memberships) unless memberships.is_empty();
232 }
233 }
234
235 declare! {
236 /// Everything inside the settings pane.
237 ///
238 /// The heading and the line are drawn either way here, unlike the library's,
239 /// which is the one real difference between the two screens.
240 #[staged]
241 pub(crate) shape settings_pane(memberships: &[MembershipView], base: &str) -> Node;
242
243 region SETTINGS_REGION as Pane {
244 section "Forum Communities";
245 include upstream_line(base);
246
247 // Two guards rather than a dispatch, which renders the same and is
248 // what the seam can derive: a dispatch's arms replace each other at one
249 // position, so only one reaches the residual and the rest are lost. See
250 // quasi-declare's `Emission::Given`, which refuses it.
251 empty "You haven't joined any forum communities yet." when memberships.is_empty();
252 include table(memberships) unless memberships.is_empty();
253 }
254 }
255
256 declare! {
257 /// The memberships, written once for both screens.
258 ///
259 /// Positional cells. Both screens take this table whole rather than picking
260 /// columns out of it, so every membership contributes the same four cells.
261 /// Nothing is paged, so no `more`: this is a whole set the handler already
262 /// counted.
263 #[staged]
264 shape table(memberships: &[MembershipView]) -> Node;
265
266 table {
267 column "Community" {
268 width Fill;
269 priority Essential;
270 }
271 column "Role" {
272 width Content;
273 }
274 column "Posts" {
275 width Content;
276 }
277 column "Joined" {
278 width Content;
279 priority Optional;
280 }
281
282 for membership in memberships.iter() {
283 cells {
284 // The destination is Multithreaded, so it leaves. That is the
285 // description saying it rather than the reader finding out: a
286 // host with no browser can decide what to do with a link off
287 // its own service.
288 cell membership.community.clone() {
289 activate to external membership.profile_url.clone();
290 }
291 cell "" {
292 token Tag::badge(membership.role.clone());
293 }
294 cell membership.posts.clone();
295 cell membership.joined.clone();
296 }
297 }
298 }
299 }
300
301 /// The renderer both screens are drawn with.
302 pub fn renderer(viewer: &Viewer) -> Webview {
303 Webview::new().with_shell(viewer.shell())
304 }
305
306 /// One membership as the tests draw it.
307 ///
308 /// Module-level rather than inside `mod tests` because `quasi::residuals` needs
309 /// one too, and `MembershipView` is this module's own type. Test-only.
310 #[cfg(test)]
311 pub(crate) fn sample(community: &str, role: &str) -> MembershipView {
312 MembershipView {
313 community: community.into(),
314 profile_url: format!("https://mt.example.com/p/{community}/u/max"),
315 role: role.into(),
316 posts: "12".into(),
317 joined: "Aug 10, 2026".into(),
318 }
319 }
320
321 #[cfg(test)]
322 mod tests {
323 use quasi_axum::Serves;
324 use quasi_router::Node;
325
326 use super::*;
327
328 use super::sample as membership;
329
330 fn render(node: &Node) -> String {
331 Webview::new().fragment(node)
332 }
333
334 #[test]
335 fn each_screen_matches_the_nav_that_targets_it() {
336 // Two navs, two regions, and they are genuinely different: the library
337 // swaps its tab pane, the settings section swaps the settings body. If
338 // either disagrees the tab swaps into nothing, and no other test sees it.
339 // The library half reads the described strip rather than the page, which
340 // stopped holding the nav when the strip was described (`6b24f2df`).
341 let library = crate::quasi::library_tabs::html("", true, true);
342 assert!(
343 library.contains(&format!("hx-get=\"{LIBRARY_PATH}\"")),
344 "{library}"
345 );
346 assert!(
347 library.contains(&format!("id=\"{LIBRARY_REGION}\"")),
348 "{library}"
349 );
350
351 // Read off the described strip since `6b24f2df` step 4, the same way the
352 // library half above is. The hand-written nav this used to read is gone.
353 let settings = crate::quasi::settings_tabs::html(
354 0,
355 "",
356 crate::quasi::settings_tabs::Gates {
357 has_media: true,
358 git_enabled: true,
359 has_mt_memberships: true,
360 has_sync_apps: true,
361 },
362 );
363 assert!(
364 settings.contains(&format!("hx-get=\"{SETTINGS_PATH}\"")),
365 "{settings}"
366 );
367 assert!(
368 settings.contains(&format!("id=\"{SETTINGS_REGION}\"")),
369 "{settings}"
370 );
371 }
372
373 /// The four headings, which moved from a hand-written `Table::new` list
374 /// into the declaration. A column dropped on the way is silent: the cells
375 /// still render and land under the wrong name.
376 #[test]
377 fn every_column_the_table_had_is_still_named() {
378 let html = render(&table(&[membership("rust", "Moderator")]));
379
380 for heading in ["Community", "Role", "Posts", "Joined"] {
381 assert!(html.contains(heading), "{heading} is gone from {html}");
382 }
383 }
384
385 #[test]
386 fn one_table_serves_both_screens() {
387 // The point of the batch. Two screens rendering the same rows differ in
388 // their chrome and nowhere else, which is what the two templates failed
389 // at: one of them grew a `col-role` class the other never got.
390 let rows = [membership("rust", "Moderator")];
391 let library = render(&library_pane(&rows, "https://mt.example.com"));
392 let settings = render(&settings_pane(&rows, "https://mt.example.com"));
393
394 for fragment in ["rust", "Moderator", "12", "Aug 10, 2026"] {
395 assert!(
396 library.contains(fragment),
397 "{fragment} in library: {library}"
398 );
399 assert!(
400 settings.contains(fragment),
401 "{fragment} in settings: {settings}"
402 );
403 }
404 // Only the settings screen carries the heading.
405 assert!(settings.contains("Forum Communities"));
406 assert!(!library.contains("Forum Communities"));
407 }
408
409 #[test]
410 fn a_community_name_leaves_for_multithreaded() {
411 let html = render(&table(&[membership("rust", "Member")]));
412
413 assert!(
414 html.contains("href=\"https://mt.example.com/p/rust/u/max\""),
415 "{html}"
416 );
417 // External, so it leaves properly: a new tab that cannot reach back
418 // through `window.opener`, and no htmx swap.
419 assert!(html.contains("rel=\"noopener noreferrer\""), "{html}");
420 assert!(!html.contains("hx-get"), "nothing swaps: {html}");
421 }
422
423 #[test]
424 fn the_upstream_line_keeps_its_link() {
425 // One sentence with one linked word in it. If the strict markdown preset
426 // ever drops links this reads as plain prose and the way to
427 // Multithreaded quietly disappears from both screens.
428 let html = render(&upstream_line("https://mt.example.com"));
429 assert!(html.contains("href=\"https://mt.example.com\""), "{html}");
430 assert!(html.contains("Multithreaded"), "{html}");
431 }
432
433 #[test]
434 fn an_empty_library_offers_a_way_out_only_when_there_is_one() {
435 let configured = render(&library_pane(&[], "https://mt.example.com"));
436 // The apostrophe arrives escaped, so the assertion matches the half of
437 // the sentence that survives verbatim rather than re-encoding it here.
438 assert!(configured.contains("joined any forum communities yet."));
439 assert!(configured.contains("Browse Communities"), "{configured}");
440 // `b279b9eb`: the way out destroys nothing and interrupts nobody, so it
441 // takes neither mark. Asserted rather than assumed because a blanket
442 // pass over the acts in this tree would toll it dangerous, and the
443 // separation is only real if the un-marked case is checked too.
444 assert!(!configured.contains("data-tone"), "{configured}");
445 assert!(!configured.contains("hx-confirm"), "{configured}");
446
447 // Multithreaded not configured at all: the same sentence, and no button
448 // pointing at an empty address.
449 let bare = render(&library_pane(&[], ""));
450 assert!(bare.contains("joined any forum communities yet."));
451 assert!(!bare.contains("Browse Communities"), "{bare}");
452 }
453
454 #[test]
455 fn an_empty_settings_section_keeps_its_heading() {
456 // The asymmetry the two templates encode: the settings section draws its
457 // heading and its line whether or not there is a table under them.
458 let html = render(&settings_pane(&[], "https://mt.example.com"));
459 assert!(html.contains("Forum Communities"));
460 assert!(html.contains("joined any forum communities yet."));
461 assert!(!html.contains("role=\"table\""), "{html}");
462 }
463
464 #[test]
465 fn a_community_name_cannot_smuggle_markup() {
466 // Every string on this screen came from another service's JSON, which
467 // is a wider door than a form on this one.
468 let html = render(&table(&[membership("<script>x()</script>", "Member")]));
469 assert!(!html.contains("<script>x()"), "{html}");
470 }
471 }
472