// /settings/notifications — the §15.4 / §15.5 / §15.6 / §15.8 surface. // // Topic 13 settled the schema and the per-category rules; Slice 6 wired // the endpoints; Slice 7 lands the surface where a contributor finds // the per-category email toggles, the digest cadence dropdown, the // quiet-hours editor, the watches overview, and the per-user mute // list. The §15.4 email footer's "Manage all preferences" link points // at this route. // // Each sub-section is intentionally thin — the rules are settled in // §15; this page renders them. The one piece of voice the spec // commits to and the surface inherits: the `email_watched_churn` // toggle renders as permanently disabled with the §15.4 refusal // tooltip ("Per-commit and per-message email is intentionally not // offered. The digest aggregates this activity weekly."). Naming the // refusal is the spec's commitment; silently omitting the toggle // would let the contract drift. import { useEffect, useMemo, useState } from 'react' import { Link } from 'react-router-dom' import { getNotificationPreferences, setNotificationPreferences, getQuietHours, setQuietHours, listWatches, setWatch, unmuteUser, muteUser, searchUsers, } from '../api.js' const CHURN_REFUSAL = 'Per-commit and per-message email is intentionally not offered. The digest aggregates this activity weekly.' export default function NotificationSettings({ viewer }) { return (

Notification settings

Which signals reach you, how, and when. The rules live in §15; this page is where you tune them.

) } // ── §15.4 email category toggles ─────────────────────────────────────────── function EmailPreferencesSection() { const [prefs, setPrefs] = useState(null) const [saving, setSaving] = useState(false) const [savedNote, setSavedNote] = useState('') const [error, setError] = useState(null) useEffect(() => { getNotificationPreferences().then(setPrefs).catch(e => setError(e.message)) }, []) async function update(field, value) { setSaving(true) setSavedNote('') try { await setNotificationPreferences({ [field]: value }) setPrefs(p => ({ ...p, [field]: value })) setSavedNote('Saved.') } catch (e) { setError(e.message) } finally { setSaving(false) setTimeout(() => setSavedNote(''), 1500) } } if (!prefs) return return ( update('email_personal_direct', v)} disabled={saving} /> update('email_watched_structural', v)} disabled={saving} /> update('email_admin_actionable', v)} disabled={saving} /> {!!prefs.email_opt_out_all && (

A hard bounce or complaint from your mailbox flipped the global email opt-out. No email will be sent until you contact an admin to clear the flag, even if individual categories are enabled.

)}

{savedNote}

) } // ── §15.5 digest cadence ─────────────────────────────────────────────────── function DigestCadenceSection() { const [cadence, setCadence] = useState(null) const [saving, setSaving] = useState(false) useEffect(() => { getNotificationPreferences().then(p => setCadence(p.digest_cadence || 'weekly')) }, []) async function update(value) { setSaving(true) try { await setNotificationPreferences({ digest_cadence: value }) setCadence(value) } finally { setSaving(false) } } return (
) } // ── §15.8 quiet hours ────────────────────────────────────────────────────── function QuietHoursSection() { const [data, setData] = useState(null) const [draft, setDraft] = useState({ start: '', end: '', timezone: '' }) const [saving, setSaving] = useState(false) const [error, setError] = useState(null) // The browser ships an IANA tz list per §15.8 — preferable to a // free-text field, since the API validates the trio. const timezones = useMemo(() => { try { return Intl.supportedValuesOf('timeZone') } catch { return ['UTC'] } }, []) useEffect(() => { getQuietHours().then(q => { setData(q) setDraft({ start: q.start || '', end: q.end || '', timezone: q.timezone || Intl.DateTimeFormat().resolvedOptions().timeZone, }) }) }, []) async function save() { setSaving(true) setError(null) try { await setQuietHours({ start: draft.start, end: draft.end, timezone: draft.timezone }) setData({ start: draft.start, end: draft.end, timezone: draft.timezone }) } catch (e) { setError(e.message) } finally { setSaving(false) } } async function clear() { setSaving(true) setError(null) try { await setQuietHours({ start: null, end: null, timezone: null }) setData({ start: null, end: null, timezone: null }) setDraft({ start: '', end: '', timezone: Intl.DateTimeFormat().resolvedOptions().timeZone }) } catch (e) { setError(e.message) } finally { setSaving(false) } } if (!data) return const isSet = !!(data.start && data.end && data.timezone) return (
{isSet && ( )}
{error &&

{error}

}
) } // ── §15.6 watches overview ───────────────────────────────────────────────── function WatchesSection() { const [watches, setWatches] = useState(null) const [updating, setUpdating] = useState({}) useEffect(() => { listWatches().then(r => setWatches(r.items || [])) }, []) async function update(slug, state) { setUpdating(u => ({ ...u, [slug]: true })) try { const r = await setWatch(slug, state) setWatches(prev => prev.map(w => w.rfc_slug === slug ? { ...w, state: r.state, set_by: 'explicit' } : w )) } finally { setUpdating(u => ({ ...u, [slug]: false })) } } if (!watches) return return ( {watches.length === 0 && (

