//! The reader's Multithreaded forum memberships, described. Two screens, one //! table. //! //! S4's second batch, and it is one conversion serving two addresses. The //! library's Communities tab and the settings pane's Forums section are the same //! four columns over the same data from the same upstream call, differing only //! in their chrome: the library one has no heading and offers a way out when the //! list is empty, the settings one has a heading and does not. //! //! Compare `routes::pages::public::landing::library_tab_communities` and //! `routes::pages::dashboard::tabs::user::dashboard_tab_forums`, which answer //! the two addresses from Askama when the screens are switched off. Those are //! two templates and two handlers holding one table between them, and the copies //! have already drifted: `user_forums.html` carries a `col-role` class on two //! cells that `library_communities.html` does not. Here the table is written //! once and the divergence has nowhere to live. //! //! # The blocking hop is a different animal on this screen //! //! Every described screen so far reached sqlx, and S3's load measurement was //! taken against sub-millisecond database round trips. This one holds its //! blocking thread across an **outbound HTTP call to another service**, with a //! five-second timeout, so a slow or hanging Multithreaded parks a thread for up //! to five seconds rather than for a query. Tokio's blocking pool defaults to //! 512 threads and the Askama version pays the same latency on a runtime worker, //! so this is not a regression and is not exhaustion at any plausible //! concurrency. It is a different regime from the one that was measured, and the //! S3 numbers should not be read as covering it. //! //! Both screens fail soft rather than propagating an upstream error, which is //! what the Askama handlers do and is the right call for a tab: a non-success //! status renders an empty list. use quasi_declare::declare; use quasi_router::screen::Tag; use quasi_router::{Request, Response, RouteError}; use quasi_webview::Webview; use super::Viewer; /// The library tab's name, read by `library_tabs` as the described marker. pub const LIBRARY_SCREEN: &str = "library_communities"; /// The address the library tab answers. pub const LIBRARY_PATH: &str = "/library/tabs/communities"; /// The region the library's tab nav targets. pub const LIBRARY_REGION: &str = "library-communities"; /// The settings section's name, read by `settings_tabs` as the described marker. pub const SETTINGS_SCREEN: &str = "user_forums"; /// The address the settings section answers. pub const SETTINGS_PATH: &str = "/dashboard/tabs/forums"; /// The region the settings strip draws for this section. /// /// Was `settings-body`, the single pane the hand-written sub-nav swapped into. /// `6b24f2df` step 4 gave each section a frame; `quasi::settings_tabs` draws /// this one from this constant. pub const SETTINGS_REGION: &str = "settings-forums"; /// How long to wait on Multithreaded before giving up, matching both Askama /// handlers. const UPSTREAM_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5); /// One community this reader belongs to, as the screens need it. pub(crate) struct MembershipView { community: String, profile_url: String, role: String, posts: String, joined: String, } /// The library's Communities tab. /// /// Renders an empty list rather than refusing when Multithreaded is not /// configured at all, which is what its Askama handler does: the library shows /// every tab to everyone, so the tab has to say something. pub fn library_screen(viewer: &Viewer, _request: Request) -> Result { let Some(base) = configured_base(viewer) else { return Ok(Response::fragment(LIBRARY_REGION, library_pane(&[], ""))); }; let memberships = fetch(viewer, viewer.reader()?, &base); Ok(Response::fragment( LIBRARY_REGION, library_pane(&memberships, &base), )) } /// The settings pane's Forums section. /// /// Refuses when Multithreaded is not configured, which is what its Askama /// handler does and is right here: the settings nav only draws this entry when /// the reader has memberships, so reaching it unconfigured is a wiring mistake /// rather than an empty list. pub fn settings_screen(viewer: &Viewer, _request: Request) -> Result { let base = configured_base(viewer) .ok_or_else(|| RouteError::not_found("forums are not configured"))?; let memberships = fetch(viewer, viewer.reader()?, &base); Ok(Response::fragment( SETTINGS_REGION, settings_pane(&memberships, &base), )) } /// The one read both panes make, for the mounts that serve them from residuals. /// /// Answers the memberships and where they are, which is what both panes fill /// from. A pane that is not configured has no base and no memberships, and /// `library_screen` already treats those as one state. pub(crate) fn reading(viewer: &Viewer) -> Result<(Vec, String), RouteError> { let Some(base) = configured_base(viewer) else { return Ok((Vec::new(), String::new())); }; let memberships = fetch(viewer, viewer.reader()?, &base); Ok((memberships, base)) } /// The same read for the settings pane, which refuses when nothing is /// configured rather than drawing an empty list. See [`settings_screen`]. pub(crate) fn settings_reading( viewer: &Viewer, ) -> Result<(Vec, String), RouteError> { let base = configured_base(viewer) .ok_or_else(|| RouteError::not_found("forums are not configured"))?; let memberships = fetch(viewer, viewer.reader()?, &base); Ok((memberships, base)) } /// Where Multithreaded lives, if it lives anywhere. fn configured_base(viewer: &Viewer) -> Option { viewer.app.config.integrations.mt_base_url.clone() } /// Ask Multithreaded what this reader belongs to. /// /// Answers an empty list on every failure rather than an error. A tab that /// cannot reach an optional integration should say the reader has no /// memberships, not replace the settings pane with a stack trace, and both /// Askama handlers already made that choice. fn fetch(viewer: &Viewer, reader: &crate::auth::SessionUser, base: &str) -> Vec { let url = format!("{base}/api/user/{}/summary", reader.id); let username = reader.username.as_ref(); // The blocking hop, and the long one. See the module header. let body = viewer.block_on(async { let response = crate::helpers::HTTP_CLIENT .get(&url) .timeout(UPSTREAM_TIMEOUT) .send() .await .inspect_err(|error| tracing::warn!(?error, "failed to fetch MT user summary")) .ok()?; if !response.status().is_success() { return None; } response .json::() .await .inspect_err(|error| tracing::warn!(?error, "failed to parse MT summary response")) .ok() }); let Some(body) = body else { return Vec::new(); }; body["memberships"] .as_array() .map(|entries| { entries .iter() .filter_map(|entry| { let slug = entry["community_slug"].as_str()?; Some(MembershipView { community: entry["community_name"].as_str()?.to_owned(), profile_url: format!("{base}/p/{slug}/u/{username}"), role: entry["role"].as_str()?.to_owned(), posts: entry["post_count"].as_i64().unwrap_or(0).to_string(), joined: entry["joined_at"] .as_str() .and_then(|at| chrono::DateTime::parse_from_rfc3339(at).ok()) .map(|at| at.format("%b %d, %Y").to_string()) .unwrap_or_default(), }) }) .collect() }) .unwrap_or_default() } declare! { /// The sentence naming where these memberships are, with the link on the name. /// /// `Node::Rich` rather than two nodes and a control: it is one sentence with /// one word in it that goes somewhere, and splitting it into text, an act /// and more text is how a sentence stops reading as a sentence in every /// host. /// /// [`super::own_prose`]'s body, written here rather than called, because the /// address has to be a hole **inside** the markdown. Handed to `own_prose` /// the sentence is formatted first, so a staged shape gets one sentinel /// where the whole document should be; written here the derivation parses /// the link with a sentinel in its destination and the residual holds /// ` Node; region LIBRARY_REGION as Pane { // The way out is offered only when there is somewhere to send them, // which is the `{% if !mt_base_url.is_empty() %}` the template wrapped // its button in. empty "You haven't joined any forum communities yet." when memberships.is_empty() { offering "Browse Communities" to external base unless base.is_empty(); } include upstream_line(base) unless memberships.is_empty(); include table(memberships) unless memberships.is_empty(); } } declare! { /// Everything inside the settings pane. /// /// The heading and the line are drawn either way here, unlike the library's, /// which is the one real difference between the two screens. #[staged] pub(crate) shape settings_pane(memberships: &[MembershipView], base: &str) -> Node; region SETTINGS_REGION as Pane { section "Forum Communities"; include upstream_line(base); // Two guards rather than a dispatch, which renders the same and is // what the seam can derive: a dispatch's arms replace each other at one // position, so only one reaches the residual and the rest are lost. See // quasi-declare's `Emission::Given`, which refuses it. empty "You haven't joined any forum communities yet." when memberships.is_empty(); include table(memberships) unless memberships.is_empty(); } } declare! { /// The memberships, written once for both screens. /// /// Positional cells. Both screens take this table whole rather than picking /// columns out of it, so every membership contributes the same four cells. /// Nothing is paged, so no `more`: this is a whole set the handler already /// counted. #[staged] shape table(memberships: &[MembershipView]) -> Node; table { column "Community" { width Fill; priority Essential; } column "Role" { width Content; } column "Posts" { width Content; } column "Joined" { width Content; priority Optional; } for membership in memberships.iter() { cells { // The destination is Multithreaded, so it leaves. That is the // description saying it rather than the reader finding out: a // host with no browser can decide what to do with a link off // its own service. cell membership.community.clone() { activate to external membership.profile_url.clone(); } cell "" { token Tag::badge(membership.role.clone()); } cell membership.posts.clone(); cell membership.joined.clone(); } } } } /// The renderer both screens are drawn with. pub fn renderer(viewer: &Viewer) -> Webview { Webview::new().with_shell(viewer.shell()) } /// One membership as the tests draw it. /// /// Module-level rather than inside `mod tests` because `quasi::residuals` needs /// one too, and `MembershipView` is this module's own type. Test-only. #[cfg(test)] pub(crate) fn sample(community: &str, role: &str) -> MembershipView { MembershipView { community: community.into(), profile_url: format!("https://mt.example.com/p/{community}/u/max"), role: role.into(), posts: "12".into(), joined: "Aug 10, 2026".into(), } } #[cfg(test)] mod tests { use quasi_axum::Serves; use quasi_router::Node; use super::*; use super::sample as membership; fn render(node: &Node) -> String { Webview::new().fragment(node) } #[test] fn each_screen_matches_the_nav_that_targets_it() { // Two navs, two regions, and they are genuinely different: the library // swaps its tab pane, the settings section swaps the settings body. If // either disagrees the tab swaps into nothing, and no other test sees it. // The library half reads the described strip rather than the page, which // stopped holding the nav when the strip was described (`6b24f2df`). let library = crate::quasi::library_tabs::html("", true, true); assert!( library.contains(&format!("hx-get=\"{LIBRARY_PATH}\"")), "{library}" ); assert!( library.contains(&format!("id=\"{LIBRARY_REGION}\"")), "{library}" ); // Read off the described strip since `6b24f2df` step 4, the same way the // library half above is. The hand-written nav this used to read is gone. let settings = crate::quasi::settings_tabs::html( 0, "", crate::quasi::settings_tabs::Gates { has_media: true, git_enabled: true, has_mt_memberships: true, has_sync_apps: true, }, ); assert!( settings.contains(&format!("hx-get=\"{SETTINGS_PATH}\"")), "{settings}" ); assert!( settings.contains(&format!("id=\"{SETTINGS_REGION}\"")), "{settings}" ); } /// The four headings, which moved from a hand-written `Table::new` list /// into the declaration. A column dropped on the way is silent: the cells /// still render and land under the wrong name. #[test] fn every_column_the_table_had_is_still_named() { let html = render(&table(&[membership("rust", "Moderator")])); for heading in ["Community", "Role", "Posts", "Joined"] { assert!(html.contains(heading), "{heading} is gone from {html}"); } } #[test] fn one_table_serves_both_screens() { // The point of the batch. Two screens rendering the same rows differ in // their chrome and nowhere else, which is what the two templates failed // at: one of them grew a `col-role` class the other never got. let rows = [membership("rust", "Moderator")]; let library = render(&library_pane(&rows, "https://mt.example.com")); let settings = render(&settings_pane(&rows, "https://mt.example.com")); for fragment in ["rust", "Moderator", "12", "Aug 10, 2026"] { assert!( library.contains(fragment), "{fragment} in library: {library}" ); assert!( settings.contains(fragment), "{fragment} in settings: {settings}" ); } // Only the settings screen carries the heading. assert!(settings.contains("Forum Communities")); assert!(!library.contains("Forum Communities")); } #[test] fn a_community_name_leaves_for_multithreaded() { let html = render(&table(&[membership("rust", "Member")])); assert!( html.contains("href=\"https://mt.example.com/p/rust/u/max\""), "{html}" ); // External, so it leaves properly: a new tab that cannot reach back // through `window.opener`, and no htmx swap. assert!(html.contains("rel=\"noopener noreferrer\""), "{html}"); assert!(!html.contains("hx-get"), "nothing swaps: {html}"); } #[test] fn the_upstream_line_keeps_its_link() { // One sentence with one linked word in it. If the strict markdown preset // ever drops links this reads as plain prose and the way to // Multithreaded quietly disappears from both screens. let html = render(&upstream_line("https://mt.example.com")); assert!(html.contains("href=\"https://mt.example.com\""), "{html}"); assert!(html.contains("Multithreaded"), "{html}"); } #[test] fn an_empty_library_offers_a_way_out_only_when_there_is_one() { let configured = render(&library_pane(&[], "https://mt.example.com")); // The apostrophe arrives escaped, so the assertion matches the half of // the sentence that survives verbatim rather than re-encoding it here. assert!(configured.contains("joined any forum communities yet.")); assert!(configured.contains("Browse Communities"), "{configured}"); // `b279b9eb`: the way out destroys nothing and interrupts nobody, so it // takes neither mark. Asserted rather than assumed because a blanket // pass over the acts in this tree would toll it dangerous, and the // separation is only real if the un-marked case is checked too. assert!(!configured.contains("data-tone"), "{configured}"); assert!(!configured.contains("hx-confirm"), "{configured}"); // Multithreaded not configured at all: the same sentence, and no button // pointing at an empty address. let bare = render(&library_pane(&[], "")); assert!(bare.contains("joined any forum communities yet.")); assert!(!bare.contains("Browse Communities"), "{bare}"); } #[test] fn an_empty_settings_section_keeps_its_heading() { // The asymmetry the two templates encode: the settings section draws its // heading and its line whether or not there is a table under them. let html = render(&settings_pane(&[], "https://mt.example.com")); assert!(html.contains("Forum Communities")); assert!(html.contains("joined any forum communities yet.")); assert!(!html.contains("role=\"table\""), "{html}"); } #[test] fn a_community_name_cannot_smuggle_markup() { // Every string on this screen came from another service's JSON, which // is a wider door than a form on this one. let html = render(&table(&[membership("", "Member")])); assert!(!html.contains("