From 35663a28c1185bc0e72d4fd3371cf84a51376291 Mon Sep 17 00:00:00 2001 From: Bendik Aagaard Lynghaug Date: Wed, 12 Aug 2026 20:48:27 +0200 Subject: [PATCH] Drop question-report entirely, obfuscate the mailto link instead The report button logged to an event stream nothing ever read - no notification, no dashboard, just a durable no-op. A browser back button already covers "this page wasn't helpful" better than a button that silently does nothing visible to anyone but the visitor who clicked it. The mailto address is now assembled from data-user/data-domain on a real mouse event instead of baked into the server-rendered href - keeps a plain mailto: string (what bulk scrapers regex HTML for) out of what a generic bot crawl sees, without hiding the contact option from an actual visitor. Co-Authored-By: Claude Sonnet 5 --- src/app.rs | 101 ++++++++++++++++--------------------------------- src/content.rs | 6 +-- style/main.css | 19 ---------- 3 files changed, 34 insertions(+), 92 deletions(-) diff --git a/src/app.rs b/src/app.rs index f6fb8e0..7c44e77 100644 --- a/src/app.rs +++ b/src/app.rs @@ -163,7 +163,6 @@ fn QuestionView( .into_any(); } - let question_id_for_note = question_id.clone(); view! { } + view! { } })} } .into_any() } -/// "Asked by X — contact them if you get stuck", plus a one-click -/// "report this question" action (see `report_question`) - mainly -/// meant for a question that reads as unhelpfully LLM-generated, so -/// whoever's responsible for it hears about it. +/// "Asked by X — contact them if you get stuck." +/// The mailto address is assembled from `data-user`/`data-domain` on a +/// real mouse event, never baked into the server-rendered `href` - +/// bulk scrapers regex HTML for `mailto:` strings, and this keeps one +/// out of what they see. Not a defense against a targeted scrape, just +/// against the generic bot crawl. #[component] -fn ResponsibleNote(question_id: String, responsible: Responsible) -> impl IntoView { - let report = ServerAction::::new(); - let reported = RwSignal::new(false); - Effect::new(move |_| { - if report.value().get().is_some_and(|r| r.is_ok()) { - reported.set(true); +fn ResponsibleNote(responsible: Responsible) -> impl IntoView { + let (user, domain) = responsible + .contact + .split_once('@') + .map(|(u, d)| (u.to_string(), d.to_string())) + .unwrap_or((responsible.contact.clone(), String::new())); + + // Zero captures (reads data-user/data-domain off the target element + // itself, not from the closure's environment) - Copy, so the same + // value works for both `on:` attributes below without cloning. + let assemble = move |ev: leptos::ev::MouseEvent| { + #[cfg(feature = "hydrate")] + { + let target = event_target::(&ev); + if let (Some(user), Some(domain)) = + (target.get_attribute("data-user"), target.get_attribute("data-domain")) + { + let _ = target.set_attribute("href", &format!("mailto:{user}@{domain}")); + } } - }); + #[cfg(not(feature = "hydrate"))] + let _ = &ev; + }; view! {

"Asked by " {responsible.name.clone()}
"— " - "contact them" + + "contact them" + " if you get stuck." - {move || { - if reported.get() { - view! { " Reported — thanks." } - .into_any() - } else { - let question_id = question_id.clone(); - view! { - - } - .into_any() - } - }}

} } @@ -1411,36 +1404,6 @@ pub async fn submit_answer( Ok(SubmitResult { next, chain_hash }) } -/// Fire-and-forget "this question wasn't helpful" report - a plain log -/// entry in the event store (`aggregate_type: "question_report"`, no -/// state machine, nothing to transition), reviewable via -/// `events::store::load_events(js, "question_report", question_id)` the -/// same way any other aggregate's history is. No auth, same as reading -/// a public question - a report is cheap, low-stakes signal, not -/// something worth gating behind a session. -#[server] -pub async fn report_question( - question_id: String, - reason: Option, -) -> Result<(), ServerFnError> { - use crate::server::AppState; - - let state = expect_context::(); - let occurred_at_ms = chrono::Utc::now().timestamp_millis(); - crate::events::store::append_event( - &state.jetstream, - "question_report", - &question_id, - "reported", - serde_json::json!({ "reason": reason }), - occurred_at_ms, - None, - ) - .await - .map_err(|e| ServerFnError::new(e.to_string()))?; - Ok(()) -} - /// Uploads one file to `POST /upload` and returns its stored object key /// - the value a `type: file` requirement contributes to /// `responses_json`, same as any other field. A plain `fetch`, not a diff --git a/src/content.rs b/src/content.rs index a1a1012..e2f1f4e 100644 --- a/src/content.rs +++ b/src/content.rs @@ -21,10 +21,8 @@ pub struct Question { pub qualifies: Option, #[serde(default)] pub alternatives: Vec, - /// Who to contact if a visitor gets stuck or finds this question - /// unhelpful - rendered as a small line on the page, and the person - /// a "report this question" action names in its own event payload - /// (see `events::store`, `question_report` aggregate-less events). + /// Who to contact if a visitor gets stuck - rendered as a small line + /// on the page. #[serde(default)] pub responsible: Option, } diff --git a/style/main.css b/style/main.css index 3d59cc6..8bdc101 100644 --- a/style/main.css +++ b/style/main.css @@ -254,25 +254,6 @@ main.not-found { color: var(--accent); } -.question-report { - font: inherit; - color: var(--ink-dim); - background: none; - border: none; - padding: 0; - text-decoration: underline; - cursor: pointer; -} - -.question-report:hover { - color: var(--accent); -} - -.question-report:disabled { - opacity: 0.5; - cursor: wait; -} - .alt-card { background: var(--paper-raised); border: 1px solid var(--line);