You are a constitutional council ranking individual git commits for ownership allocation. Compare these two commits. Decide which contributed more lasting value to the project. Judge substance, not spectacle: - Prefer correct, lasting design and real bugfixes over churn, formatting, renames, or generated noise. - Prefer clarity and necessity over sheer line count. A small precise change can beat a large diffuse one. - Do not favor a side merely because its patch is longer or noisier. - Weight what the change does for the project, not the contributor's name. Return ONLY a JSON object: {"winner": "A" or "B", "ratio": "N:M", "explanation": "..."} The explanation must cite concrete differences in the patches (1-3 sentences). Side A — contributor: tommy-mor Side A — commit message: [39d32611] helpers and cleanups Side A — unified diff (full patch): diff --git a/server/src/api/rpc.rs b/server/src/api/rpc.rs index 5049a26b096bd8435d6eb9e75ccb751b6f489061..0ff2d701dd1e8661f58abd55672cd91280e9491e 100644 --- a/server/src/api/rpc.rs +++ b/server/src/api/rpc.rs @@ -1333,11 +1333,8 @@ pub async fn handle_rpc_batch( }).collect() }) .unwrap_or_default(); - let thread_post_index = reduced - .ingests_by_scope_thread - .get(&(scope.clone(), e.thread.clone())) - .and_then(|q| q.iter().rev().position(|id| id == &e.post_id)) - .expect("rank history post_id must be in ingests_by_scope_thread for (scope, thread)"); + let thread_post_index = + reduced.thread_post_index_chronological(&scope, &e.thread, &e.post_id); RankHistoryRow { ts: e.ts, scope_rank: e.scope_rank, @@ -1504,11 +1501,11 @@ pub async fn handle_rpc_batch( .map(|ing| { let scope = scope_from_room_wire(&ing.room_id); let thread_post_index = reduced - .ingests_by_scope_thread - .get(&(scope, ing.thread_tag.clone())) - .and_then(|q| { - q.iter().rev().position(|pid| pid == &ing.id).map(|i| i + 1) - }); + .try_thread_post_index_chronological( + &scope, + &ing.thread_tag, + &ing.id, + ); FeedPost { ts: ing.ts, id: ing.id.clone(), @@ -1565,11 +1562,11 @@ pub async fn handle_rpc_batch( .map(|ing| { let scope = scope_from_room_wire(&ing.room_id); let thread_post_index = reduced - .ingests_by_scope_thread - .get(&(scope, ing.thread_tag.clone())) - .and_then(|q| { - q.iter().rev().position(|pid| pid == &ing.id).map(|i| i + 1) - }); + .try_thread_post_index_chronological( + &scope, + &ing.thread_tag, + &ing.id, + ); FeedPost { ts: ing.ts, id: ing.id.clone(), diff --git a/server/src/html/forum/ingest.rs b/server/src/html/forum/ingest.rs index 308358abc6cd6811079d9ec41b114093c0294d02..27d1852cf3b2a1718605e4633d4238383adfe33d 100644 --- a/server/src/html/forum/ingest.rs +++ b/server/src/html/forum/ingest.rs @@ -1,4 +1,3 @@ -use crate::canonical_path::canonicalize_tag; use crate::form_template::template_json_compact; use crate::reducer::{scope_from_room_wire, ReducerState}; use maud::{html, Markup}; @@ -31,11 +30,7 @@ pub(super) fn thread_nav_for_ingest(ing: &crate::events::Ingest) -> Option Option { let scope = scope_from_room_wire(&ing.room_id); - let tag = canonicalize_tag(&ing.thread_tag); - reduced - .ingests_by_scope_thread - .get(&(scope, tag)) - .and_then(|q| q.iter().rev().position(|id| id == &ing.id)) + reduced.try_thread_post_index_chronological(&scope, &ing.thread_tag, &ing.id) } fn post_header_meta( diff --git a/server/src/html/garden.rs b/server/src/html/garden.rs index 23245d6fdfc9019b199ab8417150faf5f3297067..423f23fd8c9ad7b7f454d6ea7a9a7607a4c9c5b9 100644 --- a/server/src/html/garden.rs +++ b/server/src/html/garden.rs @@ -11,7 +11,7 @@ use crate::{ canonical_path::canonicalize_item, events::ThreadCapability, path_types::CanonicalItemUrl, - reducer::{ReducerState, ScopeId}, + reducer::{ContentState, ReducerState, ScopeId}, ranking::{connected_components_from_voted_pairs, ranked_items_subset}, scope_rank::{build_children_rankings, ChildrenRankings}, state::AppState, @@ -133,6 +133,23 @@ fn user_can_view_room(reduced: &ReducerState, room_id: &str, username: Option<&s reduced.user_has_cap(room_id, u, ThreadCapability::View) } +/// Private `~/` / `-/` garden pages require at least one ingest in that scope (a `content` entry). +fn room_scope_has_garden_content(reduced: &ReducerState, nav: &ThreadNav) -> bool { + match nav.scope() { + ScopeId::Public => true, + ScopeId::Room(_) => reduced.content_for_scope(&nav.scope()).is_some(), + } +} + +fn content_for_garden_view<'a>(reduced: &'a ReducerState, scope: &ScopeId) -> &'a ContentState { + match scope { + ScopeId::Public => reduced.public(), + ScopeId::Room(_) => reduced.content_for_scope(scope).expect( + "room garden only renders after room_scope_has_garden_content returned true", + ), + } +} + /// Ontology index — root-level paths. Private (UUID) roots are excluded. pub async fn garden_index( State(state): State, @@ -298,16 +315,20 @@ pub async fn room_garden_index( uri: Uri, ) -> impl IntoResponse { let room_id = format!("{room_short}/{room_slug}"); + let Some(nav) = ThreadNav::from_room_id(&room_id) else { + return (StatusCode::NOT_FOUND, "bad room path").into_response(); + }; let reduced = state.reduced.read().await; let user = optional_principal(&headers, &jar, &reduced); if !user_can_view_room(&reduced, &room_id, user.as_deref()) { drop(reduced); return room_not_found_page(&jar, &uri).into_response(); } + if !room_scope_has_garden_content(&reduced, &nav) { + drop(reduced); + return room_not_found_page(&jar, &uri).into_response(); + } drop(reduced); - let Some(nav) = ThreadNav::from_room_id(&room_id) else { - return (StatusCode::NOT_FOUND, "bad room path").into_response(); - }; render_scope_view( state, GardenBrowsePath::Tilde(OntologyPath::root()), @@ -326,25 +347,26 @@ pub async fn room_external_garden_index( uri: Uri, ) -> impl IntoResponse { let room_id = format!("{room_short}/{room_slug}"); + let Some(nav) = ThreadNav::from_room_id(&room_id) else { + return (StatusCode::NOT_FOUND, "bad room path").into_response(); + }; let reduced = state.reduced.read().await; let user = optional_principal(&headers, &jar, &reduced); if !user_can_view_room(&reduced, &room_id, user.as_deref()) { drop(reduced); return room_not_found_page(&jar, &uri).into_response(); } - drop(reduced); - let Some(nav) = ThreadNav::from_room_id(&room_id) else { - return (StatusCode::NOT_FOUND, "bad room path").into_response(); - }; + if !room_scope_has_garden_content(&reduced, &nav) { + drop(reduced); + return room_not_found_page(&jar, &uri).into_response(); + } let ext_path = ExternalOntologyPath::from_input(""); let parent = CanonicalItemUrl::parse("https://.").unwrap(); - let child_rankings = { - let reduced = state.reduced.read().await; - let content = reduced - .content_for_scope(&nav.scope()) - .unwrap_or_else(|| reduced.public()); - build_children_rankings(content, &parent) - }; + let child_rankings = build_children_rankings( + content_for_garden_view(&reduced, &nav.scope()), + &parent, + ); + drop(reduced); let page = layout( "-/", @@ -400,16 +422,20 @@ pub async fn room_external_ontology_path( uri: Uri, ) -> impl IntoResponse { let room_id = format!("{room_short}/{room_slug}"); + let Some(nav) = ThreadNav::from_room_id(&room_id) else { + return (StatusCode::NOT_FOUND, "bad room path").into_response(); + }; let reduced = state.reduced.read().await; let user = optional_principal(&headers, &jar, &reduced); if !user_can_view_room(&reduced, &room_id, user.as_deref()) { drop(reduced); return room_not_found_page(&jar, &uri).into_response(); } + if !room_scope_has_garden_content(&reduced, &nav) { + drop(reduced); + return room_not_found_page(&jar, &uri).into_response(); + } drop(reduced); - let Some(nav) = ThreadNav::from_room_id(&room_id) else { - return (StatusCode::NOT_FOUND, "bad room path").into_response(); - }; let path = ExternalOntologyPath::from_input(&path); render_scope_view(state, GardenBrowsePath::External(path), nav, jar, uri).await } @@ -422,16 +448,20 @@ pub async fn room_ontology_path( uri: Uri, ) -> impl IntoResponse { let room_id = format!("{room_short}/{room_slug}"); + let Some(nav) = ThreadNav::from_room_id(&room_id) else { + return (StatusCode::NOT_FOUND, "bad room path").into_response(); + }; let reduced = state.reduced.read().await; let user = optional_principal(&headers, &jar, &reduced); if !user_can_view_room(&reduced, &room_id, user.as_deref()) { drop(reduced); return room_not_found_page(&jar, &uri).into_response(); } + if !room_scope_has_garden_content(&reduced, &nav) { + drop(reduced); + return room_not_found_page(&jar, &uri).into_response(); + } drop(reduced); - let Some(nav) = ThreadNav::from_room_id(&room_id) else { - return (StatusCode::NOT_FOUND, "bad room path").into_response(); - }; let path = OntologyPath::from_input(&path); render_scope_view(state, GardenBrowsePath::Tilde(path), nav, jar, uri).await } @@ -474,9 +504,7 @@ fn build_sibling_rank( item: &CanonicalItemUrl, ) -> Option { let item = item.clone().normalized_storage(); - let content = reduced - .content_for_scope(scope) - .unwrap_or_else(|| reduced.public()); + let content = content_for_garden_view(reduced, scope); let group = &content.ranking_group; let parent = item.parent()?.normalized_storage(); let siblings: Vec = content @@ -537,9 +565,7 @@ fn build_rank_history( scope: &ScopeId, item: &str, ) -> Vec { - let content = reduced - .content_for_scope(scope) - .unwrap_or_else(|| reduced.public()); + let content = content_for_garden_view(reduced, scope); let item_key = CanonicalItemUrl(item.to_string()); let entries = match content.rank_history.get(&item_key) { None => return vec![], @@ -574,11 +600,8 @@ fn build_rank_history( }) .unwrap_or_default(); - let thread_post_index = reduced - .ingests_by_scope_thread - .get(&(scope.clone(), e.thread.clone())) - .and_then(|q| q.iter().rev().position(|id| id == &e.post_id)) - .expect("rank history post_id must be in ingests_by_scope_thread for (scope, thread)"); + let thread_post_index = + reduced.thread_post_index_chronological(scope, &e.thread, &e.post_id); RankHistoryEntryView { ts: e.ts, @@ -597,9 +620,7 @@ fn build_item_page_view_model( scope: &ScopeId, item: &str, ) -> ItemPageViewModel { - let content = reduced - .content_for_scope(scope) - .unwrap_or_else(|| reduced.public()); + let content = content_for_garden_view(reduced, scope); let item_key = CanonicalItemUrl::parse(item) .unwrap_or_else(|| CanonicalItemUrl::parse("~/").unwrap()) .normalized_storage(); diff --git a/server/src/reducer.rs b/server/src/reducer.rs index 37d17e0810b627b0b54884ce707c372b69708df2..a8651994f2c062af25b6ad792a014dcc083296f9 100644 --- a/server/src/reducer.rs +++ b/server/src/reducer.rs @@ -265,6 +265,31 @@ impl ReducerState { self.content.get(scope) } + /// 0-based chronological index of `post_id` in `(scope, thread_tag)` (forum routes `/t/tag/N`). + pub fn try_thread_post_index_chronological( + &self, + scope: &ScopeId, + thread_tag: &str, + post_id: &str, + ) -> Option { + let tag = canonicalize_tag(thread_tag); + self.ingests_by_scope_thread + .get(&(scope.clone(), tag)) + .and_then(|q| q.iter().rev().position(|pid| pid == post_id)) + } + + pub fn thread_post_index_chronological( + &self, + scope: &ScopeId, + thread_tag: &str, + post_id: &str, + ) -> usize { + self.try_thread_post_index_chronological(scope, thread_tag, post_id) + .expect( + "post_id must appear in ingests_by_scope_thread for this scope and thread", + ) + } + /// Ingest ids authored by `actor`, oldest first. Unfiltered; use with access checks per ingest. pub fn posts_by_actor_ids(&self, actor: &str) -> Vec { self.posts_by_actor @@ -598,6 +623,8 @@ impl ReducerState { let _ = Self::apply_ingest_to_content(&mut cs, ing); } self.content.insert(scope, cs); + // `ingests_by_scope_thread` is intentionally not rebuilt: tombstoned ids stay in the deque so + // per-post URLs and chronological indices remain stable; only projected garden state resets. } /// Drop all reducer state keyed by a private room id (forum, garden scope, invites, grants). @@ -720,6 +747,7 @@ impl ReducerState { return; }; let scope = scope_from_room_wire(&ing.room_id.trim()); + // Rebuilds garden projection only; `ingests_by_scope_thread` is left as-is — see `rebuild_scope_content`. self.rebuild_scope_content(scope); } Event::GrantAdded(ga) => { diff --git a/server/tests/integration.rs b/server/tests/integration.rs index 9766adb43ad315a64f5df17b79f66718ce149509..515c691d04ef3f1f4bdfa8ee911360a43fd2b26e 100644 --- a/server/tests/integration.rs +++ b/server/tests/integration.rs @@ -760,6 +760,43 @@ async fn test_private_room_garden_root_lists_top_level_tilde_children() { assert!(body.contains("ordering 1")); } +#[tokio::test] +async fn test_empty_private_room_garden_returns_404() { + let (addr, _tmp, _log, _handle) = create_test_server().await; + let client = reqwest::Client::builder() + .redirect(reqwest::redirect::Policy::none()) + .build() + .unwrap(); + let bearer = test_bearer(); + + let create = rpc_batch( + &client, + addr, + Some(&bearer), + serde_json::json!([{ + "RoomCreate": { "slug": "empty-garden-room" } + }]), + ) + .await; + let room_id = create["results"][0]["result"]["RoomCreated"]["room_id"] + .as_str() + .unwrap() + .to_string(); + let (room_short, room_slug) = room_id.split_once('/').unwrap(); + + let root = client + .get(format!("http://{addr}/r/{room_short}/{room_slug}/~")) + .header("Authorization", format!("Bearer {bearer}")) + .send() + .await + .unwrap(); + assert_eq!( + root.status(), + reqwest::StatusCode::NOT_FOUND, + "no ingest yet → no room scope content → must not fall back to public garden" + ); +} + #[tokio::test] async fn test_post_check_returns_targeted_js_error_for_missing_thread_tag() { let (addr, _tmp, _log, _handle) = create_test_server().await; diff --git a/test/browser_redact_thread_index.clj b/test/browser_redact_thread_index.clj new file mode 100644 index 0000000000000000000000000000000000000000..88a86fb9844fcf78df85e59f21f2e37f27df96a5 --- /dev/null +++ b/test/browser_redact_thread_index.clj @@ -0,0 +1,169 @@ +(ns test.browser-redact-thread-index + "Regression: after PostRedacted, rebuild_scope_content drops garden projection for the redacted + ingest but ingests_by_scope_thread keeps tombstone ids so chronological indices stay aligned + with /t/:tag/:index and GetRankHistory.thread_post_index. If redacted ids were removed from the + deque without replumbing rank history / single-post resolution, the second post would incorrectly + get thread_post_index 0 (and GetRankHistory would panic if history rows still referenced the + redacted post_id)." + (:require [babashka.fs :as fs] + [cheshire.core :as json] + [clojure.string :as str] + [clojure.test :refer [deftest is]] + [com.blockether.spel.core :as core] + [com.blockether.spel.locator :as locator] + [com.blockether.spel.page :as page] + [test.common :as common] + [test.oauth :as oauth])) + +(defn- wait-for-text [pg selector expected timeout-ms] + (let [deadline (+ (System/currentTimeMillis) timeout-ms)] + (loop [] + (let [text (locator/text-content (page/locator pg selector))] + (if (and (string? text) (str/includes? text expected)) + true + (if (< (System/currentTimeMillis) deadline) + (do (Thread/sleep 200) (recur)) + false)))))) + +(defn- thread-post-at-index [forum-json idx] + (some (fn [item] + (when (and (= "post" (get item "kind")) + (= idx (get item "index"))) + item)) + (get-in forum-json ["results" 0 "result" "ForumThread" "items"]))) + +(defn redact-preserves-thread-chrono-index-flow! [] + (println "\n━━━ browser: PostRedact vs thread_post_index_chronological ━━━\n") + + (common/letlocals + (bind build (common/run-cargo-build-release! ["slugsocial-server"])) + (is (zero? (:exit build)) "cargo build succeeds") + (bind server-bin "target/release/slugsocial-server") + + (bind tmp-dir (str (fs/create-temp-dir {:prefix "slug-browser-redact-idx-"}))) + (bind slug-port (common/pick-port)) + (bind google-port (common/pick-port)) + (bind base-url (str "http://127.0.0.1:" slug-port)) + (bind google-url (str "http://127.0.0.1:" google-port)) + + (bind !server (atom nil)) + (bind !google (atom nil)) + (bind server-env (common/slug-server-env tmp-dir base-url google-url slug-port)) + (try + (reset! !google (oauth/start-mock-google google-port + :google-users ["google-user-alice"])) + (reset! !server (common/start-server server-bin server-env)) + (is (common/wait-for-server base-url 10000) "server responds to /healthz") + + (let [alice-token (oauth/fetch-bearer-token! base-url :username "alice") + thread-tag "redact-thread-idx" + item-second "~/rix/c" + ;; Two chronologically ordered posts in the same thread with disjoint tilde paths so + ;; replay after redacting the first only reapplies the second ingest successfully. + text-first (str "# " thread-tag "\n\n" + "~/rix/a {alpha}\n" + "~/rix/b {beta}\n" + "~/rix/a 2:1 ~/rix/b {browser redact thread idx post one}\n") + text-second (str "# " thread-tag "\n\n" + "~/rix/c {gamma}\n" + "~/rix/d {delta}\n" + "~/rix/c 2:1 ~/rix/d {browser redact thread idx post two}\n") + _ (is (true? (get-in (json/parse-string + (:body (oauth/http-post-json + (str base-url "/api/v0/rpc") + [{"Post" {"room" "public" + "thread_tag" thread-tag + "text" text-first + "return_rank_diff" false}}] + :headers {"Authorization" (str "Bearer " alice-token)})) + false) + ["results" 0 "ok"])) + "first Post rpc ok") + _ (is (true? (get-in (json/parse-string + (:body (oauth/http-post-json + (str base-url "/api/v0/rpc") + [{"Post" {"room" "public" + "thread_tag" thread-tag + "text" text-second + "return_rank_diff" false}}] + :headers {"Authorization" (str "Bearer " alice-token)})) + false) + ["results" 0 "ok"])) + "second Post rpc ok") + thr (json/parse-string + (:body (oauth/http-post-json + (str base-url "/api/v0/rpc") + [{"GetForumThread" {"room" "public" + "thread_tag" thread-tag + "offset" 0 + "limit" 50 + "since" nil + "before" nil + "actor" nil + "post_id" nil}}] + :headers {})) + false) + p0 (thread-post-at-index thr 0) + p1 (thread-post-at-index thr 1) + id-oldest (get p0 "id") + id-newer (get p1 "id") + _ (is (some? id-oldest) "thread has chronological index 0") + _ (is (some? id-newer) "thread has chronological index 1") + _ (is (not= id-oldest id-newer) "two distinct post ids") + rx (oauth/http-post-json + (str base-url "/api/v0/rpc") + [{"PostRedact" {"post_id" id-oldest}}] + :headers {"Authorization" (str "Bearer " alice-token)}) + _ (is (true? (get-in (json/parse-string (:body rx) false) ["results" 0 "ok"])) + "PostRedact oldest succeeds") + hist-resp (oauth/http-post-json + (str base-url "/api/v0/rpc") + [{"GetRankHistory" {"room" "public" + "item_path" item-second}}]) + hist-json (json/parse-string (:body hist-resp) false) + history (get-in hist-json ["results" 0 "result" "RankHistory" "history"])] + (is (true? (get-in hist-json ["results" 0 "ok"])) + "GetRankHistory ok after redact") + (is (seq history) + "GetRankHistory returns rows for second-post-only item (garden replayed without first ingest)") + (doseq [[i row] (map-indexed vector history)] + (is (= 1 (get row "thread_post_index")) + (str "RankHistory row " i ": thread_post_index must stay 1 for the newer post after " + "redacting chronological index 0. Stable indices require keeping redacted ids in " + "ingests_by_scope_thread; naively filtering them out makes this 0 (and can panic " + "rank history if rows still reference the redacted post)."))) + (let [url-0 (str base-url "/t/" thread-tag "/0") + url-1 (str base-url "/t/" thread-tag "/1") + g0 (oauth/http-get url-0) + g1 (oauth/http-get url-1)] + (is (= 200 (:status g0)) + "GET /t/:tag/0 responds after redact (tombstone slot preserved)") + (is (str/includes? (:body g0) "deleted") + "single-post URL index 0 shows tombstone after redact") + (is (= 200 (:status g1)) + "GET /t/:tag/1 responds") + (is (str/includes? (:body g1) "browser redact thread idx post two") + "index 1 still resolves to the second post body (not shifted to 0)")) + (core/with-playwright [pw] + (core/with-browser [browser (core/launch-chromium pw {:headless true :channel "chrome"})] + (core/with-context [ctx (core/new-context browser)] + (core/with-page [pg (core/new-page-from-context ctx)] + (page/navigate pg (str base-url "/t/" thread-tag "/0")) + (is (wait-for-text pg "body" "deleted" 15000) + "browser: per-post view 0 shows deleted tombstone") + (page/navigate pg (str base-url "/t/" thread-tag "/1")) + (is (wait-for-text pg "body" "browser redact thread idx post two" 15000) + "browser: per-post view 1 still shows second post")))))) + + (finally + (when-some [s @!server] (common/kill-server s)) + (when-some [g @!google] ((:stop-fn g))) + (fs/delete-tree tmp-dir))) + + nil)) + +(defn browser-redact-thread-index-test [& _args] + (redact-preserves-thread-chrono-index-flow!)) + +(deftest browser-redact-thread-index-stable-after-post-redacted + (redact-preserves-thread-chrono-index-flow!)) diff --git a/tests.edn b/tests.edn index 6e96432ba766461b4233c75dc48db2633483e5e0..bbc4545da34f9fad41e92a336500d79e4294f51c 100644 --- a/tests.edn +++ b/tests.edn @@ -17,7 +17,8 @@ "^test\\.browser-ui-morph$" "^test\\.browser-post-redact$" "^test\\.browser-room-delete$" - "^test\\.browser-public-garden$"] + "^test\\.browser-public-garden$" + "^test\\.browser-redact-thread-index$"] :kaocha.filter/skip-meta [:skip] :parallel? false}] :plugins [:kaocha.plugin/junit-xml] diff --git a/types/src/lib.rs b/types/src/lib.rs index 49abba8ea9f786ef68e3157d2a5d309e15e09ba0..fce1b5fa5e259a4b86a11cb4d4f7cc9bec3a0aa1 100644 --- a/types/src/lib.rs +++ b/types/src/lib.rs @@ -253,7 +253,7 @@ pub struct FeedPost { /// Primary thread tag (without #), if the ingest declared one. #[serde(skip_serializing_if = "Option::is_none")] pub thread: Option, - /// 1-based display ordinal for this post within the thread (feed only; URLs use 0-based paths). + /// 0-based chronological index within the thread (same as forum `/t/tag/N`). #[serde(skip_serializing_if = "Option::is_none")] pub thread_post_index: Option, /// Full raw body of the ingest document. Side B — contributor: tommy-mor Side B — commit message: [39f8fb3c] nit Side B — unified diff (full patch): diff --git a/server/src/html/forum/new_thread.rs b/server/src/html/forum/new_thread.rs index 4dd2654f953a7db6ee9b25c348f99906da93a94a..3ef791ea96f83ee9bb333ec225483ac317c2881d 100644 --- a/server/src/html/forum/new_thread.rs +++ b/server/src/html/forum/new_thread.rs @@ -15,7 +15,6 @@ const TAG_INPUT_ID: &str = "new-thread-tag"; fn new_thread_compose_section(room_wire: &str) -> Markup { html! { section class="compose" id=(COMPOSE_SECTION_ID) { - div id=(ERRORS_ID) {} form id=(FORM_ID) method="POST" action="/ui" data-check-action="/ui" data-check-rpc=(template_json_compact(&json!({ "action": "check_ingest", "room": room_wire, @@ -36,6 +35,7 @@ fn new_thread_compose_section(room_wire: &str) -> Markup { textarea name="text" rows="4" placeholder="First post body…" required {} p { button type="submit" { "create thread / post" } } } + div id=(ERRORS_ID) {} } } }