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"