//! 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 makeover_layout as layout; use quasi_router::screen::{Act, Cell, Cells, Column, Tag}; use quasi_router::{Action, Node, RegionKind, Request, Response, RouteError, Slot}; 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 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), )) } /// 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() } /// 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. fn upstream_line(base: &str) -> Node { super::own_prose(format!( "Your memberships across [Multithreaded]({base}) forum communities." )) } /// Everything inside the library's tab pane. fn library_pane(memberships: &[MembershipView], base: &str) -> Node { let mut slot = Slot::new(LIBRARY_REGION, RegionKind::Pane); if memberships.is_empty() { let mut nothing = Node::empty("You haven't joined any forum communities yet."); // 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. if !base.is_empty() { nothing = nothing.offering(Act::new("Browse Communities", Action::external(base))); } return Node::Region(slot.with(nothing)); } slot = slot.with(upstream_line(base)).with(table(memberships)); Node::Region(slot) } /// Everything inside the settings pane. fn settings_pane(memberships: &[MembershipView], base: &str) -> Node { let mut slot = Slot::new(SETTINGS_REGION, RegionKind::Pane) .with(Node::section("Forum Communities")) .with(upstream_line(base)); // The heading and the line are drawn either way here, unlike the library's, // which is the one real difference between the two screens. slot = if memberships.is_empty() { slot.with(Node::empty("You haven't joined any forum communities yet.")) } else { slot.with(table(memberships)) }; Node::Region(slot) } /// The memberships, written once for both screens. fn table(memberships: &[MembershipView]) -> Node { Node::Table { columns: vec![ Column::new("Community") .width(layout::Width::Fill) .priority(layout::Priority::Essential), Column::new("Role").width(layout::Width::Content), Column::new("Posts").width(layout::Width::Content), Column::new("Joined") .width(layout::Width::Content) .priority(layout::Priority::Optional), ], rows: memberships .iter() .map(|membership| { Cells::new([ // 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::new(membership.community.clone()) .activate(Action::external(membership.profile_url.clone())), Cell::tag(Tag::badge(membership.role.clone())), Cell::new(membership.posts.clone()), Cell::new(membership.joined.clone()), ]) }) .collect(), // No paging described here: every one of these tables is a // whole set the handler already counted. more: None, } } /// The renderer both screens are drawn with. pub fn renderer(viewer: &Viewer) -> Webview { Webview::new().with_shell(viewer.shell()) } #[cfg(test)] mod tests { use super::*; use quasi_axum::Serves; fn membership(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(), } } 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}" ); } #[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("