notification manager for bsky
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

move back to htmx

Swap hand-rolled fetch+DOMParser+replaceContents for HTMX attributes on the
forms and sections, return OOB HTML fragments from /apply, /propose, and
/mark-all-read instead of JSON/redirects, and render toasts server-side. The
earlier custom client was the interview cheatsheet's "essentially a hand-rolled
subset of HTMX" — replacing it cuts ~200 lines of duplicated infra.

- HTMX 2.0.4 from unpkg, defer-loaded
- Per-endpoint: /apply and /mark-all-read return a toast OOB + OOB controls
(and OOB subscriptions for /apply), with HX-Trigger: refreshQueue to drive
#queue-root re-fetch. /propose returns only the ask-root OOB since proposal
generation is read-only. /queue includes an OOB #queue-count titlebar so the
count and body swap together — avoids the "updating…" ghost next to a
populated queue.
- render/toast.ts renders toasts as HTML with a data-toast-dismiss attr; a
MutationObserver in client.ts wires auto-dismiss when HTMX inserts them.
- HTMX error path: the outer server catch returns an OOB error toast for
HX-Request:true instead of a plain-text 500, so failures still surface.
- Kept custom JS where HTMX doesn't apply: @ typeahead + mention nav,
selected-actor chips, dialog open/close, guidance save (inline feedback).
- Fixed a subtle regression the custom JS had hidden: #queue-count now
updates with every /queue swap, not just after custom loadQueue() calls.

Co-Authored-By: Claude Opus 4 (1M context) <noreply@anthropic.com>

