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: [d6b4a41e] Turn /login into an account page with alias switching. Signed-in users can see trust weight, switch aliases, claim new ones, and re-link OAuth from one place. Co-authored-by: Cursor Side A — unified diff (full patch): diff --git a/server/src/auth/mod.rs b/server/src/auth/mod.rs index d4a85ef52c15dc35148e4c743f0d646cbbdb056d..5906f93b13853421e96a3c37bc9d8202a47842bf 100644 --- a/server/src/auth/mod.rs +++ b/server/src/auth/mod.rs @@ -94,63 +94,169 @@ fn alias_list(db: &durable::Db, uuid: &str) -> Vec { .unwrap_or_default() } -fn login_body( - session: Option<&session::SessionActor>, +fn alias_claim_forms(return_to: &str, submit_label: &str) -> Result { + let check_rpc = template_json_compact(&serde_json::json!({ + "action": "check_pseudonym", + "pseudonym": {"$form": "pseudonym"}, + })) + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + let claim_rpc = template_json_compact(&serde_json::json!({ + "action": "claim_pseudonym", + "pseudonym": {"$form": "pseudonym"}, + "return_to": return_to, + })) + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + Ok(html! { + div class="alias-claim" { + form id="alias-check-form" method="POST" action="/ui" { + input type="hidden" name=(UI_RPC_FIELD) value=(check_rpc); + label for="alias-input" { "alias" } + input type="text" id="alias-input" name="pseudonym" autocomplete="off" + data-testid="alias-input" maxlength="64" placeholder="letters, numbers, _ -"; + p id="alias-status" class="muted" data-testid="alias-status" { "type to check availability" } + } + form id="alias-claim-form" method="POST" action="/ui" { + input type="hidden" name=(UI_RPC_FIELD) value=(claim_rpc); + input type="hidden" name="pseudonym" id="alias-claim-field" value=""; + button type="submit" class="btn-primary" data-testid="alias-claim" { (submit_label) } + } + } + }) +} + +fn signed_out_body(providers: &[(&str, String)]) -> Markup { + html! { + main class="panel login-page" { + section class="login-section" { + h1 { "sign in" } + p class="muted" { "link an account to vote under a lasting alias" } + @if providers.is_empty() { + p class="muted" { + "OAuth is not configured. Set GITHUB_CLIENT_ID and GITHUB_CLIENT_SECRET." + } + } @else { + ul class="oauth-provider-list" { + @for (name, href) in providers { + li { + a href=(href) class="btn-primary oauth-provider" + data-testid=(format!("oauth-{}", name.to_lowercase())) { + (format!("Continue with {name}")) + } + } + } + } + } + } + p class="login-back" { a href="/" { "← back" } } + } + } +} + +fn account_body( + actor: &session::SessionActor, aliases: &[String], providers: &[(&str, String)], + claim_forms: Markup, ) -> Markup { + let current = actor.pseudonym.trim(); html! { - main class="panel login-page" { - div class="login-grid" { - section class="login-oauth" { - h1 { "sign in" } - @if providers.is_empty() { - p class="muted" { - "OAuth is not configured. Set GITHUB_CLIENT_ID and GITHUB_CLIENT_SECRET." - } - } @else { - ul class="oauth-provider-list" { - @for (name, href) in providers { - li { - a href=(href) class="button oauth-provider" data-testid=(format!("oauth-{}", name.to_lowercase())) { - (format!("Continue with {name}")) + main class="panel login-page account-page" { + section class="login-section" { + h1 { "account" } + @if current.is_empty() { + p class="muted" { "finish setup by choosing an alias below" } + } @else { + p class="account-current" { + "voting as " + strong data-testid="account-current" { (current) } + } + } + p class="muted small" data-testid="account-weight" { + "trust weight " (format!("{:.1}", actor.trust_weight)) + " · rises when you link more OAuth providers" + } + } + + section class="login-section" { + h2 { "aliases" } + @if aliases.is_empty() { + p class="muted" data-testid="alias-list-empty" { "none yet — claim one below" } + } @else { + ul id="alias-list" class="alias-list" data-testid="alias-list" { + @for alias in aliases { + @let is_current = alias == current; + li class=(if is_current { "alias-item alias-current" } else { "alias-item" }) { + span class="alias-name" { (alias) } + @if is_current { + span class="alias-badge" data-testid="alias-current-badge" { "current" } + } @else { + form class="alias-switch" method="post" action="/auth/switch" + data-navigate="full" { + input type="hidden" name="pseudonym" value=(alias); + button type="submit" class="btn-secondary" + data-testid=(format!("alias-switch-{alias}")) { + "use" + } } } } } } - @if let Some(actor) = session { - p class="muted small" { - "session active · weight " (format!("{:.1}", actor.trust_weight)) - } - form method="post" action="/auth/logout" data-navigate="full" { - button type="submit" { "log out" } - } - } } - section class="login-aliases" { - h2 { "your aliases" } - ul id="alias-list" class="alias-list" { - @if aliases.is_empty() { - li class="muted" data-testid="alias-list-empty" { "none yet" } - } @else { - @for alias in aliases { - li { (alias) } + } + + section class="login-section" { + h2 { "add alias" } + p class="muted small" { "each alias is unique across sorter2" } + (claim_forms) + } + + @if !providers.is_empty() { + section class="login-section" { + h2 { "linked sign-in" } + p class="muted small" { "sign in again with the same provider to return to this account" } + ul class="oauth-provider-list" { + @for (name, href) in providers { + li { + a href=(href) class="btn-secondary oauth-provider" + data-testid=(format!("oauth-relink-{}", name.to_lowercase())) { + (format!("Re-link {name}")) + } } } } } } - p { a href="/" { "← back" } } + + section class="login-section login-actions" { + form method="post" action="/auth/logout" data-navigate="full" { + button type="submit" class="btn-secondary" data-testid="account-logout" { "log out" } + } + } + + p class="login-back" { a href="/" { "← back" } } } } } +fn login_body( + session: Option<&session::SessionActor>, + aliases: &[String], + providers: &[(&str, String)], + claim_forms: Option, +) -> Markup { + match (session, claim_forms) { + (Some(actor), Some(forms)) => account_body(actor, aliases, providers, forms), + _ => signed_out_body(providers), + } +} + pub async fn login_page( State(state): State, jar: CookieJar, Query(query): Query, -) -> Response { +) -> Result { let return_to = return_from_query_or_jar(&jar, query.return_to.as_deref()); let jar = jar.add(session::auth_return_cookie_value(&return_to)); @@ -164,16 +270,26 @@ pub async fn login_page( .unwrap_or_default(); let providers = oauth_providers(&base_url_from_env(state.cfg.port), &return_to); + let claim_forms = if session.is_some() { + Some(alias_claim_forms("/login", "claim alias")?) + } else { + None + }; + let markup = layout( - "login · sorter2", - login_body(session.as_ref(), &aliases, &providers), + if session.is_some() { + "account · sorter2" + } else { + "login · sorter2" + }, + login_body(session.as_ref(), &aliases, &providers, claim_forms), state.views.get_views("/login"), session .as_ref() .filter(|s| !s.pseudonym.trim().is_empty()) .map(|s| s.pseudonym.as_str()), ); - (jar, Html(markup.into_string())).into_response() + Ok((jar, Html(markup.into_string())).into_response()) } pub async fn alias_page( @@ -186,38 +302,17 @@ pub async fn alias_page( let db = state.projection_store.db(); let session = session::load_valid_session(db, &session_id).ok_or(StatusCode::UNAUTHORIZED)?; if session::session_has_pseudonym(&session) { - return Ok(Redirect::to(&return_to).into_response()); + // Already onboarded — manage aliases on the account page. + return Ok(Redirect::to("/login").into_response()); } - let check_rpc = template_json_compact(&serde_json::json!({ - "action": "check_pseudonym", - "pseudonym": {"$form": "pseudonym"}, - })) - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; - let claim_rpc = template_json_compact(&serde_json::json!({ - "action": "claim_pseudonym", - "pseudonym": {"$form": "pseudonym"}, - "return_to": return_to, - })) - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; - + let claim_forms = alias_claim_forms(&return_to, "continue")?; let body = html! { main class="panel alias-page" { h1 { "choose alias" } p class="muted" { "pick a unique display name for your votes" } - form id="alias-check-form" method="POST" action="/ui" { - input type="hidden" name=(UI_RPC_FIELD) value=(check_rpc); - label { "alias" } - input type="text" id="alias-input" name="pseudonym" autocomplete="off" - data-testid="alias-input" maxlength="64"; - p id="alias-status" class="muted" data-testid="alias-status" { "type to check availability" } - } - form id="alias-claim-form" method="POST" action="/ui" { - input type="hidden" name=(UI_RPC_FIELD) value=(claim_rpc); - input type="hidden" name="pseudonym" id="alias-claim-field" value=""; - button type="submit" class="btn-primary" data-testid="alias-claim" { "continue" } - } - p { a href="/login" { "← back to login" } } + (claim_forms) + p class="login-back" { a href="/login" { "← back to login" } } } }; diff --git a/server/src/html/mod.rs b/server/src/html/mod.rs index 3cc3d7bdf55b5cb5d009600f4ade1fcd201a410b..5b9b73c7ad24499737f0576b3603c3ff5355251e 100644 --- a/server/src/html/mod.rs +++ b/server/src/html/mod.rs @@ -149,7 +149,7 @@ pub(crate) fn layout(title: &str, body: Markup, views: u64, nav_user: Option<&st nav class="top-nav" { @if let Some(name) = nav_user { span class="top-nav-user" data-testid="nav-user" { (name) } - a href="/login" { "account" } + a href="/login" data-testid="nav-account" { "account" } form class="top-nav-logout" method="post" action="/auth/logout" data-navigate="full" { button type="submit" data-testid="nav-logout" { "log out" } } diff --git a/server/static/sorter.css b/server/static/sorter.css index 257280b6b490c65222e580a325b77351cac6cc6b..e7aca23b426d504938b90b5a25ad06b07843ba02 100644 --- a/server/static/sorter.css +++ b/server/static/sorter.css @@ -108,6 +108,152 @@ body { cursor: wait; } +.panel { + max-width: 36rem; + margin: 1.5rem auto; + padding: 1.25rem 1.5rem; + background: var(--panel); + border: 1px solid var(--border); +} + +.login-page h1, +.alias-page h1 { + margin: 0 0 0.35rem; + font-size: 1.5rem; +} + +.login-page h2, +.alias-page h2 { + margin: 0 0 0.5rem; + font-size: 1.05rem; + font-weight: 600; +} + +.login-section { + margin-bottom: 1.5rem; +} + +.login-section:last-of-type { + margin-bottom: 0.75rem; +} + +.account-current { + margin: 0.25rem 0 0.5rem; + font-size: 1.1rem; +} + +.oauth-provider-list { + list-style: none; + margin: 1rem 0 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.oauth-provider-list a { + text-align: center; + text-decoration: none; + margin-top: 0; +} + +.alias-list { + list-style: none; + margin: 0.5rem 0 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 0.4rem; +} + +.alias-item { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; + padding: 0.55rem 0.75rem; + border: 1px solid var(--border); + background: var(--bg); +} + +.alias-item.alias-current { + border-color: var(--accent); +} + +.alias-name { + font-weight: 600; +} + +.alias-badge { + font-size: 0.75rem; + color: var(--accent); + text-transform: lowercase; +} + +.alias-switch { + margin: 0; +} + +.alias-switch .btn-secondary { + margin-top: 0; +} + +.alias-claim label { + display: block; + margin-bottom: 0.35rem; + color: var(--muted); + font-size: 0.875rem; +} + +.alias-claim input[type="text"] { + width: 100%; + padding: 0.5rem 0.65rem; + background: var(--bg); + border: 1px solid var(--border); + color: var(--fg); + font: inherit; +} + +.alias-claim input[type="text"]:focus { + outline: 1px solid var(--accent); + border-color: var(--accent); +} + +#alias-status { + margin: 0.4rem 0 0.75rem; + min-height: 1.25em; +} + +#alias-status.alias-ok { + color: #8fd19e; +} + +#alias-status.alias-bad { + color: #e8a0a0; +} + +.login-actions { + padding-top: 0.75rem; + border-top: 1px solid var(--border); +} + +.login-actions .btn-secondary { + margin-top: 0; +} + +.login-back { + margin: 1rem 0 0; +} + +.login-back a { + color: var(--muted); + text-decoration: none; +} + +.login-back a:hover { + color: var(--fg); +} + .fetch-entity-form { margin-top: 0.5rem; } diff --git a/test/auth_login.clj b/test/auth_login.clj index a81e064ebb88fe359c56cf6718880dc1e4c1a7d9..6f2f00d7aa7340e63d1ac465b0a2234cec7a983f 100644 --- a/test/auth_login.clj +++ b/test/auth_login.clj @@ -55,7 +55,6 @@ (move-vote-slider-left pg) (loc/click (page/get-by-test-id pg "vote-post")) (page/wait-for-selector pg "[data-testid=oauth-github]" {:timeout 15000}) - (is (str/includes? (or (element-text pg "alias-list-empty") "") "none yet")) (loc/click (page/get-by-test-id pg "oauth-github")) (page/wait-for-selector pg "[data-testid=alias-input]" {:timeout 15000}) (type-alias! pg "seeder") Side B — contributor: tommy-mor Side B — 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 B — 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"))))))