diff --git a/api/src/handlers.rs b/api/src/handlers.rs index 9b87b72..c1f8832 100644 --- a/api/src/handlers.rs +++ b/api/src/handlers.rs @@ -5,6 +5,7 @@ use axum::http::{HeaderMap, StatusCode}; use axum::Json; use base64::Engine; use serde::{Deserialize, Serialize}; +use serde_json::Value; use crate::email; use crate::serve::AppState; @@ -257,6 +258,92 @@ fn extract_qa_reply(payload: &MtaHookPayload, expected_domain: &str) -> Option<( ) } +fn value_at_path<'a>(value: &'a Value, path: &[&str]) -> Option<&'a Value> { + let mut current = value; + for key in path { + current = current.get(*key)?; + } + Some(current) +} + +fn string_from_value(value: &Value) -> Option { + match value { + Value::String(s) => Some(s.clone()), + Value::Object(map) => map + .get("address") + .or_else(|| map.get("email")) + .or_else(|| map.get("value")) + .and_then(|v| v.as_str()) + .map(ToOwned::to_owned), + _ => None, + } +} + +fn recipients_from_value(value: Option<&Value>) -> Vec { + let Some(value) = value else { + return Vec::new(); + }; + + match value { + Value::Array(values) => values + .iter() + .filter_map(|v| string_from_value(v).map(Recipient::Address)) + .collect(), + _ => string_from_value(value) + .map(Recipient::Address) + .into_iter() + .collect(), + } +} + +fn string_at_paths(value: &Value, paths: &[&[&str]]) -> Option { + paths.iter().find_map(|path| { + value_at_path(value, path) + .and_then(|v| v.as_str()) + .map(ToOwned::to_owned) + }) +} + +fn extract_qa_reply_from_value(payload: &Value, expected_domain: &str) -> Option<(i64, String)> { + if let Some(messages) = payload.get("messages").and_then(Value::as_array) { + for message in messages { + if let Some(reply) = extract_qa_reply_from_message( + &recipients_from_value(value_at_path(message, &["envelope", "to"])), + expected_domain, + string_at_paths( + message, + &[ + &["message", "subject"], + &["message", "headers", "subject"], + &["headers", "subject"], + ], + ) + .as_deref(), + &string_at_paths(message, &[&["message", "contents"], &["contents"], &["raw_message"]]) + .unwrap_or_default(), + ) { + return Some(reply); + } + } + } + + extract_qa_reply_from_message( + &recipients_from_value(value_at_path(payload, &["envelope", "to"])), + expected_domain, + string_at_paths( + payload, + &[ + &["message", "subject"], + &["message", "headers", "subject"], + &["headers", "subject"], + ], + ) + .as_deref(), + &string_at_paths(payload, &[&["message", "contents"], &["contents"], &["raw_message"]]) + .unwrap_or_default(), + ) +} + fn extract_qa_reply_from_message( recipients: &[Recipient], expected_domain: &str, @@ -290,14 +377,27 @@ fn extract_qa_reply_from_message( pub async fn webhook( State(state): State>, headers: HeaderMap, - Json(payload): Json, + body: String, ) -> Result, (StatusCode, String)> { if !webhook_secret_matches(&headers, &state.webhook_secret) { eprintln!("Rejected webhook: invalid secret"); return Err((StatusCode::UNAUTHORIZED, "invalid secret".to_string())); } - if let Some((id, body)) = extract_qa_reply(&payload, &state.qa_reply_domain) { + let payload_value: Value = match serde_json::from_str(&body) { + Ok(payload) => payload, + Err(err) => { + eprintln!("Rejected webhook: invalid JSON payload: {err}; body={body}"); + return Ok(Json(MtaHookResponse { action: "accept" })); + } + }; + + let parsed_reply = serde_json::from_value::(payload_value.clone()) + .ok() + .and_then(|payload| extract_qa_reply(&payload, &state.qa_reply_domain)) + .or_else(|| extract_qa_reply_from_value(&payload_value, &state.qa_reply_domain)); + + if let Some((id, body)) = parsed_reply { let db = state .db .lock() @@ -313,15 +413,16 @@ pub async fn webhook( return Ok(Json(MtaHookResponse { action: "discard" })); } - // No Q&A recipient matched — let Stalwart deliver normally + eprintln!("Q&A webhook accepted payload without matched reply: {payload_value}"); Ok(Json(MtaHookResponse { action: "accept" })) } #[cfg(test)] mod tests { use axum::http::HeaderMap; + use serde_json::Value; - use super::{extract_qa_reply, webhook_secret_matches, MtaHookPayload}; + use super::{extract_qa_reply, extract_qa_reply_from_value, webhook_secret_matches, MtaHookPayload}; #[test] fn extracts_reply_from_current_stalwart_payload() { @@ -391,4 +492,27 @@ mod tests { assert!(webhook_secret_matches(&headers, "topsecret")); } + + #[test] + fn extracts_reply_from_value_with_string_recipient() { + let payload: Value = serde_json::from_str( + r#"{ + "envelope": { + "to": "qa@extremist.software" + }, + "message": { + "headers": { + "subject": "Re: 9 - hi" + }, + "contents": "Answer body" + } + }"#, + ) + .unwrap(); + + assert_eq!( + extract_qa_reply_from_value(&payload, "extremist.software"), + Some((9, "Answer body".to_string())) + ); + } } diff --git a/index.html b/index.html index f78d6bc..362f098 100644 --- a/index.html +++ b/index.html @@ -6,95 +6,130 @@ Jet Pham - Software Extremist - + - + - + - + - - + + - - + + + -
+
diff --git a/src/lib/qa.ts b/src/lib/qa.ts new file mode 100644 index 0000000..c4b6343 --- /dev/null +++ b/src/lib/qa.ts @@ -0,0 +1,62 @@ +const DRAFT_KEY = "qa-draft"; +const relativeTime = new Intl.RelativeTimeFormat(undefined, { + numeric: "auto", +}); + +function getValidDate(dateString: string): Date | null { + const date = new Date(dateString); + return Number.isNaN(date.getTime()) ? null : date; +} + +export function readQuestionDraft(): string { + try { + return localStorage.getItem(DRAFT_KEY) ?? ""; + } catch { + return ""; + } +} + +export function writeQuestionDraft(value: string) { + try { + localStorage.setItem(DRAFT_KEY, value); + } catch { + // Ignore storage failures. + } +} + +export function clearQuestionDraft() { + try { + localStorage.removeItem(DRAFT_KEY); + } catch { + // Ignore storage failures. + } +} + +export function formatExactTimestamp(dateString: string): string { + const date = getValidDate(dateString); + if (!date) return dateString; + + return new Intl.DateTimeFormat(undefined, { + dateStyle: "medium", + timeStyle: "short", + }).format(date); +} + +export function formatRelativeTimestamp(dateString: string): string { + const date = getValidDate(dateString); + if (!date) return dateString; + + const diffMs = date.getTime() - Date.now(); + const diffMinutes = Math.round(diffMs / 60000); + const diffHours = Math.round(diffMs / 3600000); + const diffDays = Math.round(diffMs / 86400000); + + if (Math.abs(diffMinutes) < 60) + return relativeTime.format(diffMinutes, "minute"); + if (Math.abs(diffHours) < 24) return relativeTime.format(diffHours, "hour"); + if (Math.abs(diffDays) < 30) return relativeTime.format(diffDays, "day"); + + return new Intl.DateTimeFormat(undefined, { dateStyle: "medium" }).format( + date, + ); +} diff --git a/src/pages/qa.ts b/src/pages/qa.ts index 190b4ca..25ee47d 100644 --- a/src/pages/qa.ts +++ b/src/pages/qa.ts @@ -1,4 +1,11 @@ -import { getQuestions, submitQuestion } from "~/lib/api"; +import { getQuestions, submitQuestion, type Question } from "~/lib/api"; +import { + clearQuestionDraft, + formatExactTimestamp, + formatRelativeTimestamp, + readQuestionDraft, + writeQuestionDraft, +} from "~/lib/qa"; import { frostedBox } from "~/components/frosted-box"; function escapeHtml(str: string): string { @@ -7,81 +14,197 @@ function escapeHtml(str: string): string { return div.innerHTML; } +function formatQuestionDates(question: Question): string { + const asked = formatRelativeTimestamp(question.created_at); + const answered = formatRelativeTimestamp(question.answered_at); + const askedExact = formatExactTimestamp(question.created_at); + const answeredExact = formatExactTimestamp(question.answered_at); + + return ` + Asked ${escapeHtml(asked)} + · + Answered ${escapeHtml(answered)}`; +} + +function renderQuestions(list: HTMLElement, questions: Question[]) { + if (questions.length === 0) { + list.innerHTML = ` +
+ No answers yet +

No questions have been answered yet.

+

Ask something above and check back once a reply is posted.

+
`; + return; + } + + list.innerHTML = questions + .map( + (q) => ` +
+ #${String(q.id)} +

${escapeHtml(q.question)}

+

${escapeHtml(q.answer)}

+

${formatQuestionDates(q)}

+
`, + ) + .join(""); +} + export async function qaPage(outlet: HTMLElement) { + const draft = readQuestionDraft(); + outlet.innerHTML = `
${frostedBox(` -
+
Ask a Question + -
- 0/200 - + aria-describedby="qa-helper qa-validation char-count" + placeholder="Type your question...">${escapeHtml(draft)} +

Press Enter to send. Press Shift+Enter for a new line.

+
+

+ ${draft.length}/200 +
+
+

+
-

-
Loading...
+
Loading answered questions...
`)}
`; const form = document.getElementById("qa-form") as HTMLFormElement; const input = document.getElementById("qa-input") as HTMLTextAreaElement; - const charCount = document.getElementById("char-count")!; - const status = document.getElementById("qa-status")!; - const list = document.getElementById("qa-list")!; + const submitButton = document.getElementById( + "qa-submit", + ) as HTMLButtonElement; + const charCount = document.getElementById("char-count") as HTMLSpanElement; + const validation = document.getElementById( + "qa-validation", + ) as HTMLParagraphElement; + const status = document.getElementById("qa-status") as HTMLParagraphElement; + const list = document.getElementById("qa-list") as HTMLDivElement; + + let isSubmitting = false; + let hasInteracted = draft.trim().length > 0; + + function setStatus(message: string, color: string) { + status.textContent = message; + status.style.color = color; + } + + function updateValidation() { + const trimmed = input.value.trim(); + const remaining = 200 - input.value.length; + charCount.textContent = `${input.value.length}/200`; + + if (trimmed.length === 0 && hasInteracted) { + validation.textContent = "Question cannot be empty."; + validation.style.color = "var(--light-red)"; + input.setAttribute("aria-invalid", "true"); + return false; + } + + input.removeAttribute("aria-invalid"); + + if (remaining <= 20) { + validation.textContent = `${remaining} characters left.`; + validation.style.color = + remaining <= 5 ? "var(--yellow)" : "var(--dark-gray)"; + return true; + } + + validation.textContent = ""; + return true; + } + + async function loadQuestions() { + list.textContent = "Loading answered questions..."; + list.style.color = "var(--light-gray)"; + list.style.textAlign = "left"; + + try { + const questions = await getQuestions(); + renderQuestions(list, questions); + } catch { + list.innerHTML = ` +
+ Load failed +

Failed to load answered questions.

+

+ +

+
`; + list.style.color = "var(--light-red)"; + + const retryButton = document.getElementById( + "qa-retry", + ) as HTMLButtonElement | null; + retryButton?.addEventListener("click", () => { + void loadQuestions(); + }); + } + } input.addEventListener("input", () => { - charCount.textContent = `${input.value.length}/200`; + hasInteracted = true; + writeQuestionDraft(input.value); + updateValidation(); + }); + + input.addEventListener("keydown", (e) => { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + form.requestSubmit(); + } }); form.addEventListener("submit", (e) => { e.preventDefault(); - const question = input.value.trim(); - if (!question) return; + if (isSubmitting) return; - status.textContent = "Submitting..."; - status.style.color = "var(--light-gray)"; + const question = input.value.trim(); + hasInteracted = true; + if (!updateValidation() || !question) { + setStatus("Write a question before submitting.", "var(--light-red)"); + input.focus(); + return; + } + + isSubmitting = true; + submitButton.disabled = true; + setStatus("Submitting...", "var(--light-gray)"); submitQuestion(question) .then(() => { input.value = ""; - charCount.textContent = "0/200"; - status.textContent = "Question submitted! It will appear here once answered."; - status.style.color = "var(--light-green)"; + clearQuestionDraft(); + hasInteracted = false; + updateValidation(); + setStatus( + "Question submitted! It will appear here once answered.", + "var(--light-green)", + ); + input.focus(); }) .catch((err: unknown) => { - status.textContent = err instanceof Error ? err.message : "Failed to submit question."; - status.style.color = "var(--light-red)"; + setStatus( + err instanceof Error ? err.message : "Failed to submit question.", + "var(--light-red)", + ); + }) + .finally(() => { + isSubmitting = false; + submitButton.disabled = false; }); }); - try { - const questions = await getQuestions(); - if (questions.length === 0) { - list.textContent = "No questions answered yet."; - list.style.color = "var(--dark-gray)"; - } else { - list.innerHTML = questions - .map( - (q) => ` -
- #${String(q.id)} -

${escapeHtml(q.question)}

-

${escapeHtml(q.answer)}

-

- Asked ${q.created_at} · Answered ${q.answered_at} -

-
`, - ) - .join(""); - } - } catch { - list.textContent = "Failed to load questions."; - list.style.color = "var(--light-red)"; - list.style.textAlign = "center"; - } + updateValidation(); + await loadQuestions(); } diff --git a/src/router.ts b/src/router.ts index a78b093..0a1059d 100644 --- a/src/router.ts +++ b/src/router.ts @@ -27,12 +27,26 @@ export function route(path: string, handler: PageHandler) { export function navigate(path: string) { history.pushState(null, "", path); - void render(); + window.scrollTo({ top: 0, behavior: "auto" }); + void render(true); } -async function render() { +function updateNavState(path: string) { + const navLinks = + document.querySelectorAll("[data-nav-link]"); + navLinks.forEach((link) => { + if (link.pathname === path) { + link.setAttribute("aria-current", "page"); + } else { + link.removeAttribute("aria-current"); + } + }); +} + +async function render(focusOutlet = false) { const path = location.pathname; const outlet = document.getElementById("outlet")!; + updateNavState(path); for (const r of routes) { const match = path.match(r.pattern); @@ -43,6 +57,7 @@ async function render() { }); outlet.innerHTML = ""; await r.handler(outlet, params); + if (focusOutlet) outlet.focus(); return; } } @@ -51,14 +66,20 @@ async function render() { if (notFoundHandler) { await notFoundHandler(outlet, {}); } + if (focusOutlet) outlet.focus(); } export function initRouter() { - window.addEventListener("popstate", () => void render()); + window.addEventListener("popstate", () => void render(true)); document.addEventListener("click", (e) => { const anchor = (e.target as HTMLElement).closest("a"); - if (anchor?.origin === location.origin && !anchor.hasAttribute("download")) { + if ( + anchor?.origin === location.origin && + !anchor.hash && + !anchor.hasAttribute("download") && + !anchor.hasAttribute("target") + ) { e.preventDefault(); navigate(anchor.pathname); } diff --git a/src/styles/globals.css b/src/styles/globals.css index df9432a..4e569ac 100644 --- a/src/styles/globals.css +++ b/src/styles/globals.css @@ -9,144 +9,232 @@ } :root { - --font-sans: "IBM VGA", ui-monospace, "SF Mono", "Monaco", "Inconsolata", "Roboto Mono", "Source Code Pro", monospace; - --font-mono: "IBM VGA", ui-monospace, "SF Mono", "Monaco", "Inconsolata", "Roboto Mono", "Source Code Pro", monospace; + --font-sans: + "IBM VGA", ui-monospace, "SF Mono", "Monaco", "Inconsolata", "Roboto Mono", + "Source Code Pro", monospace; + --font-mono: + "IBM VGA", ui-monospace, "SF Mono", "Monaco", "Inconsolata", "Roboto Mono", + "Source Code Pro", monospace; /* 16-color palette */ --black: #000000; - --blue: #0000AA; - --green: #00AA00; - --cyan: #00AAAA; - --red: #AA0000; - --magenta: #AA00AA; - --brown: #AA5500; - --light-gray: #AAAAAA; + --blue: #0000aa; + --green: #00aa00; + --cyan: #00aaaa; + --red: #aa0000; + --magenta: #aa00aa; + --brown: #aa5500; + --light-gray: #aaaaaa; --dark-gray: #555555; - --light-blue: #5555FF; - --light-green: #55FF55; - --light-cyan: #55FFFF; - --light-red: #FF5555; - --light-magenta: #FF55FF; - --yellow: #FFFF55; - --white: #FFFFFF; + --light-blue: #5555ff; + --light-green: #55ff55; + --light-cyan: #55ffff; + --light-red: #ff5555; + --light-magenta: #ff55ff; + --yellow: #ffff55; + --white: #ffffff; } - - /* Global BBS-style 80-character width constraint - responsive */ + +/* Global BBS-style 80-character width constraint - responsive */ +html { + height: 100%; + width: min( + 80ch, + 100vw + ); /* 80 characters wide on desktop, full width on mobile */ + padding: 1rem; + margin: 0 auto; + font-family: "IBM VGA", monospace; + font-size: 1.25rem; /* Smaller font size for mobile */ + white-space: normal; + line-height: 1; + box-sizing: border-box; +} + +/* Desktop font size */ +@media (min-width: 768px) { html { - height: 100%; - width: min(80ch, 100vw); /* 80 characters wide on desktop, full width on mobile */ - padding: 1rem; - margin: 0 auto; - font-family: "IBM VGA", monospace; - font-size: 1.25rem; /* Smaller font size for mobile */ - white-space: normal; - line-height: 1; - box-sizing: border-box; + font-size: 1.5rem; } - - /* Desktop font size */ - @media (min-width: 768px) { - html { - font-size: 1.5rem; - } - } - - /* Apply CGA theme to body */ - body { - height: 100%; - background-color: var(--black); - color: var(--white); - margin: 0; - padding: 0; - } - - /* Global focus ring styles for all tabbable elements */ - button:focus, - a:focus, - input:focus, - textarea:focus, - select:focus, - [tabindex]:focus, - [contenteditable]:focus { - outline: 2px solid white; - outline-offset: -2px; - border-radius: 0; - } - - /* Link styles - blue without underline */ - a { - color: var(--light-blue); - text-decoration: none; } - - a:hover { - background-color: var(--light-blue); - color: var(--black); - } - /* Form inputs */ - .qa-textarea { - width: 100%; - background-color: var(--black); - border: 2px solid var(--white); - color: var(--light-gray); - padding: 1ch; - resize: none; - font-family: inherit; - font-size: inherit; - line-height: inherit; - } +/* Apply CGA theme to body */ +body { + height: 100%; + background-color: var(--black); + color: var(--white); + margin: 0; + padding: 0; +} - .qa-button { - border: none; - padding: 0.25ch 1ch; - color: var(--yellow); - background: transparent; - font-family: inherit; - font-size: inherit; - cursor: pointer; - } +#outlet { + display: block; +} - .qa-button:hover { - background-color: var(--yellow); - color: var(--black); - } +/* Global focus ring styles for all tabbable elements */ +button:focus, +a:focus, +input:focus, +textarea:focus, +select:focus, +[tabindex]:focus, +[contenteditable]:focus { + outline: 2px solid white; + outline-offset: -2px; + border-radius: 0; +} - /* Project markdown content */ - .project-content h2 { - color: var(--light-cyan); - margin-top: 2ch; - margin-bottom: 1ch; - } +/* Link styles - blue without underline */ +a { + color: var(--light-blue); + text-decoration: none; + text-underline-offset: 0.2ch; +} - .project-content h3 { - color: var(--light-green); - margin-top: 1.5ch; - margin-bottom: 0.5ch; - } +a:hover, +a:focus-visible, +a[aria-current="page"] { + background-color: var(--light-blue); + color: var(--black); + text-decoration: underline; +} - .project-content p { - margin-bottom: 1ch; - } +a[href^="http://"]::after, +a[href^="https://"]::after { + content: " [EXT]"; + color: var(--dark-gray); +} - .project-content ul, - .project-content ol { - margin-left: 2ch; - margin-bottom: 1ch; - } +.skip-link { + position: absolute; + left: 1rem; + top: 0; + transform: translateY(-150%); + padding: 0.5ch 1ch; + background: var(--yellow); + color: var(--black); + z-index: 20; +} - .project-content code { - color: var(--yellow); - } +.skip-link:focus { + transform: translateY(1rem); +} - .project-content pre { - border: 2px solid var(--dark-gray); - padding: 1ch; - overflow-x: auto; - margin-bottom: 1ch; - } +/* Form inputs */ +.qa-textarea { + width: 100%; + background-color: var(--black); + border: 2px solid var(--white); + color: var(--light-gray); + padding: 1ch; + resize: none; + font-family: inherit; + font-size: inherit; + line-height: inherit; +} - .project-content a { - color: var(--light-blue); - } +.qa-textarea::placeholder { + color: var(--dark-gray); +} +.qa-button { + border: none; + padding: 0.25ch 1ch; + color: var(--yellow); + background: transparent; + font-family: inherit; + font-size: inherit; + cursor: pointer; +} + +.qa-button:hover { + background-color: var(--yellow); + color: var(--black); +} + +.qa-button:disabled { + color: var(--dark-gray); + cursor: wait; +} + +.qa-button:disabled:hover { + background: transparent; + color: var(--dark-gray); +} + +.qa-helper { + margin-top: 1ch; + color: var(--dark-gray); +} + +.qa-meta { + margin-top: 0.5ch; + color: var(--dark-gray); +} + +.qa-inline-action { + border: none; + background: transparent; + color: var(--light-blue); + padding: 0; + font: inherit; + cursor: pointer; + text-decoration: underline; + text-underline-offset: 0.2ch; +} + +.qa-inline-action:hover, +.qa-inline-action:focus-visible { + background-color: var(--light-blue); + color: var(--black); +} + +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +/* Project markdown content */ +.project-content h2 { + color: var(--light-cyan); + margin-top: 2ch; + margin-bottom: 1ch; +} + +.project-content h3 { + color: var(--light-green); + margin-top: 1.5ch; + margin-bottom: 0.5ch; +} + +.project-content p { + margin-bottom: 1ch; +} + +.project-content ul, +.project-content ol { + margin-left: 2ch; + margin-bottom: 1ch; +} + +.project-content code { + color: var(--yellow); +} + +.project-content pre { + border: 2px solid var(--dark-gray); + padding: 1ch; + overflow-x: auto; + margin-bottom: 1ch; +} + +.project-content a { + color: var(--light-blue); +}