Skip to main content

max / makenotwork

14.9 KB · 363 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::{Shell, Webview};
38
39 use super::Viewer;
40
41 /// The library tab's switch. `QUASI_SCREENS=library_communities`.
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 const LIBRARY_REGION: &str = "tab-content";
49
50 /// The settings section's switch. `QUASI_SCREENS=user_forums`.
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 nav targets. The same pane `ssh_keys` replaces.
57 const SETTINGS_REGION: &str = "settings-body";
58
59 /// How long to wait on Multithreaded before giving up, matching both Askama
60 /// handlers.
61 const UPSTREAM_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5);
62
63 /// One community this reader belongs to, as the screens need it.
64 pub struct MembershipView {
65 community: String,
66 profile_url: String,
67 role: String,
68 posts: String,
69 joined: String,
70 }
71
72 /// The library's Communities tab.
73 ///
74 /// Renders an empty list rather than refusing when Multithreaded is not
75 /// configured at all, which is what its Askama handler does: the library shows
76 /// every tab to everyone, so the tab has to say something.
77 pub fn library_screen(viewer: &Viewer, _request: Request) -> Result<Response, RouteError> {
78 let Some(base) = configured_base(viewer) else {
79 return Ok(Response::fragment(LIBRARY_REGION, library_pane(&[], "")));
80 };
81 let memberships = fetch(viewer, &base);
82 Ok(Response::fragment(
83 LIBRARY_REGION,
84 library_pane(&memberships, &base),
85 ))
86 }
87
88 /// The settings pane's Forums section.
89 ///
90 /// Refuses when Multithreaded is not configured, which is what its Askama
91 /// handler does and is right here: the settings nav only draws this entry when
92 /// the reader has memberships, so reaching it unconfigured is a wiring mistake
93 /// rather than an empty list.
94 pub fn settings_screen(viewer: &Viewer, _request: Request) -> Result<Response, RouteError> {
95 let base = configured_base(viewer)
96 .ok_or_else(|| RouteError::not_found("forums are not configured"))?;
97 let memberships = fetch(viewer, &base);
98 Ok(Response::fragment(
99 SETTINGS_REGION,
100 settings_pane(&memberships, &base),
101 ))
102 }
103
104 /// Where Multithreaded lives, if it lives anywhere.
105 fn configured_base(viewer: &Viewer) -> Option<String> {
106 viewer.app.config.integrations.mt_base_url.clone()
107 }
108
109 /// Ask Multithreaded what this reader belongs to.
110 ///
111 /// Answers an empty list on every failure rather than an error. A tab that
112 /// cannot reach an optional integration should say the reader has no
113 /// memberships, not replace the settings pane with a stack trace, and both
114 /// Askama handlers already made that choice.
115 fn fetch(viewer: &Viewer, base: &str) -> Vec<MembershipView> {
116 let url = format!("{base}/api/user/{}/summary", viewer.user.id);
117 let username = viewer.user.username.as_ref();
118
119 // The blocking hop, and the long one. See the module header.
120 let body = viewer.block_on(async {
121 let response = crate::helpers::HTTP_CLIENT
122 .get(&url)
123 .timeout(UPSTREAM_TIMEOUT)
124 .send()
125 .await
126 .inspect_err(|error| tracing::warn!(?error, "failed to fetch MT user summary"))
127 .ok()?;
128 if !response.status().is_success() {
129 return None;
130 }
131 response
132 .json::<serde_json::Value>()
133 .await
134 .inspect_err(|error| tracing::warn!(?error, "failed to parse MT summary response"))
135 .ok()
136 });
137
138 let Some(body) = body else {
139 return Vec::new();
140 };
141 body["memberships"]
142 .as_array()
143 .map(|entries| {
144 entries
145 .iter()
146 .filter_map(|entry| {
147 let slug = entry["community_slug"].as_str()?;
148 Some(MembershipView {
149 community: entry["community_name"].as_str()?.to_owned(),
150 profile_url: format!("{base}/p/{slug}/u/{username}"),
151 role: entry["role"].as_str()?.to_owned(),
152 posts: entry["post_count"].as_i64().unwrap_or(0).to_string(),
153 joined: entry["joined_at"]
154 .as_str()
155 .and_then(|at| chrono::DateTime::parse_from_rfc3339(at).ok())
156 .map(|at| at.format("%b %d, %Y").to_string())
157 .unwrap_or_default(),
158 })
159 })
160 .collect()
161 })
162 .unwrap_or_default()
163 }
164
165 /// The sentence naming where these memberships are, with the link on the name.
166 ///
167 /// `Node::Rich` rather than two nodes and a control: it is one sentence with one
168 /// word in it that goes somewhere, and splitting it into text, an act and more
169 /// text is how a sentence stops reading as a sentence in every host.
170 fn upstream_line(base: &str) -> Node {
171 Node::rich(format!(
172 "Your memberships across [Multithreaded]({base}) forum communities."
173 ))
174 }
175
176 /// Everything inside the library's tab pane.
177 fn library_pane(memberships: &[MembershipView], base: &str) -> Node {
178 let mut slot = Slot::new(LIBRARY_REGION, RegionKind::Pane);
179
180 if memberships.is_empty() {
181 let mut nothing = Node::empty("You haven't joined any forum communities yet.");
182 // The way out is offered only when there is somewhere to send them,
183 // which is the `{% if !mt_base_url.is_empty() %}` the template wrapped
184 // its button in.
185 if !base.is_empty() {
186 nothing = nothing.offering(Act::new("Browse Communities", Action::external(base)));
187 }
188 return Node::Region(slot.with(nothing));
189 }
190
191 slot = slot.with(upstream_line(base)).with(table(memberships));
192 Node::Region(slot)
193 }
194
195 /// Everything inside the settings pane.
196 fn settings_pane(memberships: &[MembershipView], base: &str) -> Node {
197 let mut slot = Slot::new(SETTINGS_REGION, RegionKind::Pane)
198 .with(Node::section("Forum Communities"))
199 .with(upstream_line(base));
200
201 // The heading and the line are drawn either way here, unlike the library's,
202 // which is the one real difference between the two screens.
203 slot = if memberships.is_empty() {
204 slot.with(Node::empty("You haven't joined any forum communities yet."))
205 } else {
206 slot.with(table(memberships))
207 };
208
209 Node::Region(slot)
210 }
211
212 /// The memberships, written once for both screens.
213 fn table(memberships: &[MembershipView]) -> Node {
214 Node::Table {
215 columns: vec![
216 Column::new("Community")
217 .width(layout::Width::Fill)
218 .priority(layout::Priority::Essential),
219 Column::new("Role").width(layout::Width::Content),
220 Column::new("Posts").width(layout::Width::Content),
221 Column::new("Joined")
222 .width(layout::Width::Content)
223 .priority(layout::Priority::Optional),
224 ],
225 rows: memberships
226 .iter()
227 .map(|membership| {
228 Cells::new([
229 // The destination is Multithreaded, so it leaves. That is
230 // the description saying it rather than the reader finding
231 // out: a host with no browser can decide what to do with a
232 // link off its own service.
233 Cell::new(membership.community.clone())
234 .activate(Action::external(membership.profile_url.clone())),
235 Cell::tag(Tag::badge(membership.role.clone())),
236 Cell::new(membership.posts.clone()),
237 Cell::new(membership.joined.clone()),
238 ])
239 })
240 .collect(),
241 }
242 }
243
244 /// The renderer both screens are drawn with.
245 pub fn renderer(_viewer: &Viewer) -> Webview {
246 Webview::new().with_shell(Shell::under("/static").layered(["base", "components", "responsive"]))
247 }
248
249 #[cfg(test)]
250 mod tests {
251 use super::*;
252 use quasi_axum::Serves;
253
254 fn membership(community: &str, role: &str) -> MembershipView {
255 MembershipView {
256 community: community.into(),
257 profile_url: format!("https://mt.example.com/p/{community}/u/max"),
258 role: role.into(),
259 posts: "12".into(),
260 joined: "Aug 10, 2026".into(),
261 }
262 }
263
264 fn render(node: &Node) -> String {
265 Webview::new().fragment(node)
266 }
267
268 #[test]
269 fn each_screen_matches_the_nav_that_targets_it() {
270 // Two navs, two regions, and they are genuinely different: the library
271 // swaps its tab pane, the settings section swaps the settings body. If
272 // either disagrees the tab swaps into nothing, and no other test sees it.
273 let library = include_str!("../../templates/pages/library.html");
274 assert!(library.contains(&format!("hx-get=\"{LIBRARY_PATH}\"")));
275 assert!(library.contains(&format!("hx-target=\"#{LIBRARY_REGION}\"")));
276
277 let settings = include_str!("../../templates/partials/tabs/user_settings.html");
278 assert!(settings.contains(&format!("hx-get=\"{SETTINGS_PATH}\"")));
279 assert!(settings.contains(&format!("hx-target=\"#{SETTINGS_REGION}\"")));
280 }
281
282 #[test]
283 fn one_table_serves_both_screens() {
284 // The point of the batch. Two screens rendering the same rows differ in
285 // their chrome and nowhere else, which is what the two templates failed
286 // at: one of them grew a `col-role` class the other never got.
287 let rows = [membership("rust", "Moderator")];
288 let library = render(&library_pane(&rows, "https://mt.example.com"));
289 let settings = render(&settings_pane(&rows, "https://mt.example.com"));
290
291 for fragment in ["rust", "Moderator", "12", "Aug 10, 2026"] {
292 assert!(
293 library.contains(fragment),
294 "{fragment} in library: {library}"
295 );
296 assert!(
297 settings.contains(fragment),
298 "{fragment} in settings: {settings}"
299 );
300 }
301 // Only the settings screen carries the heading.
302 assert!(settings.contains("Forum Communities"));
303 assert!(!library.contains("Forum Communities"));
304 }
305
306 #[test]
307 fn a_community_name_leaves_for_multithreaded() {
308 let html = render(&table(&[membership("rust", "Member")]));
309
310 assert!(
311 html.contains("href=\"https://mt.example.com/p/rust/u/max\""),
312 "{html}"
313 );
314 // External, so it leaves properly: a new tab that cannot reach back
315 // through `window.opener`, and no htmx swap.
316 assert!(html.contains("rel=\"noopener noreferrer\""), "{html}");
317 assert!(!html.contains("hx-get"), "nothing swaps: {html}");
318 }
319
320 #[test]
321 fn the_upstream_line_keeps_its_link() {
322 // One sentence with one linked word in it. If the strict markdown preset
323 // ever drops links this reads as plain prose and the way to
324 // Multithreaded quietly disappears from both screens.
325 let html = render(&upstream_line("https://mt.example.com"));
326 assert!(html.contains("href=\"https://mt.example.com\""), "{html}");
327 assert!(html.contains("Multithreaded"), "{html}");
328 }
329
330 #[test]
331 fn an_empty_library_offers_a_way_out_only_when_there_is_one() {
332 let configured = render(&library_pane(&[], "https://mt.example.com"));
333 // The apostrophe arrives escaped, so the assertion matches the half of
334 // the sentence that survives verbatim rather than re-encoding it here.
335 assert!(configured.contains("joined any forum communities yet."));
336 assert!(configured.contains("Browse Communities"), "{configured}");
337
338 // Multithreaded not configured at all: the same sentence, and no button
339 // pointing at an empty address.
340 let bare = render(&library_pane(&[], ""));
341 assert!(bare.contains("joined any forum communities yet."));
342 assert!(!bare.contains("Browse Communities"), "{bare}");
343 }
344
345 #[test]
346 fn an_empty_settings_section_keeps_its_heading() {
347 // The asymmetry the two templates encode: the settings section draws its
348 // heading and its line whether or not there is a table under them.
349 let html = render(&settings_pane(&[], "https://mt.example.com"));
350 assert!(html.contains("Forum Communities"));
351 assert!(html.contains("joined any forum communities yet."));
352 assert!(!html.contains("role=\"table\""), "{html}");
353 }
354
355 #[test]
356 fn a_community_name_cannot_smuggle_markup() {
357 // Every string on this screen came from another service's JSON, which
358 // is a wider door than a form on this one.
359 let html = render(&table(&[membership("<script>x()</script>", "Member")]));
360 assert!(!html.contains("<script>x()"), "{html}");
361 }
362 }
363