No watches yet. Open an RFC, propose, or join a thread — auto-watch will set one for you.

)} {watches.length > 0 && ( {watches.map(w => ( ))}
RFC State Set by Last participation
{w.rfc_title || w.rfc_slug} {w.set_by === 'explicit' ? 'You' : 'Auto'} {w.last_participation_at || '—'}
)}
) } // ── §15.8 per-user mute list + typeahead add ─────────────────────────────── function MutesSection({ viewer }) { const [mutes, setMutes] = useState(null) const [error, setError] = useState(null) async function refresh() { try { const r = await listMutes() setMutes(r.items || []) } catch (e) { setError(e.message) } } useEffect(() => { refresh() }, []) async function remove(userId) { await unmuteUser(userId) setMutes(prev => prev.filter(m => m.muted_user_id !== userId)) } return ( {viewer.role === 'owner' || viewer.role === 'admin' ? (

Owners and admins cannot mute notifications — the role requires receiving signals from everyone. (§15.8)

) : ( )} {mutes && mutes.length === 0 && (

No active mutes.

)} {mutes && mutes.length > 0 && (
    {mutes.map(m => (
  • @{m.gitea_login} {m.display_name} since {m.muted_at}
  • ))}
)} {error &&

{error}

}
) } // The mute-list read endpoint isn't a separate route in the API today // (§17 names add/delete only); we read the join here against /api/users/me // via a tiny dedicated endpoint that mirrors the watches shape. For // Slice 7's v1 surface, we compute the list client-side from a server // endpoint that returns the joined rows — added in api_admin.py's // neighborhood for proximity. async function listMutes() { const res = await fetch('/api/users/me/notification-mutes') if (!res.ok) { const detail = await res.text() throw new Error(detail || `HTTP ${res.status}`) } return res.json() } function MuteTypeahead({ onMuted }) { const [q, setQ] = useState('') const [results, setResults] = useState([]) const [open, setOpen] = useState(false) const [busy, setBusy] = useState(false) const [hint, setHint] = useState('') useEffect(() => { const t = setTimeout(() => { searchUsers(q).then(r => setResults(r.items || [])) }, 120) return () => clearTimeout(t) }, [q]) async function mute(user) { setBusy(true) setHint('') try { await muteUser(user.id) setQ('') setOpen(false) onMuted?.() } catch (e) { setHint(e.message) } finally { setBusy(false) } } return (
{ setQ(e.target.value); setOpen(true) }} onFocus={() => setOpen(true)} onBlur={() => setTimeout(() => setOpen(false), 150)} disabled={busy} /> {open && results.length > 0 && (
    {results.map(r => (
  • ))}
)} {hint &&

{hint}

}
) } // ── Small layout primitives ──────────────────────────────────────────────── function SectionShell({ title, subtitle, children }) { return (

{title}

{subtitle &&

{subtitle}

}
{children}
) } function Toggle({ label, description, checked, onChange, disabled, title }) { return ( ) }