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: [d6da7856] Resurrect page view counts (ViewStore + middleware + layout) (#138) * Wire ViewStore through AppState, view-count middleware, and HTML. - Add views module to lib, ViewStore on AppState (create_app_state and tests). - Implement canonical_view_url and GET view_count_middleware with path filters. - Layer middleware before with_state; add url crate for query canonicalization. - Pass canonical-key view counts into garden, forum, search, and try layouts. - Replace stub integration test with real view counter assertions. Co-authored-by: tommy * Test vote/compare query canonicalization instead of search. Vote compare uses chromeless layout without view badge; assert the shared ViewStore count for permuted left/right query order via AppState. Co-authored-by: tommy * Browser test: stop waiting for removed vote-compare shell. Assert vote compare via body.view-vote-compare and compare heading instead of .vote-compare-shell, which was intentionally removed from the HTML. Co-authored-by: tommy * Drop vote-compare preview morph and browser assertion. vote_compare_post_success_js now only refreshes #vote-edge-history-region. Remove preview wrap markup, browser test wait on #vote-compare-preview, and unused forum re-export of ingest_entry_markup. Co-authored-by: tommy --------- Co-authored-by: Cursor Agent Side A — unified diff (full patch): diff --git a/Cargo.lock b/Cargo.lock index ad7e4fe6d4ba2f2b033916194c1ef1ed873f1d46..bf8153d9c723af97122c9ffdd4a7cfe82e853bb6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1805,6 +1805,7 @@ dependencies = [ "tower-http 0.5.2", "tracing", "tracing-subscriber", + "url", "urlencoding", "uuid", ] diff --git a/server/Cargo.toml b/server/Cargo.toml index 6d85b8cce5701482367d4be43287394e26f5896f..0d9cdcd21d94751babdf769e118d2c89d6a009d8 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -28,6 +28,7 @@ slug-types = { path = "../types" } async-trait = "0.1" base64 = "0.22" postcard = { version = "1", features = ["use-std"] } +url = "2" urlencoding = "2" [dev-dependencies] diff --git a/server/src/html/editor.rs b/server/src/html/editor.rs index a24e638802c8bb38699b39fdb0c53c22183a4a49..8ff58326eb75806eeec3937f088fa977c7f3886e 100644 --- a/server/src/html/editor.rs +++ b/server/src/html/editor.rs @@ -9,8 +9,9 @@ use maud::{html, Markup}; use serde::Deserialize; use crate::{ - api::{validate_ingest_document, resolve_item}, + api::{resolve_item, validate_ingest_document}, html::JsBuilder, + middleware::canonical_view_url, reducer::ScopeId, state::AppState, }; @@ -25,7 +26,10 @@ fn bc_try() -> Markup { } /// The interactive editor page — `/try`. -pub async fn editor_page(jar: CookieJar, uri: Uri) -> impl IntoResponse { +pub async fn editor_page(State(state): State, jar: CookieJar, uri: Uri) -> impl IntoResponse { + let url_key = canonical_view_url(&uri); + let view_count = state.views.get_views(&url_key); + let page = layout( "try — slug.social", "view-thread", @@ -41,7 +45,7 @@ pub async fn editor_page(jar: CookieJar, uri: Uri) -> impl IntoResponse { div id="editor-results" {} } }, - None, + Some(view_count), theme_from_jar(&jar), &theme_next_from_uri(&uri), None, diff --git a/server/src/html/forum/feed.rs b/server/src/html/forum/feed.rs index 63f196e0df251678bd649da97107e35a741f97fa..70321dd2a75e563282f7612f2b2fa6d57b9175d9 100644 --- a/server/src/html/forum/feed.rs +++ b/server/src/html/forum/feed.rs @@ -8,6 +8,7 @@ use maud::{html, Markup}; use crate::api::optional_principal; use crate::canonical_path::canonicalize_tag; +use crate::middleware::canonical_view_url; use crate::reducer::{ReducerState, ScopeId}; use crate::state::AppState; use crate::timeago; @@ -218,6 +219,9 @@ pub async fn home( let strip = auth_strip(&headers, &jar, &reduced_read); drop(reduced_read); + let url_key = canonical_view_url(&uri); + let view_count = state.views.get_views(&url_key); + let page = layout( "slug.social", "view-thread", @@ -251,7 +255,7 @@ pub async fn home( (render_thread_feed(Some(&nav), "thread-feed", &public_rows, now)) (cli_panel(&["npx slugsocial public forum list"])) }, - None, + Some(view_count), theme_from_jar(&jar), &theme_next_from_uri(&uri), None, diff --git a/server/src/html/forum/mod.rs b/server/src/html/forum/mod.rs index 53809e94e3b107a1ddb937c2e45325fc6b8706c6..bd60703924eee5b0e5a7f69c0a2dc526442512a2 100644 --- a/server/src/html/forum/mod.rs +++ b/server/src/html/forum/mod.rs @@ -20,7 +20,6 @@ pub use profile::user_profile_page; pub use views::{room_page, room_thread_view, thread_view}; pub(crate) use access::{user_can_post_room, user_can_view_room}; -pub(crate) use ingest::ingest_entry_markup; pub(crate) use new_thread::{fragment_new_thread_slot, login_to_post_hint_markup}; pub(crate) use room_members::room_members_section_markup; pub(crate) use thread_morph::{ diff --git a/server/src/html/forum/post_single.rs b/server/src/html/forum/post_single.rs index 6e36e80d076ddd8ca521c1450cbf8cb7ba6a28fe..91866b2cc3dea98d0c3847f54379245eca67894c 100644 --- a/server/src/html/forum/post_single.rs +++ b/server/src/html/forum/post_single.rs @@ -8,6 +8,7 @@ use maud::html; use crate::api::optional_principal; use crate::canonical_path::canonicalize_tag; +use crate::middleware::canonical_view_url; use crate::reducer::ScopeId; use crate::state::AppState; @@ -70,6 +71,9 @@ async fn thread_post_view_inner( } }; + let url_key = canonical_view_url(&uri); + let view_count = state.views.get_views(&url_key); + let page = layout( &format!("#{tag} / post #{index}"), "view-thread", @@ -87,7 +91,7 @@ async fn thread_post_view_inner( p class="muted" { "post not found" } } }, - None, + Some(view_count), theme_from_jar(&jar), &theme_next_from_uri(&uri), None, diff --git a/server/src/html/forum/profile.rs b/server/src/html/forum/profile.rs index ebdc41fdd44e97a10a49ecd1ac5150ae390ec319..94701f8e9e293b64b889291c2145f97c1078c1c4 100644 --- a/server/src/html/forum/profile.rs +++ b/server/src/html/forum/profile.rs @@ -9,6 +9,7 @@ use maud::html; use crate::api::optional_principal; use crate::canonical_path::canonicalize_tag; use crate::identity::parse_username; +use crate::middleware::canonical_view_url; use crate::state::AppState; use super::ingest::{thread_nav_for_ingest, thread_post_index_in_scope}; @@ -74,6 +75,9 @@ pub async fn user_profile_page( }; let now = now_ms(); + let url_key = canonical_view_url(&uri); + let view_count = state.views.get_views(&url_key); + let page = layout( &format!("@{canon}"), "view-thread", @@ -108,7 +112,7 @@ pub async fn user_profile_page( } (cli_panel(&[format!("npx slugsocial public forum list")])) }, - None, + Some(view_count), theme_from_jar(&jar), &theme_next_from_uri(&uri), None, diff --git a/server/src/html/forum/views.rs b/server/src/html/forum/views.rs index 34c47a2a6838a0ec99415e326cbd78f2cc988bd7..b363f0d7c8af3d941e4f402c1f1b87100bb0d394 100644 --- a/server/src/html/forum/views.rs +++ b/server/src/html/forum/views.rs @@ -11,6 +11,7 @@ use serde_json::json; use crate::api::optional_principal; use crate::canonical_path::canonicalize_tag; use crate::form_template::template_json_compact; +use crate::middleware::canonical_view_url; use crate::reducer::ScopeId; use crate::state::AppState; @@ -141,6 +142,9 @@ async fn thread_view_inner( ScopeId::Room(r) => format!("npx slugsocial private {r} forum show {tag}"), }; + let url_key = canonical_view_url(&uri); + let view_count = state.views.get_views(&url_key); + let body = html! { (strip) nav class="breadcrumb" { (bc) } @@ -166,7 +170,7 @@ async fn thread_view_inner( &format!("#{tag}"), "view-thread", body, - None, + Some(view_count), theme_from_jar(&jar), &theme_next_from_uri(&uri), None, @@ -276,6 +280,9 @@ pub async fn room_page( let audit_cli = format!("npx slugsocial private {room_id} audit"); drop(reduced); + let url_key = canonical_view_url(&uri); + let view_count = state.views.get_views(&url_key); + let slug_display = room_id .split_once('/') .map(|(_, slug)| slug) @@ -296,7 +303,7 @@ pub async fn room_page( } (cli_panel(&[forum_cli, garden_cli, audit_cli])) }, - None, + Some(view_count), theme_from_jar(&jar), &theme_next_from_uri(&uri), None, diff --git a/server/src/html/garden.rs b/server/src/html/garden.rs index 2d5fcd903fe8f56800ad2c5564b704bd25f9b18d..9e08307e636ce7984766168db1187f84b8635201 100644 --- a/server/src/html/garden.rs +++ b/server/src/html/garden.rs @@ -14,11 +14,12 @@ use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD as B64_ENGINE use crate::{ api::optional_principal, canonical_path::{canonicalize_item, canonicalize_tag}, + middleware::canonical_view_url, events::ThreadCapability, form_template::template_json_compact, html::{JsBuilder, ui_action::UI_RPC_FIELD, user_can_post_room}, path_types::ItemId, - reducer::{ContentState, ReducerState, ScopeId, scope_from_room_wire}, + reducer::{ContentState, ReducerState, ScopeId}, scope_rank::{ChildrenRankings, build_children_rankings}, state::AppState, timeago, @@ -28,7 +29,7 @@ use super::{ bc_path, bc_path_external, bc_segment, breadcrumb_path::{ExternalOntologyPath, OntologyPath}, cli_panel, - forum::{ThreadNav, ingest_entry_markup}, + forum::ThreadNav, layout, layout_full_bleed_chromeless, now_ms, ratio_pct, render_linkified_with_embeds_in_scope, theme_from_jar, theme_next_from_uri, }; @@ -224,39 +225,24 @@ fn vote_edge_history_markup(content: &ContentState, left: &ItemId, right: &ItemI } } -/// After a successful vote post: morph the new card into `#vote-compare-preview` and refresh edge history. +/// After a successful vote post: refresh edge history (no in-page preview card). pub(crate) async fn vote_compare_post_success_js( state: &AppState, nav: &ThreadNav, - room_wire: &str, - thread_tag: &str, + _room_wire: &str, + _thread_tag: &str, left: &ItemId, right: &ItemId, - post_id: &str, - post_idx: Option, + _post_id: &str, + _post_idx: Option, ) -> String { let reduced = state.reduced.read().await; - let scope = scope_from_room_wire(room_wire); - let Some(ing) = reduced.ingests_by_id.get(post_id).cloned() else { - drop(reduced); - return "console.warn('vote compare: new post not found');".to_string(); - }; - let idx = match post_idx { - Some(i) => i, - None => reduced - .try_thread_post_index_chronological(&scope, thread_tag, post_id) - .unwrap_or(0), - }; - let viewer = None::<&str>; - let now = now_ms(); let content = content_for_garden_view(&reduced, &nav.scope()); let edge_history = vote_edge_history_markup(content, left, right); - let card = ingest_entry_markup(nav, thread_tag, idx, &ing, viewer, now, &reduced); drop(reduced); - let mut b = JsBuilder::new(); - b = b.morph_inner_selector("#vote-compare-preview", card); - b = b.morph_inner_selector("#vote-edge-history-region", edge_history); - b.build() + JsBuilder::new() + .morph_inner_selector("#vote-edge-history-region", edge_history) + .build() } fn item_display_path(item: &str) -> String { @@ -515,6 +501,9 @@ pub async fn garden_index( build_children_rankings(reduced.public(), &ItemId::ontology_root()) }; + let url_key = canonical_view_url(&uri); + let view_count = state.views.get_views(&url_key); + let page = layout( "~/", "view-ontology view-ontology-light", @@ -556,7 +545,7 @@ pub async fn garden_index( } (cli_panel(&["npx slugsocial garden tree"])) }, - None, + Some(view_count), theme_from_jar(&jar), &theme_next_from_uri(&uri), Some("public"), @@ -597,6 +586,9 @@ pub async fn external_garden_index( build_children_rankings(reduced.public(), &parent) }; + let url_key = canonical_view_url(&uri); + let view_count = state.views.get_views(&url_key); + let page = layout( "-/", "view-ontology view-ontology-light", @@ -637,7 +629,7 @@ pub async fn external_garden_index( } } }, - None, + Some(view_count), theme_from_jar(&jar), &theme_next_from_uri(&uri), Some("public"), @@ -727,6 +719,9 @@ pub async fn room_external_garden_index( build_children_rankings(content_for_garden_view(&reduced, &nav.scope()), &parent); drop(reduced); + let url_key = canonical_view_url(&uri); + let view_count = state.views.get_views(&url_key); + let page = layout( "-/", "view-ontology view-ontology-light", @@ -766,7 +761,7 @@ pub async fn room_external_garden_index( } } }, - None, + Some(view_count), theme_from_jar(&jar), &theme_next_from_uri(&uri), Some(nav.room_wire.as_str()), @@ -1080,6 +1075,9 @@ async fn render_scope_view( .filter(|s| !s.is_empty()) .unwrap_or_else(|| "/".to_string()); + let url_key = canonical_view_url(&uri); + let view_count = state.views.get_views(&url_key); + let page = layout( &item_display_path(&model.item), "view-ontology view-ontology-light", @@ -1227,7 +1225,7 @@ async fn render_scope_view( }; (cli_panel(std::slice::from_ref(&cli))) }, - None, + Some(view_count), theme_from_jar(&jar), &theme_next_from_uri(&uri), Some(&garden_room), diff --git a/server/src/html/search.rs b/server/src/html/search.rs index 04fd6f27854138f668689df3b250d7240645d1ac..35d3604ab8ee50327af5b0acf1a86e646633cf1f 100644 --- a/server/src/html/search.rs +++ b/server/src/html/search.rs @@ -10,6 +10,7 @@ use serde::Deserialize; use crate::{ api::optional_principal, events::ThreadCapability, + middleware::canonical_view_url, reducer::{ReducerState, ScopeId}, state::AppState, timeago, @@ -407,6 +408,9 @@ pub async fn search_page( SearchResults { items: vec![], threads: vec![], posts: vec![] } }; + let url_key = canonical_view_url(&uri); + let view_count = state.views.get_views(&url_key); + let page = layout( "search \u{2014} slug.social", "view-thread", @@ -420,7 +424,7 @@ pub async fn search_page( (render_search_results(&results, &query)) (cli_panel(&["npx slugsocial search "])) }, - None, + Some(view_count), theme_from_jar(&jar), &theme_next_from_uri(&uri), None, diff --git a/server/src/lib.rs b/server/src/lib.rs index fc95fc5a845fde9718195e275f2b228f68d913a6..b902094653cf9319efd14e67b91436e269362441 100644 --- a/server/src/lib.rs +++ b/server/src/lib.rs @@ -16,6 +16,7 @@ pub mod reducer; pub mod scope_rank; pub mod state; pub mod timeago; +pub mod views; pub mod write_cmd; use std::collections::HashMap; @@ -37,6 +38,8 @@ pub fn create_app_state(cfg: AppConfig) -> AppState { let (stream_tx, _) = broadcast::channel(64); let (js_tx, _) = broadcast::channel(64); let (write_tx, write_rx) = mpsc::channel::(256); + let views_path = format!("{}/views.json", cfg.data_dir); + let views = crate::views::ViewStore::new(&views_path); let state = AppState { cfg: Arc::new(cfg), event_log: Arc::new(event_log), @@ -46,6 +49,7 @@ pub fn create_app_state(cfg: AppConfig) -> AppState { stream_tx, js_tx, write_tx, + views, }; tokio::spawn(crate::api::write_actor::writer_actor(write_rx, state.clone())); state @@ -118,6 +122,10 @@ pub fn create_app(state: AppState) -> Router { .route("/api/v0/pending-session/:id", get(api::get_pending_session)) .route("/api/v0/whoami", get(api::get_whoami)) .route("/api/v0/rpc", post(api::handle_rpc_batch)) + .layer(axum::middleware::from_fn_with_state( + state.clone(), + crate::middleware::view_count_middleware, + )) .with_state(state) .layer(TraceLayer::new_for_http()) } diff --git a/server/src/middleware.rs b/server/src/middleware.rs index 47751d0da2cf44df44b3e7c94e2a1063b29bd6d6..458bd65054bb1baa70691b06ec44ae71b72b47c5 100644 --- a/server/src/middleware.rs +++ b/server/src/middleware.rs @@ -1,12 +1,47 @@ use axum::{ - extract::Request, + extract::{Request, State}, middleware::Next, response::Response, }; +use crate::state::AppState; + +pub fn canonical_view_url(uri: &axum::http::Uri) -> String { + let path = uri.path(); + if let Some(query) = uri.query() { + let mut pairs: Vec<_> = url::form_urlencoded::parse(query.as_bytes()).into_owned().collect(); + if pairs.is_empty() { + return path.to_string(); + } + pairs.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| a.1.cmp(&b.1))); + + let new_query = url::form_urlencoded::Serializer::new(String::new()) + .extend_pairs(pairs) + .finish(); + format!("{path}?{new_query}") + } else { + path.to_string() + } +} + pub async fn view_count_middleware( + State(state): State, req: Request, next: Next, ) -> Response { + if req.method() == axum::http::Method::GET { + let path = req.uri().path(); + + if !path.starts_with("/static") + && !path.starts_with("/api") + && !path.starts_with("/sse") + && !path.starts_with("/auth") + && path != "/healthz" + && path != "/ui" + { + let url_key = canonical_view_url(req.uri()); + state.views.increment(url_key); + } + } next.run(req).await } diff --git a/server/src/state.rs b/server/src/state.rs index 8fc39914d64ace53a544c5eb85bc61447db60305..13831e868031edc19fda0f347376e0b17e8b99b7 100644 --- a/server/src/state.rs +++ b/server/src/state.rs @@ -67,6 +67,7 @@ pub struct AppState { pub js_tx: broadcast::Sender, /// All durable writes and reducer mutations are serialized through this channel. pub write_tx: mpsc::Sender, + pub views: crate::views::ViewStore, } impl AppState { @@ -81,6 +82,8 @@ impl AppState { let event_log = EventLog::new(cfg.event_log_path.clone()); let (stream_tx, _) = broadcast::channel(64); let (js_tx, _) = broadcast::channel(64); + let views_path = format!("{}/views.json", cfg.data_dir); + let views = crate::views::ViewStore::new(&views_path); Self { cfg: Arc::new(cfg), event_log: Arc::new(event_log), @@ -90,6 +93,7 @@ impl AppState { stream_tx, js_tx, write_tx, + views, } } } diff --git a/server/tests/integration.rs b/server/tests/integration.rs index b34ba69a2115cad9875afce17743ad27f3838959..bd10fd2a6ef06ac1a23de790a54ad5aa0065a472 100644 --- a/server/tests/integration.rs +++ b/server/tests/integration.rs @@ -1,8 +1,10 @@ +use axum::http::Uri; use sha2::{Digest, Sha256}; -use slug_types::room_route_segment; +use slug_types::{room_route_segment, ItemId}; use slugsocial_server::{ event_log::EventLog, events::{Event, TokenIssued, UserRegistered}, + middleware::canonical_view_url, spawn_writer_actor_for_test, state::{AppConfig, AppState}, }; @@ -1368,7 +1370,69 @@ async fn test_search_page_and_results() { #[tokio::test] async fn test_view_counts_increment_and_display() { - // HTML view counters are offline during the auth-v3 refactor. + let (addr, _tmp, _log, state, _handle) = create_test_server_with_state().await; + let client = reqwest::Client::new(); + let url = format!("http://{addr}/~"); + + let r1 = client.get(&url).send().await.unwrap(); + assert!(r1.status().is_success()); + let body1 = r1.text().await.unwrap(); + assert!( + body1.contains("1 views"), + "expected first GET to show 1 views, body snippet: {}", + &body1.chars().take(500).collect::() + ); + + let r2 = client.get(&url).send().await.unwrap(); + assert!(r2.status().is_success()); + let body2 = r2.text().await.unwrap(); + assert!( + body2.contains("2 views"), + "expected second GET to show 2 views" + ); + + // Vote compare uses permuted `left` / `right`; middleware canonicalizes query order. + let left = ItemId::parse("~/vc-l") + .unwrap() + .normalized_storage() + .to_storage_string(); + let right = ItemId::parse("~/vc-r") + .unwrap() + .normalized_storage() + .to_storage_string(); + let vote_q_right_first = format!( + "/vote/compare?right={}&left={}", + urlencoding::encode(&right), + urlencoding::encode(&left) + ); + let vote_q_left_first = format!( + "/vote/compare?left={}&right={}", + urlencoding::encode(&left), + urlencoding::encode(&right) + ); + let vote_key_uri: Uri = format!("http://127.0.0.1{vote_q_left_first}") + .parse() + .unwrap(); + let vote_key = canonical_view_url(&vote_key_uri); + + let v1 = client + .get(format!("http://{addr}{vote_q_right_first}")) + .send() + .await + .unwrap(); + assert!(v1.status().is_success(), "vote compare GET 1: {}", v1.status()); + let v2 = client + .get(format!("http://{addr}{vote_q_left_first}")) + .send() + .await + .unwrap(); + assert!(v2.status().is_success(), "vote compare GET 2: {}", v2.status()); + + assert_eq!( + state.views.get_views(&vote_key), + 2, + "permuted vote/compare URLs should share one ViewStore key ({vote_key:?})" + ); } #[tokio::test] diff --git a/test/browser_vote_compare.clj b/test/browser_vote_compare.clj index 78fc40c160c84478ab8ee0d953b9ed878b4c712f..839482d12963f130a2063f2212a6b922b2d229e1 100644 --- a/test/browser_vote_compare.clj +++ b/test/browser_vote_compare.clj @@ -71,13 +71,12 @@ (page/navigate pg (str base-url "/login")) (is (wait-for-text pg "body" "@alice" 15000) "alice session after login") (page/navigate pg cmp-url) - (is (wait-for-text pg ".vote-compare-shell" "compare" 15000) "vote compare shell") + ;; No .vote-compare-shell wrapper — wait on stable vote-compare UI instead. + (is (wait-for-text pg "body.view-vote-compare" "compare" 15000) "vote compare page") (is (wait-for-text pg "ul.vote-edge-history" "seed edge vote" 15000) "edge history lists canonical-order vote") (locator/fill (page/locator pg "#vote-explain") "because playwright says so") (locator/click (page/locator pg "#vote-compare-form button[type=submit]")) - (is (wait-for-text pg "#vote-compare-preview" "because playwright" 20000) - "preview region morphs new post card") (is (wait-for-text pg "ul.vote-edge-history" "because playwright" 20000) "new vote appears in edge history after morph")))))) Side B — contributor: tommy-mor Side B — commit message: [d88719a1] refactor(html): unify public and room new-thread forms - Single DOM contract: #new-thread-ui-slot, #new-thread-compose, #new-thread-form, #new-thread-tag; shared check_ingest + post_ingest error wiring for all scopes. - Replace ExpandPublic/ExpandRoom/SetRoomNewThreadComposeExpanded with ExpandNewThreadForm { room_wire } and SetNewThreadComposeExpanded { room_wire, expanded }; room_wire "public" covers the home toolbar flow. - SSE refresh resets #new-thread-compose form for any room_key. - Browser test selectors updated for the unified ids. Made-with: Cursor Side B — unified diff (full patch): diff --git a/server/src/api/rpc.rs b/server/src/api/rpc.rs index 03f4f7efe08df3ec160e50d08652bde7d6a9c4d2..422b520f527f64571f86f0ee3cb57edcbc21c0c3 100644 --- a/server/src/api/rpc.rs +++ b/server/src/api/rpc.rs @@ -63,11 +63,7 @@ async fn broadcast_web_refresh(state: &AppState, room_key: &str, thread_id: &str crate::html::thread_feed_region_markup(state, Some(room_key), thread_id, None).await; let builder = JsBuilder::new().morph_selector(&format!("#{feed_id}"), feed_markup); - let builder = if room_key == "public" { - builder.qs("#public-new-thread-compose form").reset() - } else { - builder.qs("#room-new-thread-compose form").reset() - }; + let builder = builder.qs("#new-thread-compose form").reset(); let builder = builder.if_current_path_matches(&thread_url, |builder| { builder.morph_selector("#thread-feed-region", thread_feed_markup) }); diff --git a/server/src/api/ui_html.rs b/server/src/api/ui_html.rs index 6ff9f1319e91f61bf8546eb65637c20f3a58483c..e979053ff1ba8c0e2bf55add0a32b7de11cf1e56 100644 --- a/server/src/api/ui_html.rs +++ b/server/src/api/ui_html.rs @@ -19,7 +19,7 @@ use crate::{ }, canonical_path::canonicalize_tag, html::{ - fragment_public_new_thread_form, fragment_room_new_thread_form, login_to_post_hint_markup, + fragment_new_thread_slot, login_to_post_hint_markup, parse_html_ui_from_form, room_members_section_markup, thread_feed_html, thread_feed_html_for_room, thread_feed_region_markup, thread_ui_collapse_redacted_post, thread_ui_expand_post_full, thread_ui_expand_redacted_post, ui_js_warn, user_can_post_room, @@ -139,24 +139,24 @@ async fn dispatch_ui_action( } } } - HtmlUiAction::ExpandPublicNewThreadForm => { - let reduced = state.reduced.read().await; - let user = session.map(|s| s.username.as_str()); - drop(reduced); - let markup = if user.is_some() { - fragment_public_new_thread_form(true) - } else { - login_to_post_hint_markup() - }; - JsBuilder::new() - .morph_inner_selector("#public-new-thread-ui-slot", markup) - .into_response() - } - HtmlUiAction::ExpandRoomNewThreadForm { room_wire } => { + HtmlUiAction::ExpandNewThreadForm { room_wire } => { let room_wire = room_wire.trim().to_string(); if room_wire.is_empty() { return ui_js_warn("missing room").into_response(); } + if room_wire == "public" { + let reduced = state.reduced.read().await; + let user = session.map(|s| s.username.as_str()); + drop(reduced); + let markup = if user.is_some() { + fragment_new_thread_slot(&ThreadNav::public(), true, false) + } else { + login_to_post_hint_markup() + }; + return JsBuilder::new() + .morph_inner_selector("#new-thread-ui-slot", markup) + .into_response(); + } let reduced = state.reduced.read().await; let user = session.map(|s| s.username.as_str()); if !reduced.rooms.contains(&room_wire) { @@ -176,12 +176,12 @@ async fn dispatch_ui_action( return ui_js_warn("bad room").into_response(); }; let markup = if can_post { - fragment_room_new_thread_form(&nav, true, false) + fragment_new_thread_slot(&nav, true, false) } else { login_to_post_hint_markup() }; JsBuilder::new() - .morph_inner_selector("#room-new-thread-ui-slot", markup) + .morph_inner_selector("#new-thread-ui-slot", markup) .into_response() } HtmlUiAction::SetRoomMembersExpanded { room_wire, expanded } => { @@ -205,11 +205,28 @@ async fn dispatch_ui_action( .morph_selector("#room-members-section", markup) .into_response() } - HtmlUiAction::SetRoomNewThreadComposeExpanded { room_wire, expanded } => { + HtmlUiAction::SetNewThreadComposeExpanded { room_wire, expanded } => { let room_wire = room_wire.trim().to_string(); if room_wire.is_empty() { return ui_js_warn("missing room").into_response(); } + if room_wire == "public" { + let reduced = state.reduced.read().await; + let _user = session.map(|s| s.username.as_str()); + drop(reduced); + let can_post = session.is_some(); + let nav = ThreadNav::public(); + let markup = if can_post { + fragment_new_thread_slot(&nav, true, expanded) + } else { + login_to_post_hint_markup() + }; + let mut b = JsBuilder::new().morph_inner_selector("#new-thread-ui-slot", markup); + if expanded && can_post { + b = b.focus_selector("#new-thread-tag"); + } + return b.into_response(); + } let reduced = state.reduced.read().await; let user = session.map(|s| s.username.as_str()); if !reduced.rooms.contains(&room_wire) { @@ -229,13 +246,13 @@ async fn dispatch_ui_action( return ui_js_warn("bad room").into_response(); }; let markup = if can_post { - fragment_room_new_thread_form(&nav, true, expanded) + fragment_new_thread_slot(&nav, true, expanded) } else { login_to_post_hint_markup() }; - let mut b = JsBuilder::new().morph_inner_selector("#room-new-thread-ui-slot", markup); + let mut b = JsBuilder::new().morph_inner_selector("#new-thread-ui-slot", markup); if expanded && can_post { - b = b.focus_selector("#room-new-tag"); + b = b.focus_selector("#new-thread-tag"); } b.into_response() } diff --git a/server/src/html/forum/feed.rs b/server/src/html/forum/feed.rs index 30b95ff6e85245a82199c96e41484d03c24989dd..1b4ae7baa3ad4757b74172d7b67f3f2b33d1075d 100644 --- a/server/src/html/forum/feed.rs +++ b/server/src/html/forum/feed.rs @@ -245,11 +245,13 @@ pub async fn home( p class="muted" { "dark = time-ordered · light = vote-ranked" } div class="thread-feed-toolbar" { form method="POST" action="/ui" { - input type="hidden" name=(UI_RPC_FIELD) value=(template_json_compact(&HtmlUiAction::ExpandPublicNewThreadForm).expect("static json")); + input type="hidden" name=(UI_RPC_FIELD) value=(template_json_compact(&HtmlUiAction::ExpandNewThreadForm { + room_wire: "public".into(), + }).expect("static json")); button type="submit" class="section-add-btn" { "+" } } } - div id="public-new-thread-ui-slot" {} + div id="new-thread-ui-slot" {} (render_thread_feed(Some(&nav), "thread-feed", &public_rows, now)) (cli_panel(&["npx slugsocial public forum list"])) }, diff --git a/server/src/html/forum/mod.rs b/server/src/html/forum/mod.rs index 8fbf3b71f41fafb474999ebb3d622e6bd5b5f313..bd60703924eee5b0e5a7f69c0a2dc526442512a2 100644 --- a/server/src/html/forum/mod.rs +++ b/server/src/html/forum/mod.rs @@ -20,9 +20,7 @@ pub use profile::user_profile_page; pub use views::{room_page, room_thread_view, thread_view}; pub(crate) use access::{user_can_post_room, user_can_view_room}; -pub(crate) use new_thread::{ - fragment_public_new_thread_form, fragment_room_new_thread_form, login_to_post_hint_markup, -}; +pub(crate) use new_thread::{fragment_new_thread_slot, login_to_post_hint_markup}; pub(crate) use room_members::room_members_section_markup; pub(crate) use thread_morph::{ thread_ui_collapse_redacted_post, thread_ui_expand_post_full, thread_ui_expand_redacted_post, diff --git a/server/src/html/forum/new_thread.rs b/server/src/html/forum/new_thread.rs index b2d24b8ab3ec7eee0edd16bed8ee6605e49bf408..4dd2654f953a7db6ee9b25c348f99906da93a94a 100644 --- a/server/src/html/forum/new_thread.rs +++ b/server/src/html/forum/new_thread.rs @@ -5,120 +5,48 @@ use serde_json::json; use super::nav::ThreadNav; -struct NewThreadIds { - compose_section_id: &'static str, - errors_id: &'static str, - form_id: &'static str, - tag_input_id: &'static str, - text_input_id: Option<&'static str>, -} - -const PUBLIC_IDS: NewThreadIds = NewThreadIds { - compose_section_id: "public-new-thread-compose", - errors_id: "public-new-thread-errors", - form_id: "public-new-thread-form", - tag_input_id: "new-thread-tag", - text_input_id: Some("new-thread-text"), -}; - -const ROOM_IDS: NewThreadIds = NewThreadIds { - compose_section_id: "room-new-thread-compose", - errors_id: "room-new-thread-errors", - form_id: "room-new-thread-form", - tag_input_id: "room-new-tag", - text_input_id: None, -}; - -#[derive(Clone, Copy)] -enum NewThreadComposeKind { - /// Home page: no client-side check RPC; post template omits `error_target` / `form_id`. - Public, - /// Room page: `check_ingest` + error targets on post (matches thread compose). - Room, -} - -/// Shared `
` for creating a thread + first post. -fn new_thread_compose_section(room_wire: &str, ids: &NewThreadIds, kind: NewThreadComposeKind) -> Markup { - let client_check = matches!(kind, NewThreadComposeKind::Room); - let (tag_placeholder, text_placeholder, submit_label) = match kind { - NewThreadComposeKind::Public => ( - "thread-title-slug-here", - "Hello threadgoers!! Behold my new thread!", - "create thread / make first post", - ), - NewThreadComposeKind::Room => ( - "thread-topic-slug-here", - "First post body…", - "post", - ), - }; +/// Stable ids shared by public home and private room “new thread” UI (`#new-thread-ui-slot`). +const COMPOSE_SECTION_ID: &str = "new-thread-compose"; +const ERRORS_ID: &str = "new-thread-errors"; +const FORM_ID: &str = "new-thread-form"; +const TAG_INPUT_ID: &str = "new-thread-tag"; +/// Shared `
`: check + post with `error_target` / `form_id` (same as thread reply compose). +fn new_thread_compose_section(room_wire: &str) -> Markup { html! { - section class="compose" id=(ids.compose_section_id) { - div id=(ids.errors_id) {} - @if client_check { - form id=(ids.form_id) method="POST" action="/ui" data-check-action="/ui" data-check-rpc=(template_json_compact(&json!({ - "action": "check_ingest", + 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, + "thread_tag": {"$form": "thread_tag"}, + "text": {"$form": "text"}, + "error_target": ERRORS_ID, + "form_id": FORM_ID, + })).unwrap()) { + input type="hidden" name=(UI_RPC_FIELD) value=(template_json_compact(&json!({ + "action": "post_ingest", "room": room_wire, "thread_tag": {"$form": "thread_tag"}, "text": {"$form": "text"}, - "error_target": ids.errors_id, - "form_id": ids.form_id, - })).unwrap()) { - input type="hidden" name=(UI_RPC_FIELD) value=(template_json_compact(&json!({ - "action": "post_ingest", - "room": room_wire, - "thread_tag": {"$form": "thread_tag"}, - "text": {"$form": "text"}, - "error_target": ids.errors_id, - "form_id": ids.form_id, - })).unwrap()); - input type="text" id=(ids.tag_input_id) name="thread_tag" pattern="[a-z0-9_\\-]{1,64}" required placeholder=(tag_placeholder); - @if let Some(tid) = ids.text_input_id { - textarea id=(tid) name="text" rows="4" placeholder=(text_placeholder) required {} - } @else { - textarea name="text" rows="4" placeholder=(text_placeholder) required {} - } - p { button type="submit" { (submit_label) } } - } - } @else { - form id=(ids.form_id) method="POST" action="/ui" { - input type="hidden" name=(UI_RPC_FIELD) value=(template_json_compact(&json!({ - "action": "post_ingest", - "room": room_wire, - "thread_tag": {"$form": "thread_tag"}, - "text": {"$form": "text"}, - })).unwrap()); - input type="text" id=(ids.tag_input_id) name="thread_tag" pattern="[a-z0-9_\\-]{1,64}" placeholder=(tag_placeholder); - @if let Some(tid) = ids.text_input_id { - textarea id=(tid) name="text" rows="4" placeholder=(text_placeholder) {} - } @else { - textarea name="text" rows="4" placeholder=(text_placeholder) {} - } - p { button type="submit" { (submit_label) } } - } + "error_target": ERRORS_ID, + "form_id": FORM_ID, + })).unwrap()); + input type="text" id=(TAG_INPUT_ID) name="thread_tag" pattern="[a-z0-9_\\-]{1,64}" required placeholder="thread-topic-slug-here"; + textarea name="text" rows="4" placeholder="First post body…" required {} + p { button type="submit" { "create thread / post" } } } } } } -fn new_thread_form_public(show: bool) -> Markup { - if !show { - return html! {}; - } - new_thread_compose_section("public", &PUBLIC_IDS, NewThreadComposeKind::Public) -} - -fn new_thread_form_for_room(nav: &ThreadNav, show: bool, compose_expanded: bool) -> Markup { - if !show { - return html! {}; - } - // Single root for Idiomorph when morphing `#room-new-thread-ui-slot` (expanded has form + section). +/// Inner fragment morphed into `#new-thread-ui-slot` (Idiomorph replaces children; outer `id` stays). +fn new_thread_slot_inner(nav: &ThreadNav, compose_expanded: bool) -> Markup { html! { - div class="room-new-thread-slot-inner" { + div class="new-thread-slot-inner" { @if compose_expanded { form method="POST" action="/ui" { - input type="hidden" name=(UI_RPC_FIELD) value=(template_json_compact(&HtmlUiAction::SetRoomNewThreadComposeExpanded { + input type="hidden" name=(UI_RPC_FIELD) value=(template_json_compact(&HtmlUiAction::SetNewThreadComposeExpanded { room_wire: nav.room_wire.clone(), expanded: false, }).expect("static json")); @@ -126,10 +54,10 @@ fn new_thread_form_for_room(nav: &ThreadNav, show: bool, compose_expanded: bool) "-" } } - (new_thread_compose_section(&nav.room_wire, &ROOM_IDS, NewThreadComposeKind::Room)) + (new_thread_compose_section(&nav.room_wire)) } @else { form method="POST" action="/ui" { - input type="hidden" name=(UI_RPC_FIELD) value=(template_json_compact(&HtmlUiAction::SetRoomNewThreadComposeExpanded { + input type="hidden" name=(UI_RPC_FIELD) value=(template_json_compact(&HtmlUiAction::SetNewThreadComposeExpanded { room_wire: nav.room_wire.clone(), expanded: true, }).expect("static json")); @@ -142,16 +70,19 @@ fn new_thread_form_for_room(nav: &ThreadNav, show: bool, compose_expanded: bool) } } -pub(crate) fn login_to_post_hint_markup() -> Markup { - html! { - p class="muted" { "log in to post" } +pub(crate) fn new_thread_slot_markup(nav: &ThreadNav, show: bool, compose_expanded: bool) -> Markup { + if !show { + return html! {}; } + new_thread_slot_inner(nav, compose_expanded) } -pub(crate) fn fragment_public_new_thread_form(show: bool) -> Markup { - new_thread_form_public(show) +pub(crate) fn fragment_new_thread_slot(nav: &ThreadNav, show: bool, compose_expanded: bool) -> Markup { + new_thread_slot_markup(nav, show, compose_expanded) } -pub(crate) fn fragment_room_new_thread_form(nav: &ThreadNav, show: bool, compose_expanded: bool) -> Markup { - new_thread_form_for_room(nav, show, compose_expanded) +pub(crate) fn login_to_post_hint_markup() -> Markup { + html! { + p class="muted" { "log in to post" } + } } diff --git a/server/src/html/forum/views.rs b/server/src/html/forum/views.rs index be08491b893f5960723e77c7f7fb9d416100f0fb..f9c425634d1dfa2cbbc2e22c3c745717a6486173 100644 --- a/server/src/html/forum/views.rs +++ b/server/src/html/forum/views.rs @@ -19,7 +19,7 @@ use super::access::user_can_view_room; use super::feed::{collect_thread_rows_for_scope, render_thread_feed}; use super::ingest::ingest_entry_markup; use super::nav::ThreadNav; -use super::new_thread::fragment_room_new_thread_form; +use super::new_thread::fragment_new_thread_slot; use super::page::{auth_strip, bc_room}; use super::paginator::{render_thread_paginator, PAGE_SIZE}; use super::room_members::room_members_section_markup; @@ -276,8 +276,8 @@ pub async fn room_page( h3 { "threads" } (render_thread_feed(Some(&nav), "room-thread-feed", &rows, now)) @if show_new { - div id="room-new-thread-ui-slot" { - (fragment_room_new_thread_form(&nav, true, false)) + div id="new-thread-ui-slot" { + (fragment_new_thread_slot(&nav, true, false)) } } (cli_panel(&[forum_cli, garden_cli, audit_cli])) diff --git a/server/src/html/mod.rs b/server/src/html/mod.rs index 6617781a2e8e86c2e2693788ea7cd0eb0e3659a2..e142240c95c8e92c22ab631df98268253cf0cbdf 100644 --- a/server/src/html/mod.rs +++ b/server/src/html/mod.rs @@ -27,9 +27,9 @@ pub use forum::{ }; pub(crate) use forum::{ - fragment_public_new_thread_form, fragment_room_new_thread_form, login_to_post_hint_markup, - room_members_section_markup, thread_ui_collapse_redacted_post, thread_ui_expand_post_full, - thread_ui_expand_redacted_post, user_can_post_room, user_can_view_room, + fragment_new_thread_slot, login_to_post_hint_markup, room_members_section_markup, + thread_ui_collapse_redacted_post, thread_ui_expand_post_full, thread_ui_expand_redacted_post, + user_can_post_room, user_can_view_room, }; pub use garden::{garden_index, ontology_path, room_garden_index, room_ontology_path}; pub use search::{search_page, search_results_fragment}; @@ -183,7 +183,7 @@ impl JsBuilder { ) } - /// Morph **children** of `selector` so the outer element (e.g. `#room-new-thread-ui-slot`) keeps its `id`. + /// Morph **children** of `selector` so the outer element (e.g. `#new-thread-ui-slot`) keeps its `id`. pub(crate) fn morph_inner_selector(self, selector: &str, markup: Markup) -> Self { self.qs(selector).morph_inner(markup) } diff --git a/server/src/html/ui_action.rs b/server/src/html/ui_action.rs index 0c4d33e58ad2bff9661bf70ec994ac30f7ca3efe..5031ebfeb928f28e471f23210b8c644654adb7c7 100644 --- a/server/src/html/ui_action.rs +++ b/server/src/html/ui_action.rs @@ -38,10 +38,9 @@ pub enum HtmlUiAction { RedactPost { post_id: String, }, - /// Morph `#public-new-thread-ui-slot` to the new-thread form (or login hint). - ExpandPublicNewThreadForm, - /// Morph `#room-new-thread-ui-slot` for the given room wire id. - ExpandRoomNewThreadForm { + /// Morph `#new-thread-ui-slot` inner to the collapsed compose toggle (or login hint). + /// Use `room_wire: "public"` for the public forum home; otherwise a private room id (`short/slug`). + ExpandNewThreadForm { room_wire: String, }, /// Morph `#room-members-section` — members list open or collapsed (server-rendered). @@ -50,8 +49,8 @@ pub enum HtmlUiAction { #[serde(default)] expanded: bool, }, - /// Morph `#room-new-thread-ui-slot` — compose body open or collapsed (server-rendered). - SetRoomNewThreadComposeExpanded { + /// Morph `#new-thread-ui-slot` inner — compose open or collapsed (`room_wire: "public"` for home). + SetNewThreadComposeExpanded { room_wire: String, #[serde(default)] expanded: bool, @@ -133,15 +132,23 @@ mod tests { } #[test] - fn expand_public_unit_variant() { - let template = serde_json::json!({ "action": "expand_public_new_thread_form" }); + fn expand_new_thread_form_public() { + let template = serde_json::json!({ + "action": "expand_new_thread_form", + "room_wire": "public", + }); let mut form = HashMap::new(); form.insert( UI_RPC_FIELD.to_string(), serde_json::to_string(&template).unwrap(), ); let a = parse_html_ui_from_form(&form).unwrap(); - assert_eq!(a, HtmlUiAction::ExpandPublicNewThreadForm); + assert_eq!( + a, + HtmlUiAction::ExpandNewThreadForm { + room_wire: "public".into(), + } + ); } #[test] @@ -190,9 +197,9 @@ mod tests { } #[test] - fn set_room_new_thread_compose_expanded_true() { + fn set_new_thread_compose_expanded_true() { let template = serde_json::json!({ - "action": "set_room_new_thread_compose_expanded", + "action": "set_new_thread_compose_expanded", "room_wire": "ab/cd", "expanded": true, }); @@ -204,7 +211,7 @@ mod tests { let a = parse_html_ui_from_form(&form).unwrap(); assert_eq!( a, - HtmlUiAction::SetRoomNewThreadComposeExpanded { + HtmlUiAction::SetNewThreadComposeExpanded { room_wire: "ab/cd".into(), expanded: true, } diff --git a/test/browser_sse.clj b/test/browser_sse.clj index 3ac71a2986cbb71ed2c513f50cd5ef3f2582c945..8bb064dbc2dc0feb6caaa67f520a461645958277 100644 --- a/test/browser_sse.clj +++ b/test/browser_sse.clj @@ -80,19 +80,17 @@ room-url (str base-url "/r/" room-short "/" room-slug) thread-url (str room-url "/t/sse-thread")] ;; Object under test: slug_ui.js intercepts POST /ui, evals JS, morphs - ;; #room-new-thread-ui-slot (expand compose), then post_ingest redirects to thread. + ;; #new-thread-ui-slot (expand compose), then post_ingest redirects to thread. (page/navigate alice-pg room-url) (page/wait-for-load-state alice-pg :load) - (is (wait-for-text alice-pg "#room-new-thread-ui-slot" - "new thread in this room" 30000) + (is (wait-for-text alice-pg "#new-thread-ui-slot" "+" 30000) "collapsed new-thread control in slot (page + slug_ui.js)") - (locator/click (page/locator alice-pg "#room-new-thread-ui-slot button.form-toggle")) - (is (wait-for-text alice-pg "#room-new-thread-ui-slot" - "hide new thread form" 30000) + (locator/click (page/locator alice-pg "#new-thread-ui-slot button.form-toggle")) + (is (wait-for-text alice-pg "#new-thread-compose" "create thread / post" 30000) "compose expanded via POST /ui morph (slug_ui.js)") - (locator/fill (page/locator alice-pg "#room-new-tag") "sse-thread") - (locator/fill (page/locator alice-pg "#room-new-thread-compose textarea") "seed thread") - (locator/click (page/locator alice-pg "#room-new-thread-form button[type='submit']")) + (locator/fill (page/locator alice-pg "#new-thread-tag") "sse-thread") + (locator/fill (page/locator alice-pg "#new-thread-compose textarea") "seed thread") + (locator/click (page/locator alice-pg "#new-thread-form button[type='submit']")) (page/wait-for-url alice-pg thread-url {:timeout 90000.0}) (page/wait-for-load-state alice-pg :load) (is (wait-for-text alice-pg "#thread-feed-region" "seed thread" 45000)