|
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 |
+ |
}
|