Drop question-report entirely, obfuscate the mailto link instead
Deploy / deploy (push) Successful in 1m2s
Deploy / deploy (push) Successful in 1m2s
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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
27dac946a3
commit
35663a28c1
+32
-69
@@ -163,7 +163,6 @@ fn QuestionView(
|
||||
.into_any();
|
||||
}
|
||||
|
||||
let question_id_for_note = question_id.clone();
|
||||
view! {
|
||||
<Hero
|
||||
title=question.name.clone()
|
||||
@@ -208,58 +207,52 @@ fn QuestionView(
|
||||
.responsible
|
||||
.clone()
|
||||
.map(|r| {
|
||||
view! { <ResponsibleNote question_id=question_id_for_note.clone() responsible=r/> }
|
||||
view! { <ResponsibleNote responsible=r/> }
|
||||
})}
|
||||
}
|
||||
.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::<ReportQuestion>::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::<web_sys::HtmlAnchorElement>(&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! {
|
||||
<p class="question-responsible">
|
||||
"Asked by " {responsible.name.clone()}
|
||||
<br/>
|
||||
"— "
|
||||
<a href=format!("mailto:{}", responsible.contact)>"contact them"</a>
|
||||
<a data-user=user data-domain=domain on:mouseover=assemble on:click=assemble>
|
||||
"contact them"
|
||||
</a>
|
||||
" if you get stuck."
|
||||
{move || {
|
||||
if reported.get() {
|
||||
view! { <span class="question-reported">" Reported — thanks."</span> }
|
||||
.into_any()
|
||||
} else {
|
||||
let question_id = question_id.clone();
|
||||
view! {
|
||||
<button
|
||||
type="button"
|
||||
class="question-report"
|
||||
disabled=move || report.pending().get()
|
||||
on:click=move |_| {
|
||||
report
|
||||
.dispatch(ReportQuestion {
|
||||
question_id: question_id.clone(),
|
||||
reason: None,
|
||||
});
|
||||
}
|
||||
>
|
||||
" Not helpful? Report this question."
|
||||
</button>
|
||||
}
|
||||
.into_any()
|
||||
}
|
||||
}}
|
||||
</p>
|
||||
}
|
||||
}
|
||||
@@ -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<String>,
|
||||
) -> Result<(), ServerFnError> {
|
||||
use crate::server::AppState;
|
||||
|
||||
let state = expect_context::<AppState>();
|
||||
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
|
||||
|
||||
+2
-4
@@ -21,10 +21,8 @@ pub struct Question {
|
||||
pub qualifies: Option<String>,
|
||||
#[serde(default)]
|
||||
pub alternatives: Vec<Alternative>,
|
||||
/// 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<Responsible>,
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user