diff --git a/server/src/fetch/html.rs b/server/src/fetch/html.rs index 20c2b6539ef8bab2ec5bc106c44af20229ce2bff..94cafb07e5465035e80674203fc95b0838a6d06a 100644 --- a/server/src/fetch/html.rs +++ b/server/src/fetch/html.rs @@ -7,7 +7,7 @@ use crate::{ html::sanitize::entity_body_html, nsfw::{node_is_nsfw, nsfw_entity_gate}, path_types::ItemId, - reddit::{is_children_fetchable, is_fetchable, is_ranked_fetchable}, + reddit::{is_children_fetchable, is_fetchable, is_ranked_fetchable, FetchKind}, reducer::NodeState, render::reddit::is_reddit_post, ui_action::UI_RPC_FIELD, @@ -70,7 +70,10 @@ fn fetch_button(item: &ItemId, kind: &str, label: &str, fetching: bool) -> Marku /// Reddit/API import controls — `POST /ui` with `fetch_entity` returns an SSE /// stream whose events are JS snippets to `eval`. -pub fn fetch_entity_panel(item: &ItemId, node: &NodeState, fetching: bool) -> Markup { +/// +/// When `fetching` is `Some(kind)`, only the button for that kind shows the +/// loading state; sibling fetch buttons stay interactive. +pub fn fetch_entity_panel(item: &ItemId, node: &NodeState, fetching: Option) -> Markup { let has_data = node.data.is_some(); let self_ok = is_fetchable(item); let children_ok = is_children_fetchable(item); @@ -78,7 +81,10 @@ pub fn fetch_entity_panel(item: &ItemId, node: &NodeState, fetching: bool) -> Ma if !self_ok && !children_ok && !ranked_ok { return html! {}; } - let self_label = if fetching { + let self_fetching = fetching == Some(FetchKind::SelfEntity); + let children_fetching = fetching == Some(FetchKind::Children); + let ranked_fetching = fetching == Some(FetchKind::Ranked); + let self_label = if self_fetching { "Fetching…" } else if has_data { "Refresh this" @@ -88,17 +94,22 @@ pub fn fetch_entity_panel(item: &ItemId, node: &NodeState, fetching: bool) -> Ma html! { div id="fetch-controls" class="fetch-controls" { @if self_ok { - (fetch_button(item, "self", self_label, fetching)) + (fetch_button(item, "self", self_label, self_fetching)) } @if children_ok { - (fetch_button(item, "children", if fetching { "Fetching…" } else { "Fetch posts" }, fetching)) + (fetch_button( + item, + "children", + if children_fetching { "Fetching…" } else { "Fetch posts" }, + children_fetching + )) } @if ranked_ok { (fetch_button( item, "ranked", - if fetching { "Fetching…" } else { "Refresh ranking" }, - fetching + if ranked_fetching { "Fetching…" } else { "Refresh ranking" }, + ranked_fetching )) } } @@ -106,7 +117,12 @@ pub fn fetch_entity_panel(item: &ItemId, node: &NodeState, fetching: bool) -> Ma } /// Entity card + fetch control (morph target [`entity_section_selector`]). -pub fn entity_section(item: &ItemId, node: &NodeState, fetching: bool, nsfw_ok: bool) -> Markup { +pub fn entity_section( + item: &ItemId, + node: &NodeState, + fetching: Option, + nsfw_ok: bool, +) -> Markup { let gated = node_is_nsfw(node) && !nsfw_ok; html! { section class="entity-section demo-panel" data-entity-section=(item.as_str()) { @@ -138,7 +154,7 @@ mod tests { }), ..Default::default() }; - let html = entity_section(&node.id, &node, false, false).into_string(); + let html = entity_section(&node.id, &node, None, false).into_string(); assert!(html.contains("NSFW content is hidden until you opt in.")); assert!( html.contains("Yes, I am 18+"), @@ -151,4 +167,36 @@ mod tests { "gated entity should not show fetch controls: {html}" ); } + + #[test] + fn only_active_fetch_button_shows_loading_state() { + let id = ItemId::from_url("https://reddit.com/r/rust").unwrap(); + let node = NodeState { + id: id.clone(), + ..Default::default() + }; + let html = fetch_entity_panel(&id, &node, Some(FetchKind::Children)).into_string(); + assert!( + html.contains("disabled"), + "active fetch button should be disabled: {html}" + ); + assert!( + html.contains(">Fetching…"), + "active fetch button should show loading label: {html}" + ); + assert!( + html.contains(">Fetch from Reddit"), + "sibling self button must stay idle: {html}" + ); + assert!( + !html.contains("disabled\">Fetch from Reddit"), + "sibling self button must not be disabled: {html}" + ); + // Children button is the only disabled one; self stays enabled. + let disabled_count = html.matches("disabled").count(); + assert_eq!( + disabled_count, 1, + "exactly one button should be disabled while fetching: {html}" + ); + } } diff --git a/server/src/fetch/mod.rs b/server/src/fetch/mod.rs index 4b117e4493369fe28bc2402c2cbcb358689c4c48..717e814542ac521a08646fccc7ed54ecfca460de 100644 --- a/server/src/fetch/mod.rs +++ b/server/src/fetch/mod.rs @@ -95,7 +95,10 @@ pub fn fetch_entity_stream( let node = tree.get(&id).unwrap_or(&empty); let sel = html::entity_section_selector(&id); JsBuilder::new() - .morph_selector(&sel, html::entity_section(&id, node, true, nsfw_ok)) + .morph_selector( + &sel, + html::entity_section(&id, node, Some(kind), nsfw_ok), + ) .build() }; yield Ok(js_event(fetching_js)); @@ -131,7 +134,7 @@ pub fn fetch_entity_stream( let node = tree.get(&id).unwrap_or(&empty); let sel = html::entity_section_selector(&id); let mut b = JsBuilder::new() - .morph_selector(&sel, html::entity_section(&id, node, false, nsfw_ok)); + .morph_selector(&sel, html::entity_section(&id, node, None, nsfw_ok)); if kind == FetchKind::Children || kind == FetchKind::Ranked { b = b.morph_selector( "#ranking-panel", @@ -146,7 +149,7 @@ pub fn fetch_entity_stream( let node = tree.get(&id).unwrap_or(&empty); let sel = html::entity_section_selector(&id); let js = JsBuilder::new() - .morph_selector(&sel, html::entity_section(&id, node, false, nsfw_ok)) + .morph_selector(&sel, html::entity_section(&id, node, None, nsfw_ok)) .raw(&error_js(&format!("Reddit rate limit — retry in {reset_secs}s."))) .build(); yield Ok(js_event(js)); @@ -157,7 +160,7 @@ pub fn fetch_entity_stream( let node = tree.get(&id).unwrap_or(&empty); let sel = html::entity_section_selector(&id); let js = JsBuilder::new() - .morph_selector(&sel, html::entity_section(&id, node, false, nsfw_ok)) + .morph_selector(&sel, html::entity_section(&id, node, None, nsfw_ok)) .raw(&error_js(&format!("Fetch failed: {msg}"))) .build(); yield Ok(js_event(js)); diff --git a/server/src/html/mod.rs b/server/src/html/mod.rs index 6ad49d4b7a96bf60007ff5c9281959c32f994b54..319907cdf879befc5bfb2b7b0e304fb06ecdc195 100644 --- a/server/src/html/mod.rs +++ b/server/src/html/mod.rs @@ -567,7 +567,7 @@ async fn item_page(state: AppState, uri: Uri, item: ItemId, jar: CookieJar) -> M @if gated { (nsfw_enter_panel(&return_to)) } @else { - (entity_section(&item, node, false, nsfw_ok)) + (entity_section(&item, node, None, nsfw_ok)) @if let Some(href) = vote_link { p class="vote-cta" { a class="btn-primary" href=(href) data-testid="vote-children" { "Vote on children" } diff --git a/server/src/html/vote.rs b/server/src/html/vote.rs index 7286ad162acaf04ff4cc4496efb6934728ab9cbc..0452eb75b043c88ae6cd30bf40a59307825fbe22 100644 --- a/server/src/html/vote.rs +++ b/server/src/html/vote.rs @@ -267,7 +267,7 @@ fn vote_compare_item_card( .expect("skip item rpc json"); html! { div class=(format!("vote-compare-side {side_class}")) { - (entity_section(item, &node, false, nsfw_ok)) + (entity_section(item, &node, None, nsfw_ok)) form class="vote-skip-form" method="POST" action="/ui" { input type="hidden" name=(UI_RPC_FIELD) value=(skip_rpc); button type="submit" class="btn-secondary vote-skip"