+222 -348
+56 -281
src/client.ts
··· 1 - // Browser-side JS — transpiled from TS by Bun at startup and served as /app.js. 2 - // Handles: @ typeahead + actor selection, async propose/apply with progress states, 3 - // same-page section replacement, toast feedback. Enter submits, Shift+Enter for newline. 4 - 5 - type ToastDetail = { 6 - label: string 7 - href?: string 8 - detail: string 9 - status?: 'ok' | 'failed' | 'no-diff' 10 - } 11 - 12 - type ToastPayload = { 13 - description: string 14 - verified: boolean 15 - partial?: boolean 16 - details?: ToastDetail[] 17 - text: string 18 - } 1 + // Browser-side JS — transpiled from TS by Bun and served as /app.js. 2 + // HTMX drives fetch/swap for /propose, /apply, /mark-all-read, /queue, and 3 + // toast OOB insertion. This file keeps the stateful bits HTMX doesn't cover: 4 + // the @ typeahead + selected-actor chips, dialog open/close, guidance save 5 + // (inline feedback), toast auto-dismiss, and Enter-to-submit on the textarea. 19 6 20 7 type ActorSuggestion = { 21 8 did: string ··· 40 27 let mentionIndex = 0 41 28 let mentionFetchToken = 0 42 29 let mentionDebounce: number | undefined 43 - 44 - function feedbackRoot() { 45 - return document.getElementById('proposal-feedback') 46 - } 47 - 48 - function toastRoot() { 49 - return document.getElementById('toast-stack') 50 - } 51 30 52 31 function mentionRoot() { 53 32 return document.getElementById('actor-suggestions') ··· 218 197 }, 120) 219 198 } 220 199 221 - function showProposalError(message: string) { 222 - const feedback = feedbackRoot() 223 - if (!feedback) return 224 - const shell = document.createElement('div') 225 - shell.className = 'empty' 226 - shell.textContent = message 227 - feedback.replaceChildren(shell) 200 + // Dialogs — native <dialog> handles ESC; this covers the header triggers and 201 + // backdrop clicks. Event delegation keeps this working through OOB swaps. 202 + 203 + function openDialog(id: string) { 204 + const dialog = document.getElementById(id) 205 + if (dialog instanceof HTMLDialogElement && !dialog.open) dialog.showModal() 228 206 } 229 207 230 - function showProposalProgress(_message: string) { 231 - const feedback = feedbackRoot() 232 - if (!feedback) return () => {} 233 - const actor = selectedActors()[0]?.handle || '' 234 - const shell = document.createElement('div') 235 - shell.className = 'loading-shell' 236 - const title = document.createElement('div') 237 - title.className = 'loading-title' 238 - title.textContent = actor ? `looking up ${actor}…` : 'thinking it through…' 239 - const copy = document.createElement('div') 240 - copy.className = 'loading-copy' 241 - copy.textContent = actor 242 - ? 'Finding the right account before suggesting a change.' 243 - : 'Choosing the smallest change that matches your request.' 244 - shell.append(title, copy) 245 - feedback.replaceChildren(shell) 246 - 247 - const timer = window.setTimeout(() => { 248 - title.textContent = 'deciding what to change…' 249 - copy.textContent = 'Putting together a suggestion you can review first.' 250 - }, 900) 251 - 252 - return () => window.clearTimeout(timer) 208 + function closeDialog(dialog: HTMLDialogElement) { 209 + if (dialog.open) dialog.close() 253 210 } 254 211 255 - function showToast(payload: ToastPayload) { 256 - const root = toastRoot() 257 - if (!root) return 258 - 259 - const toneClass = payload.partial 260 - ? 'toast-partial' 261 - : payload.verified 262 - ? 'toast-ok' 263 - : 'toast-bad' 264 - const kickerLabel = payload.partial 265 - ? 'partial' 266 - : payload.verified 267 - ? 'done' 268 - : 'needs attention' 269 - // Partial toasts stick around longer because they carry failure detail 270 - // the user needs to read. 271 - const dismissAfter = payload.partial ? 6400 : 3200 212 + // Toast auto-dismiss — toasts are rendered server-side and inserted by HTMX 213 + // via hx-swap-oob="beforeend:#toast-stack". The observer watches for new 214 + // children and schedules their removal based on the data-toast-dismiss value. 272 215 273 - const toast = document.createElement('div') 274 - toast.className = `toast ${toneClass}` 275 - 276 - const kicker = document.createElement('div') 277 - kicker.className = 'toast-kicker' 278 - kicker.textContent = kickerLabel 279 - 280 - const title = document.createElement('div') 281 - title.className = 'toast-title' 282 - title.textContent = payload.description 216 + function wireToast(node: HTMLElement) { 217 + const dismissMs = Number(node.dataset.toastDismiss || 3200) 218 + window.setTimeout(() => node.remove(), Number.isFinite(dismissMs) ? dismissMs : 3200) 219 + } 283 220 284 - const copy = document.createElement('div') 285 - copy.className = 'toast-copy' 286 - if (payload.details?.length) { 287 - for (const detail of payload.details) { 288 - const line = document.createElement('div') 289 - line.className = `toast-line toast-line-${detail.status || 'ok'}` 290 - if (detail.href) { 291 - const link = document.createElement('a') 292 - link.className = 'actor-link' 293 - link.href = detail.href 294 - link.target = '_blank' 295 - link.rel = 'noopener noreferrer' 296 - link.textContent = detail.label 297 - line.append(link, document.createTextNode(` ${detail.detail}`)) 298 - } else { 299 - line.textContent = `${detail.label}: ${detail.detail}` 221 + function initToastObserver() { 222 + const stack = document.getElementById('toast-stack') 223 + if (!stack) return 224 + const observer = new MutationObserver(mutations => { 225 + for (const mutation of mutations) { 226 + for (const node of mutation.addedNodes) { 227 + if (node instanceof HTMLElement && node.classList.contains('toast')) { 228 + wireToast(node) 229 + } 300 230 } 301 - copy.append(line) 302 231 } 303 - } else { 304 - copy.textContent = payload.text 305 - } 306 - 307 - toast.append(kicker, title, copy) 308 - root.append(toast) 309 - window.setTimeout(() => toast.remove(), dismissAfter) 232 + }) 233 + observer.observe(stack, {childList: true}) 310 234 } 311 235 312 - function parseHtmlDocument(html: string) { 313 - return new DOMParser().parseFromString(html, 'text/html') 314 - } 315 - 316 - function replaceContents(id: string, nextDoc: Document) { 317 - const current = document.getElementById(id) 318 - const next = nextDoc.getElementById(id) 319 - if (current && next) current.innerHTML = next.innerHTML 320 - } 321 - 322 - function replaceMainSections(nextDoc: Document) { 323 - replaceContents('controls-root', nextDoc) 324 - replaceContents('ask-root', nextDoc) 325 - replaceContents('queue-root', nextDoc) 326 - // Subscriptions live inside a dialog — refresh its body so the list stays 327 - // in sync after apply/mark-all-read, regardless of whether the dialog is open. 328 - replaceContents('subscriptions-root', nextDoc) 329 - // Guidance panel is deliberately NOT refreshed — preserves in-flight typing. 236 + // Guidance save — kept as a custom AJAX handler because the feedback ("saved") 237 + // is inline and lightweight; not worth a full OOB-swap round-trip. 330 238 331 - const currentQueueCount = document.getElementById('queue-count') 332 - const nextQueueCount = nextDoc.getElementById('queue-count') 333 - if (currentQueueCount && nextQueueCount) { 334 - currentQueueCount.textContent = nextQueueCount.textContent 335 - } 336 - } 337 - 338 - async function refreshFromHome(toast?: ToastPayload) { 339 - const res = await fetch('/', {headers: {'x-noti-fragment': '1'}}) 340 - const html = await res.text() 341 - const nextDoc = parseHtmlDocument(html) 342 - replaceMainSections(nextDoc) 343 - if (toast) showToast(toast) 344 - } 345 - 346 - async function loadQueue() { 347 - const root = document.getElementById('queue-root') 348 - const count = document.getElementById('queue-count') 349 - if (!root || !count) return 350 - try { 351 - const res = await fetch('/queue', {headers: {'x-noti-fragment': '1'}}) 352 - if (!res.ok) throw new Error('failed to load suggestions') 353 - const html = await res.text() 354 - const nextCount = res.headers.get('x-noti-ready-count') 355 - root.innerHTML = html 356 - if (nextCount) count.textContent = `${nextCount} ready` 357 - } catch { 358 - root.innerHTML = '<div class="empty">could not load suggestions right now</div>' 359 - count.textContent = 'unavailable' 360 - } 361 - } 362 - 363 - async function handlePropose(form: HTMLFormElement) { 364 - const formData = new FormData(form) 365 - const message = String(formData.get('message') || '').trim() 366 - if (!message) return 367 - 239 + async function handleGuidanceSave(form: HTMLFormElement) { 240 + const textarea = form.querySelector('textarea[name="guidance"]') 241 + const hint = document.getElementById('guidance-hint') 368 242 const submitter = form.querySelector('button[type="submit"]') 369 - const clearProgress = showProposalProgress(message) 370 - if (submitter instanceof HTMLButtonElement) submitter.disabled = true 371 - 372 - try { 373 - const res = await fetch(form.action, {method: 'POST', body: formData}) 374 - const html = await res.text() 375 - const nextDoc = parseHtmlDocument(html) 376 - replaceMainSections(nextDoc) 377 - await loadQueue() 378 - } catch { 379 - clearProgress() 380 - showProposalError('could not build a suggestion right now') 381 - } finally { 382 - if (submitter instanceof HTMLButtonElement) submitter.disabled = false 383 - } 384 - } 385 - 386 - async function handleApply(form: HTMLFormElement) { 387 - const submitter = form.querySelector('button[type="submit"]') 243 + if (!(textarea instanceof HTMLTextAreaElement)) return 388 244 if (submitter instanceof HTMLButtonElement) submitter.disabled = true 389 245 try { 390 - const res = await fetch(form.action, { 391 - method: 'POST', 392 - body: new FormData(form), 393 - headers: {'x-noti-ajax': '1'}, 394 - }) 395 - if (!res.ok) throw new Error('apply failed') 396 - const payload = (await res.json()) as {toast: ToastPayload} 397 - await refreshFromHome(payload.toast) 398 - await loadQueue() 246 + const body = new FormData() 247 + body.set('guidance', textarea.value) 248 + const res = await fetch('/api/prefs', {method: 'POST', body}) 249 + if (!res.ok) throw new Error('save failed') 250 + const payload = (await res.json()) as {guidance?: string} 251 + textarea.value = typeof payload.guidance === 'string' ? payload.guidance : '' 252 + if (hint) { 253 + hint.textContent = 'saved' 254 + hint.classList.add('saved') 255 + window.setTimeout(() => { 256 + hint.textContent = 'in-memory; resets when the server restarts' 257 + hint.classList.remove('saved') 258 + }, 1600) 259 + } 399 260 } catch { 400 - showToast({ 401 - verified: false, 402 - description: 'Could not apply that change', 403 - text: 'Try again in a moment.', 404 - }) 261 + if (hint) hint.textContent = 'could not save — try again' 405 262 } finally { 406 263 if (submitter instanceof HTMLButtonElement) submitter.disabled = false 407 264 } 408 265 } 409 266 410 - async function handleMarkAllRead(form: HTMLFormElement) { 411 - const confirmed = window.confirm('Mark all unread notifications as read in Bluesky?') 412 - if (!confirmed) return 413 - 414 - const submitter = form.querySelector('button[type="submit"]') 415 - if (submitter instanceof HTMLButtonElement) submitter.disabled = true 416 - try { 417 - const res = await fetch(form.action, {method: 'POST', body: new FormData(form)}) 418 - if (!res.ok) throw new Error('mark all read failed') 419 - await refreshFromHome({ 420 - verified: true, 421 - description: 'Marked all notifications read', 422 - text: 'Your Bluesky notification badge is cleared.', 423 - }) 424 - await loadQueue() 425 - } catch { 426 - showToast({ 427 - verified: false, 428 - description: 'Could not mark notifications read', 429 - text: 'Try again in a moment.', 430 - }) 431 - } finally { 432 - if (submitter instanceof HTMLButtonElement) submitter.disabled = false 433 - } 434 - } 267 + // --- Event wiring --------------------------------------------------------- 435 268 436 269 document.addEventListener('input', event => { 437 270 const target = event.target ··· 445 278 void loadMentionSuggestions(mention) 446 279 }) 447 280 448 - function openDialog(id: string) { 449 - const dialog = document.getElementById(id) 450 - if (dialog instanceof HTMLDialogElement && !dialog.open) dialog.showModal() 451 - } 452 - 453 - function closeDialog(dialog: HTMLDialogElement) { 454 - if (dialog.open) dialog.close() 455 - } 456 - 457 281 document.addEventListener('click', event => { 458 282 const target = event.target 459 283 if (target instanceof HTMLElement) { ··· 463 287 return 464 288 } 465 289 const opener = target.closest('[data-dialog]') 466 - if (opener instanceof HTMLElement && !(opener as HTMLButtonElement).disabled) { 290 + if (opener instanceof HTMLButtonElement && !opener.disabled) { 467 291 const id = opener.dataset.dialog 468 292 if (id) { 469 293 openDialog(id) ··· 478 302 return 479 303 } 480 304 } 481 - // Backdrop click — dialog element itself is the event target when clicking 482 - // outside the shell. The inner .dialog-shell swallows its own clicks. 483 305 if (target instanceof HTMLDialogElement && target.open) { 484 306 closeDialog(target) 485 307 return ··· 536 358 } 537 359 }) 538 360 539 - async function handleGuidanceSave(form: HTMLFormElement) { 540 - const textarea = form.querySelector('textarea[name="guidance"]') 541 - const hint = document.getElementById('guidance-hint') 542 - const summary = document.querySelector('#guidance-panel .guidance-summary') 543 - const submitter = form.querySelector('button[type="submit"]') 544 - if (!(textarea instanceof HTMLTextAreaElement)) return 545 - if (submitter instanceof HTMLButtonElement) submitter.disabled = true 546 - try { 547 - const body = new FormData() 548 - body.set('guidance', textarea.value) 549 - const res = await fetch('/api/prefs', {method: 'POST', body}) 550 - if (!res.ok) throw new Error('save failed') 551 - const payload = (await res.json()) as {guidance?: string} 552 - const saved = typeof payload.guidance === 'string' ? payload.guidance : '' 553 - textarea.value = saved 554 - if (summary) summary.textContent = saved ? 'steering active' : 'add steering (optional)' 555 - if (hint) { 556 - hint.textContent = 'saved' 557 - hint.classList.add('saved') 558 - window.setTimeout(() => { 559 - hint.textContent = 'in-memory; resets when the server restarts' 560 - hint.classList.remove('saved') 561 - }, 1600) 562 - } 563 - } catch { 564 - if (hint) hint.textContent = 'could not save — try again' 565 - } finally { 566 - if (submitter instanceof HTMLButtonElement) submitter.disabled = false 567 - } 568 - } 569 - 361 + // Guidance form — custom AJAX save with inline feedback. 570 362 document.addEventListener('submit', event => { 571 363 const form = event.target 572 364 if (!(form instanceof HTMLFormElement)) return 573 - 574 - const action = new URL(form.action, window.location.href).pathname 575 - if (action === '/propose') { 576 - event.preventDefault() 577 - void handlePropose(form) 578 - return 579 - } 580 - if (action === '/apply') { 581 - event.preventDefault() 582 - void handleApply(form) 583 - return 584 - } 585 - if (action === '/mark-all-read') { 586 - event.preventDefault() 587 - void handleMarkAllRead(form) 588 - return 589 - } 590 365 if (form.id === 'guidance-form') { 591 366 event.preventDefault() 592 367 void handleGuidanceSave(form) ··· 594 369 }) 595 370 596 371 renderSelectedActors(selectedActors()) 597 - void loadQueue() 372 + initToastObserver()
+60 -36
src/render/page.ts
··· 1 1 import {getGuidance} from '../prefs' 2 - import type {AppState, ManagementTarget} from '../types' 2 + import type {ActionProposal, AppState, ManagementTarget} from '../types' 3 3 import {escapeHtml} from './html' 4 4 import {proposalBlock, renderQueue, subscriptionsSection} from './queue' 5 5 import {styles} from './styles' 6 6 7 7 const GEAR_ICON = `<svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 1 1-4 0v-.09a1.65 1.65 0 0 0-1-1.51 1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 1 1 0-4h.09a1.65 1.65 0 0 0 1.51-1 1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33h.09a1.65 1.65 0 0 0 1-1.51V3a2 2 0 1 1 4 0v.09a1.65 1.65 0 0 0 1 1.51h.09a1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82v.09a1.65 1.65 0 0 0 1.51 1H21a2 2 0 1 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>` 8 + 9 + function oobAttr(oob: boolean) { 10 + return oob ? ' hx-swap-oob="true"' : '' 11 + } 8 12 9 13 function guidancePanel() { 10 14 const guidance = getGuidance() ··· 22 26 ` 23 27 } 24 28 25 - function subscriptionsCount(targets: ManagementTarget[]) { 26 - return targets.length 29 + type ControlsState = { 30 + unreadCount: number 31 + currentSubscriptions: ManagementTarget[] 32 + } 33 + 34 + // Header controls — unread pill, mark-all-read, subscriptions dialog trigger, 35 + // settings gear. Re-emitted as an OOB swap on state-changing requests so the 36 + // unread count and subscription badge stay in sync without a full page reload. 37 + export function headerControlsSection(state: ControlsState, oob = false) { 38 + const subCount = state.currentSubscriptions.length 39 + return `<div class="controls" id="controls-root"${oobAttr(oob)}> 40 + <div class="pill">${state.unreadCount} unread</div> 41 + <form hx-post="/mark-all-read" hx-swap="none" hx-confirm="Mark all unread notifications as read in Bluesky?" hx-disabled-elt="find button"> 42 + <button class="button" type="submit">mark all read</button> 43 + </form> 44 + <button class="button header-button" type="button" data-dialog="subscriptions-dialog" ${subCount === 0 ? 'disabled' : ''} aria-label="Open subscriptions"> 45 + subscriptions${subCount ? ` <span class="header-badge">${subCount}</span>` : ''} 46 + </button> 47 + <button class="button header-button icon-button" type="button" data-dialog="settings-dialog" aria-label="Open settings"> 48 + ${GEAR_ICON} 49 + </button> 50 + </div>` 51 + } 52 + 53 + // The ask-noti form + feedback. OOB-swapped after /propose so the form clears 54 + // and the new proposal / help / refusal appears in place. 55 + export function askSection(state: {placeholder: string; proposal?: ActionProposal}, oob = false) { 56 + return `<section class="ask" id="ask-root"${oobAttr(oob)}> 57 + <div class="ask-title">ask noti</div> 58 + <div class="ask-copy">to mute an account, or change whose posts or replies you get notified about.</div> 59 + <form class="ask-form" hx-post="/propose" hx-swap="none" hx-disabled-elt="find button.propose" id="propose-form"> 60 + <div class="ask-input-shell"> 61 + <div class="selected-actors" id="selected-actors" hidden></div> 62 + <input type="hidden" name="selectedActors" value="[]"> 63 + <textarea name="message" placeholder="${escapeHtml(state.placeholder)}"></textarea> 64 + <div class="mention-menu" id="actor-suggestions" hidden></div> 65 + </div> 66 + <div class="ask-actions"> 67 + <div class="ask-hint">type <code>@</code> to look up an account</div> 68 + <button class="button propose" type="submit">suggest action</button> 69 + </div> 70 + </form> 71 + <div id="proposal-feedback">${proposalBlock(state.proposal)}</div> 72 + </section>` 73 + } 74 + 75 + // Subscriptions dialog body. OOB-swapped whenever upstream subscription state 76 + // changes so the dialog shows a current list whenever opened. 77 + export function subscriptionsBodySection(targets: ManagementTarget[], oob = false) { 78 + return `<div class="dialog-body" id="subscriptions-root"${oobAttr(oob)}>${subscriptionsSection(targets)}</div>` 27 79 } 28 80 29 81 export function renderPage(state: AppState) { 30 - const subscriptions = subscriptionsSection(state.currentSubscriptions) 31 82 const queue = renderQueue(state) 32 83 const queueCount = state.queueState === 'loading' ? 'updating…' : `${state.recommendations.length} suggestions` 33 - const subCount = subscriptionsCount(state.currentSubscriptions) 34 84 35 85 return `<!doctype html> 36 86 <html lang="en"> ··· 39 89 <meta name="viewport" content="width=device-width, initial-scale=1"> 40 90 <title>noti</title> 41 91 <link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🔔</text></svg>"> 92 + <script src="https://unpkg.com/htmx.org@2.0.4" defer></script> 42 93 <style>${styles}</style> 43 94 </head> 44 95 <body> 45 96 <div class="page"> 46 97 <div class="top"> 47 98 <h1>noti</h1> 48 - <div class="controls" id="controls-root"> 49 - <div class="pill">${state.unreadCount} unread</div> 50 - <form method="post" action="/mark-all-read"> 51 - <button class="button" type="submit">mark all read</button> 52 - </form> 53 - <button class="button header-button" type="button" data-dialog="subscriptions-dialog" ${subCount === 0 ? 'disabled' : ''} aria-label="Open subscriptions"> 54 - subscriptions${subCount ? ` <span class="header-badge">${subCount}</span>` : ''} 55 - </button> 56 - <button class="button header-button icon-button" type="button" data-dialog="settings-dialog" aria-label="Open settings"> 57 - ${GEAR_ICON} 58 - </button> 59 - </div> 99 + ${headerControlsSection(state)} 60 100 </div> 61 101 62 - <section class="ask" id="ask-root"> 63 - <div class="ask-title">ask noti</div> 64 - <div class="ask-copy">to mute an account, or change whose posts or replies you get notified about.</div> 65 - <form class="ask-form" method="post" action="/propose" id="propose-form"> 66 - <div class="ask-input-shell"> 67 - <div class="selected-actors" id="selected-actors" hidden></div> 68 - <input type="hidden" name="selectedActors" value="[]"> 69 - <textarea name="message" placeholder="${escapeHtml(state.placeholder)}"></textarea> 70 - <div class="mention-menu" id="actor-suggestions" hidden></div> 71 - </div> 72 - <div class="ask-actions"> 73 - <div class="ask-hint">type <code>@</code> to look up an account</div> 74 - <button class="button propose" type="submit">suggest action</button> 75 - </div> 76 - </form> 77 - <div id="proposal-feedback">${proposalBlock(state.proposal)}</div> 78 - </section> 102 + ${askSection(state)} 79 103 80 104 <div class="queue-header"> 81 105 <div class="section-kicker">recommended actions</div> 82 106 <div class="queue-titlebar" id="queue-count">${queueCount}</div> 83 107 </div> 84 - <section class="queue" id="queue-root">${queue}</section> 108 + <section class="queue" id="queue-root" hx-get="/queue" hx-trigger="load, refreshQueue from:body" hx-swap="innerHTML">${queue}</section> 85 109 <a class="footer-link" href="https://bsky.app/settings/notifications" target="_blank" rel="noopener noreferrer">configure notification settings in Bluesky 🦋 ↗</a> 86 110 87 111 <dialog class="dialog" id="subscriptions-dialog" aria-labelledby="subscriptions-dialog-title"> ··· 93 117 </div> 94 118 <button class="icon-button dialog-close" type="button" data-dialog-close aria-label="Close">×</button> 95 119 </header> 96 - <div class="dialog-body" id="subscriptions-root">${subscriptions}</div> 120 + ${subscriptionsBodySection(state.currentSubscriptions)} 97 121 </div> 98 122 </dialog> 99 123
+2 -2
src/render/queue.ts
··· 37 37 <pre>${escapeHtml(recommendation.code)}</pre> 38 38 </details> 39 39 <div class="queue-button-row"> 40 - <form method="post" action="/apply"> 40 + <form hx-post="/apply" hx-swap="none" hx-disabled-elt="find button"> 41 41 <input type="hidden" name="description" value="${escapeHtml(recommendation.description)}"> 42 42 <input type="hidden" name="code" value="${escapeHtml(recommendation.code)}"> 43 43 ${sourceInputs} ··· 80 80 .map(uri => `<input type="hidden" name="sourceUri" value="${escapeHtml(uri)}">`) 81 81 .join('') 82 82 return ` 83 - <form method="post" action="/apply"> 83 + <form hx-post="/apply" hx-swap="none" hx-disabled-elt="find button"> 84 84 <input type="hidden" name="description" value="${escapeHtml(description)}"> 85 85 <input type="hidden" name="code" value="${escapeHtml(code)}"> 86 86 <input type="hidden" name="actorDid" value="${escapeHtml(actorDid)}">
+50
src/render/toast.ts
··· 1 + import type {ApplyResultLine} from '../types' 2 + import {escapeHtml} from './html' 3 + 4 + type ToastSpec = { 5 + description: string 6 + verified: boolean 7 + partial: boolean 8 + details: ApplyResultLine[] 9 + } 10 + 11 + function toneClass(spec: Pick<ToastSpec, 'verified' | 'partial'>) { 12 + if (spec.partial) return 'toast-partial' 13 + return spec.verified ? 'toast-ok' : 'toast-bad' 14 + } 15 + 16 + function kickerLabel(spec: Pick<ToastSpec, 'verified' | 'partial'>) { 17 + if (spec.partial) return 'partial' 18 + return spec.verified ? 'done' : 'needs attention' 19 + } 20 + 21 + function renderLine(line: ApplyResultLine) { 22 + const status = line.status || 'ok' 23 + const body = line.href 24 + ? `<a class="actor-link" href="${escapeHtml(line.href)}" target="_blank" rel="noopener noreferrer">${escapeHtml(line.label)}</a> ${escapeHtml(line.detail)}` 25 + : `${escapeHtml(line.label)}: ${escapeHtml(line.detail)}` 26 + return `<div class="toast-line toast-line-${status}">${body}</div>` 27 + } 28 + 29 + // Partial toasts stay longer because they carry failure detail that needs 30 + // reading; other toasts auto-dismiss quickly. The data attribute drives the 31 + // client-side MutationObserver in client.ts. 32 + export function renderToast(spec: ToastSpec) { 33 + const dismissMs = spec.partial ? 6400 : 3200 34 + const fallback = spec.verified ? 'Done.' : 'No state change detected.' 35 + const lines = spec.details.length 36 + ? spec.details.map(renderLine).join('') 37 + : `<div class="toast-line">${escapeHtml(fallback)}</div>` 38 + return ` 39 + <div class="toast ${toneClass(spec)}" data-toast-dismiss="${dismissMs}"> 40 + <div class="toast-kicker">${escapeHtml(kickerLabel(spec))}</div> 41 + <div class="toast-title">${escapeHtml(spec.description)}</div> 42 + <div class="toast-copy">${lines}</div> 43 + </div> 44 + ` 45 + } 46 + 47 + // Wraps a toast for HTMX out-of-band insertion into #toast-stack. 48 + export function renderToastOOB(spec: ToastSpec) { 49 + return `<div hx-swap-oob="beforeend:#toast-stack">${renderToast(spec)}</div>` 50 + }
+54 -29
src/server.ts
··· 22 22 import {getGuidance, setGuidance} from './prefs' 23 23 import {proposeAction, generateRecommendations} from './recommend' 24 24 import {renderPage, renderQueue} from './render/index' 25 + import {askSection, headerControlsSection, subscriptionsBodySection} from './render/page' 26 + import {renderToastOOB} from './render/toast' 25 27 import {knownActors} from './state' 26 28 import type { 27 29 ActionProposal, ··· 193 195 }) 194 196 } 195 197 196 - function toastPayload(description: string, verified: boolean, partial: boolean, details: ApplyResultLine[]) { 197 - return { 198 - description, 199 - verified, 200 - partial, 201 - details: details.map(d => ({label: d.label, href: d.href, detail: d.detail, status: d.status})), 202 - text: details.length 203 - ? details.map(d => `${d.label}: ${d.detail}`).join('. ') 204 - : verified ? 'Done.' : 'No state change detected.', 205 - } 206 - } 198 + const HTML_HEADERS = {'content-type': 'text/html; charset=utf-8'} 207 199 208 200 function verifiesNotificationSeen(code: string) { 209 201 return code.includes('agent.app.bsky.notification.updateSeen') ··· 610 602 if (req.method === 'GET' && url.pathname === '/queue') { 611 603 const base = await buildBaseState() 612 604 const recommendations = await recommendationsFor(base) 613 - return new Response(renderQueue({recommendations, queueState: 'ready'}), { 614 - headers: { 615 - 'content-type': 'text/html; charset=utf-8', 616 - 'x-noti-ready-count': String(recommendations.length), 617 - }, 618 - }) 605 + // Main body goes into #queue-root (the hx-get target); the OOB span 606 + // updates the "N suggestions" titlebar in the same swap so it never 607 + // says "updating…" next to a populated queue. 608 + const countLabel = recommendations.length === 1 ? '1 suggestion' : `${recommendations.length} suggestions` 609 + const countOOB = `<div id="queue-count" class="queue-titlebar" hx-swap-oob="true">${countLabel}</div>` 610 + const body = countOOB + renderQueue({recommendations, queueState: 'ready'}) 611 + return new Response(body, {headers: HTML_HEADERS}) 619 612 } 620 613 621 614 if (req.method === 'POST' && url.pathname === '/mark-all-read') { 622 615 const agent = await createAgent() 623 616 await agent.app.bsky.notification.updateSeen({seenAt: new Date().toISOString()}) 624 - return Response.redirect(`${url.origin}/`, 303) 617 + const base = await buildBaseState() 618 + const body = [ 619 + renderToastOOB({ 620 + description: 'Marked all notifications read', 621 + verified: true, 622 + partial: false, 623 + details: [{label: 'Your Bluesky notification badge', detail: 'is cleared.', status: 'ok'}], 624 + }), 625 + headerControlsSection(base, true), 626 + ].join('\n') 627 + return new Response(body, { 628 + headers: {...HTML_HEADERS, 'HX-Trigger': 'refreshQueue'}, 629 + }) 625 630 } 626 631 627 632 if (req.method === 'POST' && url.pathname === '/apply') { ··· 664 669 details: verification.details, 665 670 }) 666 671 667 - if (req.headers.get('x-noti-ajax') === '1') { 668 - return Response.json({ 669 - ok: true, 672 + // Rebuild state post-mutation so OOB swaps reflect the now-current 673 + // unread count and subscription set. Queue recommendations are re-fetched 674 + // by #queue-root via the HX-Trigger: refreshQueue header below. 675 + const base = await buildBaseState() 676 + const body = [ 677 + renderToastOOB({ 678 + description, 670 679 verified: verification.verified, 671 680 partial: verification.partial, 672 - toast: toastPayload(description, verification.verified, verification.partial, verification.details), 673 - }) 674 - } 675 - 676 - return Response.redirect(`${url.origin}/`, 303) 681 + details: verification.details, 682 + }), 683 + headerControlsSection(base, true), 684 + subscriptionsBodySection(base.currentSubscriptions, true), 685 + ].join('\n') 686 + return new Response(body, { 687 + headers: {...HTML_HEADERS, 'HX-Trigger': 'refreshQueue'}, 688 + }) 677 689 } 678 690 679 691 if (req.method === 'POST' && url.pathname === '/propose') { ··· 681 693 const message = String(form.get('message') || '').trim() 682 694 const selectedActors = parseSelectedActors(form) 683 695 const state = message ? await buildProposalState(message, selectedActors) : await buildState() 684 - return new Response(renderPage(state), { 685 - headers: {'content-type': 'text/html; charset=utf-8'}, 686 - }) 696 + // OOB-swap just the ask-root; queue and subscriptions didn't change 697 + // (proposal generation is read-only), so no other fragments to ship. 698 + return new Response(askSection(state, true), {headers: HTML_HEADERS}) 687 699 } 688 700 689 701 if (req.method === 'GET' && url.pathname === '/') { ··· 698 710 return new Response('not found', {status: 404}) 699 711 } catch (error) { 700 712 const message = error instanceof Error ? error.message : String(error) 713 + console.error('[server] unhandled', error) 714 + // HTMX requests: return an error toast as OOB so the user sees feedback 715 + // instead of a broken swap. 200 keeps HTMX from entering its error path 716 + // (which would hide the toast); content is the toast OOB fragment. 717 + if (req.headers.get('HX-Request') === 'true') { 718 + const body = renderToastOOB({ 719 + description: 'Something went wrong', 720 + verified: false, 721 + partial: false, 722 + details: [{label: 'server', detail: message, status: 'failed'}], 723 + }) 724 + return new Response(body, {headers: HTML_HEADERS, status: 200}) 725 + } 701 726 return new Response(`noti-ts error: ${message}`, {status: 500}) 702 727 } 703 728 },