Skip to main content

max / makenotwork

16.3 KB · 401 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 makeover_layout as layout;
35 use quasi_router::screen::{Act, Cell, Cells, Column, Tag};
36 use quasi_router::{Action, Node, RegionKind, Request, Response, RouteError, Slot};
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 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 /// Where Multithreaded lives, if it lives anywhere.
109 fn configured_base(viewer: &Viewer) -> Option<String> {
110 viewer.app.config.integrations.mt_base_url.clone()
111 }
112
113 /// Ask Multithreaded what this reader belongs to.
114 ///
115 /// Answers an empty list on every failure rather than an error. A tab that
116 /// cannot reach an optional integration should say the reader has no
117 /// memberships, not replace the settings pane with a stack trace, and both
118 /// Askama handlers already made that choice.
119 fn fetch(viewer: &Viewer, reader: &crate::auth::SessionUser, base: &str) -> Vec<MembershipView> {
120 let url = format!("{base}/api/user/{}/summary", reader.id);
121 let username = reader.username.as_ref();
122
123 // The blocking hop, and the long one. See the module header.
124 let body = viewer.block_on(async {
125 let response = crate::helpers::HTTP_CLIENT
126 .get(&url)
127 .timeout(UPSTREAM_TIMEOUT)
128 .send()
129 .await
130 .inspect_err(|error| tracing::warn!(?error, "failed to fetch MT user summary"))
131 .ok()?;
132 if !response.status().is_success() {
133 return None;
134 }
135 response
136 .json::<serde_json::Value>()
137 .await
138 .inspect_err(|error| tracing::warn!(?error, "failed to parse MT summary response"))
139 .ok()
140 });
141
142 let Some(body) = body else {
143 return Vec::new();
144 };
145 body["memberships"]
146 .as_array()
147 .map(|entries| {
148 entries
149 .iter()
150 .filter_map(|entry| {
151 let slug = entry["community_slug"].as_str()?;
152 Some(MembershipView {
153 community: entry["community_name"].as_str()?.to_owned(),
154 profile_url: format!("{base}/p/{slug}/u/{username}"),
155 role: entry["role"].as_str()?.to_owned(),
156 posts: entry["post_count"].as_i64().unwrap_or(0).to_string(),
157 joined: entry["joined_at"]
158 .as_str()
159 .and_then(|at| chrono::DateTime::parse_from_rfc3339(at).ok())
160 .map(|at| at.format("%b %d, %Y").to_string())
161 .unwrap_or_default(),
162 })
163 })
164 .collect()
165 })
166 .unwrap_or_default()
167 }
168
169 /// The sentence naming where these memberships are, with the link on the name.
170 ///
171 /// `Node::Rich` rather than two nodes and a control: it is one sentence with one
172 /// word in it that goes somewhere, and splitting it into text, an act and more
173 /// text is how a sentence stops reading as a sentence in every host.
174 fn upstream_line(base: &str) -> Node {
175 super::own_prose(format!(
176 "Your memberships across [Multithreaded]({base}) forum communities."
177 ))
178 }
179
180 /// Everything inside the library's tab pane.
181 fn library_pane(memberships: &[MembershipView], base: &str) -> Node {
182 let mut slot = Slot::new(LIBRARY_REGION, RegionKind::Pane);
183
184 if memberships.is_empty() {
185 let mut nothing = Node::empty("You haven't joined any forum communities yet.");
186 // The way out is offered only when there is somewhere to send them,
187 // which is the `{% if !mt_base_url.is_empty() %}` the template wrapped
188 // its button in.
189 if !base.is_empty() {
190 nothing = nothing.offering(Act::new("Browse Communities", Action::external(base)));
191 }
192 return Node::Region(slot.with(nothing));
193 }
194
195 slot = slot.with(upstream_line(base)).with(table(memberships));
196 Node::Region(slot)
197 }
198
199 /// Everything inside the settings pane.
200 fn settings_pane(memberships: &[MembershipView], base: &str) -> Node {
201 let mut slot = Slot::new(SETTINGS_REGION, RegionKind::Pane)
202 .with(Node::section("Forum Communities"))
203 .with(upstream_line(base));
204
205 // The heading and the line are drawn either way here, unlike the library's,
206 // which is the one real difference between the two screens.
207 slot = if memberships.is_empty() {
208 slot.with(Node::empty("You haven't joined any forum communities yet."))
209 } else {
210 slot.with(table(memberships))
211 };
212
213 Node::Region(slot)
214 }
215
216 /// The memberships, written once for both screens.
217 fn table(memberships: &[MembershipView]) -> Node {
218 Node::Table {
219 columns: vec![
220 Column::new("Community")
221 .width(layout::Width::Fill)
222 .priority(layout::Priority::Essential),
223 Column::new("Role").width(layout::Width::Content),
224 Column::new("Posts").width(layout::Width::Content),
225 Column::new("Joined")
226 .width(layout::Width::Content)
227 .priority(layout::Priority::Optional),
228 ],
229 rows: memberships
230 .iter()
231 .map(|membership| {
232 Cells::new([
233 // The destination is Multithreaded, so it leaves. That is
234 // the description saying it rather than the reader finding
235 // out: a host with no browser can decide what to do with a
236 // link off its own service.
237 Cell::new(membership.community.clone())
238 .activate(Action::external(membership.profile_url.clone())),
239 Cell::tag(Tag::badge(membership.role.clone())),
240 Cell::new(membership.posts.clone()),
241 Cell::new(membership.joined.clone()),
242 ])
243 })
244 .collect(),
245 // No paging described here: every one of these tables is a
246 // whole set the handler already counted.
247 more: None,
248 }
249 }
250
251 /// The renderer both screens are drawn with.
252 pub fn renderer(viewer: &Viewer) -> Webview {
253 Webview::new().with_shell(viewer.shell())
254 }
255
256 #[cfg(test)]
257 mod tests {
258 use super::*;
259 use quasi_axum::Serves;
260
261 fn membership(community: &str, role: &str) -> MembershipView {
262 MembershipView {
263 community: community.into(),
264 profile_url: format!("https://mt.example.com/p/{community}/u/max"),
265 role: role.into(),
266 posts: "12".into(),
267 joined: "Aug 10, 2026".into(),
268 }
269 }
270
271 fn render(node: &Node) -> String {
272 Webview::new().fragment(node)
273 }
274
275 #[test]
276 fn each_screen_matches_the_nav_that_targets_it() {
277 // Two navs, two regions, and they are genuinely different: the library
278 // swaps its tab pane, the settings section swaps the settings body. If
279 // either disagrees the tab swaps into nothing, and no other test sees it.
280 // The library half reads the described strip rather than the page, which
281 // stopped holding the nav when the strip was described (`6b24f2df`).
282 let library = crate::quasi::library_tabs::html("", true, true);
283 assert!(
284 library.contains(&format!("hx-get=\"{LIBRARY_PATH}\"")),
285 "{library}"
286 );
287 assert!(
288 library.contains(&format!("id=\"{LIBRARY_REGION}\"")),
289 "{library}"
290 );
291
292 // Read off the described strip since `6b24f2df` step 4, the same way the
293 // library half above is. The hand-written nav this used to read is gone.
294 let settings = crate::quasi::settings_tabs::html(
295 0,
296 "",
297 crate::quasi::settings_tabs::Gates {
298 has_media: true,
299 git_enabled: true,
300 has_mt_memberships: true,
301 has_sync_apps: true,
302 },
303 );
304 assert!(
305 settings.contains(&format!("hx-get=\"{SETTINGS_PATH}\"")),
306 "{settings}"
307 );
308 assert!(
309 settings.contains(&format!("id=\"{SETTINGS_REGION}\"")),
310 "{settings}"
311 );
312 }
313
314 #[test]
315 fn one_table_serves_both_screens() {
316 // The point of the batch. Two screens rendering the same rows differ in
317 // their chrome and nowhere else, which is what the two templates failed
318 // at: one of them grew a `col-role` class the other never got.
319 let rows = [membership("rust", "Moderator")];
320 let library = render(&library_pane(&rows, "https://mt.example.com"));
321 let settings = render(&settings_pane(&rows, "https://mt.example.com"));
322
323 for fragment in ["rust", "Moderator", "12", "Aug 10, 2026"] {
324 assert!(
325 library.contains(fragment),
326 "{fragment} in library: {library}"
327 );
328 assert!(
329 settings.contains(fragment),
330 "{fragment} in settings: {settings}"
331 );
332 }
333 // Only the settings screen carries the heading.
334 assert!(settings.contains("Forum Communities"));
335 assert!(!library.contains("Forum Communities"));
336 }
337
338 #[test]
339 fn a_community_name_leaves_for_multithreaded() {
340 let html = render(&table(&[membership("rust", "Member")]));
341
342 assert!(
343 html.contains("href=\"https://mt.example.com/p/rust/u/max\""),
344 "{html}"
345 );
346 // External, so it leaves properly: a new tab that cannot reach back
347 // through `window.opener`, and no htmx swap.
348 assert!(html.contains("rel=\"noopener noreferrer\""), "{html}");
349 assert!(!html.contains("hx-get"), "nothing swaps: {html}");
350 }
351
352 #[test]
353 fn the_upstream_line_keeps_its_link() {
354 // One sentence with one linked word in it. If the strict markdown preset
355 // ever drops links this reads as plain prose and the way to
356 // Multithreaded quietly disappears from both screens.
357 let html = render(&upstream_line("https://mt.example.com"));
358 assert!(html.contains("href=\"https://mt.example.com\""), "{html}");
359 assert!(html.contains("Multithreaded"), "{html}");
360 }
361
362 #[test]
363 fn an_empty_library_offers_a_way_out_only_when_there_is_one() {
364 let configured = render(&library_pane(&[], "https://mt.example.com"));
365 // The apostrophe arrives escaped, so the assertion matches the half of
366 // the sentence that survives verbatim rather than re-encoding it here.
367 assert!(configured.contains("joined any forum communities yet."));
368 assert!(configured.contains("Browse Communities"), "{configured}");
369 // `b279b9eb`: the way out destroys nothing and interrupts nobody, so it
370 // takes neither mark. Asserted rather than assumed because a blanket
371 // pass over the acts in this tree would toll it dangerous, and the
372 // separation is only real if the un-marked case is checked too.
373 assert!(!configured.contains("data-tone"), "{configured}");
374 assert!(!configured.contains("hx-confirm"), "{configured}");
375
376 // Multithreaded not configured at all: the same sentence, and no button
377 // pointing at an empty address.
378 let bare = render(&library_pane(&[], ""));
379 assert!(bare.contains("joined any forum communities yet."));
380 assert!(!bare.contains("Browse Communities"), "{bare}");
381 }
382
383 #[test]
384 fn an_empty_settings_section_keeps_its_heading() {
385 // The asymmetry the two templates encode: the settings section draws its
386 // heading and its line whether or not there is a table under them.
387 let html = render(&settings_pane(&[], "https://mt.example.com"));
388 assert!(html.contains("Forum Communities"));
389 assert!(html.contains("joined any forum communities yet."));
390 assert!(!html.contains("role=\"table\""), "{html}");
391 }
392
393 #[test]
394 fn a_community_name_cannot_smuggle_markup() {
395 // Every string on this screen came from another service's JSON, which
396 // is a wider door than a form on this one.
397 let html = render(&table(&[membership("<script>x()</script>", "Member")]));
398 assert!(!html.contains("<script>x()"), "{html}");
399 }
400 }
401