Skip to main content

max / makenotwork

Describe the forum memberships table once for both screens
Author: Max Johnson <me@maxj.phd> · 2026-08-11 01:21 UTC
Signed with PGP, not checked
Commit: 2cf55d3f29e1ada3079837b6b4a547d35a7fe270
Parent: a14fbf4
4 files changed, +404 insertions, -7 deletions
@@ -31,6 +31,7 @@
31 31 use crate::AppState;
32 32 use crate::auth::SessionUser;
33 33
34 + pub mod forum_memberships;
34 35 pub mod library_contacts;
35 36 pub mod ssh_keys;
36 37
@@ -121,6 +122,28 @@
121 122 mount(app, library_contacts::screen, library_contacts::renderer),
122 123 ));
123 124 }
125 + // One module, two screens: the library tab and the settings section are the
126 + // same table under different chrome, and each is switched on by itself.
127 + if described(app, forum_memberships::LIBRARY_SCREEN) {
128 + mounted.push((
129 + forum_memberships::LIBRARY_PATH,
130 + mount(
131 + app,
132 + forum_memberships::library_screen,
133 + forum_memberships::renderer,
134 + ),
135 + ));
136 + }
137 + if described(app, forum_memberships::SETTINGS_SCREEN) {
138 + mounted.push((
139 + forum_memberships::SETTINGS_PATH,
140 + mount(
141 + app,
142 + forum_memberships::settings_screen,
143 + forum_memberships::renderer,
144 + ),
145 + ));
146 + }
124 147
125 148 mounted
126 149 }
@@ -77,7 +77,6 @@
77 77 get(tabs::dashboard_tab_analytics),
78 78 )
79 79 .route_get("/dashboard/tabs/synckit", get(tabs::dashboard_tab_synckit))
80 - .route_get("/dashboard/tabs/forums", get(tabs::dashboard_tab_forums))
81 80 .route_get("/dashboard/tabs/media", get(tabs::dashboard_tab_media))
82 81 .route_get("/dashboard/tabs/support", get(tabs::dashboard_tab_support))
83 82 // The SSH-keys tab is registered below rather than here: when its
@@ -157,9 +156,9 @@
157 156 )
158 157 .route_layer(GovernorLayer::new(read_rate_limit));
159 158
160 - // The one screen the description layer serves, when it is switched on. The
159 + // The screens the description layer serves, when they are switched on. Each
161 160 // Askama handler stays registered and reachable in every other deployment,
162 - // which is what makes the conversion revert by editing an env var.
161 + // which is what makes a conversion revert by editing an env var.
163 162 let tab_routes = if screens.enabled(crate::quasi::ssh_keys::SCREEN) {
164 163 tab_routes
165 164 } else {
@@ -168,6 +167,14 @@
168 167 get(tabs::dashboard_tab_ssh_keys),
169 168 )
170 169 };
170 + let tab_routes = if screens.enabled(crate::quasi::forum_memberships::SETTINGS_SCREEN) {
171 + tab_routes
172 + } else {
173 + tab_routes.route_get(
174 + crate::quasi::forum_memberships::SETTINGS_PATH,
175 + get(tabs::dashboard_tab_forums),
176 + )
177 + };
171 178
172 179 CsrfRouter::new()
173 180 .merge(wizards::wizard_routes())
@@ -67,9 +67,18 @@
67 67 get(landing::library_tab_contacts),
68 68 )
69 69 };
70 + let communities_route = if screens.enabled(crate::quasi::forum_memberships::LIBRARY_SCREEN) {
71 + CsrfRouter::new()
72 + } else {
73 + CsrfRouter::new().route_get(
74 + crate::quasi::forum_memberships::LIBRARY_PATH,
75 + get(landing::library_tab_communities),
76 + )
77 + };
70 78
71 79 CsrfRouter::new()
72 80 .merge(contacts_route)
81 + .merge(communities_route)
73 82 .route_get("/", get(landing::index))
74 83 .route_get("/library", get(landing::library))
75 84 .route_get("/cart", get(landing::cart_page))
@@ -82,10 +91,6 @@
82 91 "/library/tabs/collections",
83 92 get(landing::library_tab_collections),
84 93 )
85 - .route_get(
86 - "/library/tabs/communities",
87 - get(landing::library_tab_communities),
88 - )
89 94 // Both health endpoints run ~8 COUNT(*) queries + an S3 connectivity
90 95 // round-trip per hit, unauthenticated, a strictly more expensive
91 96 // DoS-amplification surface than discover, which is already throttled.
@@ -1,0 +1,362 @@
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::Render;
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 + }