// /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 (
)
}
// ── §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 (
update(e.target.value)}
disabled={saving || cadence == null}
>
Off — never send a digest
Weekly
Daily
)
}
// ── §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 (
Start
setDraft(d => ({ ...d, start: e.target.value }))}
/>
End
setDraft(d => ({ ...d, end: e.target.value }))}
/>
Timezone
setDraft(d => ({ ...d, timezone: e.target.value }))}
>
{timezones.map(tz => {tz} )}
{isSet ? 'Update quiet hours' : 'Set quiet hours'}
{isSet && (
Clear
)}
{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 && (
RFC
State
Set by
Last participation
{watches.map(w => (
{w.rfc_title || w.rfc_slug}
update(w.rfc_slug, e.target.value)}
disabled={!!updating[w.rfc_slug]}
>
Watching
Following
Muted
{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}
remove(m.muted_user_id)}>
Unmute
))}
)}
{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 => (
mute(r)} disabled={busy}>
@{r.gitea_login}
{r.display_name}
{(r.role === 'owner' || r.role === 'admin') && (
{r.role}
)}
))}
)}
{hint &&
{hint}
}
)
}
// ── Small layout primitives ────────────────────────────────────────────────
function SectionShell({ title, subtitle, children }) {
return (
{title}
{subtitle && {subtitle}
}
{children}
)
}
function Toggle({ label, description, checked, onChange, disabled, title }) {
return (
onChange?.(e.target.checked)}
disabled={disabled}
/>
{label}
{description && {description} }
)
}