// /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 { entryPath, useProjectId } from '../lib/entryPaths' import { getNotificationPreferences, setNotificationPreferences, getQuietHours, setQuietHours, listWatches, setWatch, unmuteUser, muteUser, searchUsers, getCookieConsent, getMe, setPasscode, clearPasscode, listMyDevices, revokeMyDevice, revokeAllMyDevices, } from '../api.js' import { getConsent, onConsentChange, hydrateFromServer } from '../lib/consent.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.

) } // ── v0.11.0: trusted devices (§6.2, roadmap item #9) ────────────────────── // // Lists the user's active device-trust rows and lets them revoke any // or all. A revoke marks the row dead server-side; the matching // device's next visit will be refused and the cookie cleared. The // surface intentionally does not single out the row whose cookie the // current request carries — every row reads identically, so the user // can revoke "this device" alongside any other from a single page. function DevicesSection() { const [devices, setDevices] = useState(null) const [error, setError] = useState(null) const [busy, setBusy] = useState(false) useEffect(() => { refresh() }, []) async function refresh() { try { const { items } = await listMyDevices() setDevices(items || []) setError(null) } catch (e) { setError(e.message || 'Could not load trusted devices.') } } async function onRevoke(deviceId) { setBusy(true) try { await revokeMyDevice(deviceId) await refresh() } catch (e) { setError(e.message || 'Could not revoke device.') } finally { setBusy(false) } } async function onRevokeAll() { if (!confirm('Revoke trust on every device, including this one? You will be asked to sign in via email next time.')) { return } setBusy(true) try { await revokeAllMyDevices() await refresh() } catch (e) { setError(e.message || 'Could not revoke devices.') } finally { setBusy(false) } } return ( {devices === null &&

Loading…

} {devices !== null && devices.length === 0 && (

No trusted devices. Sign in and check “Trust this device for 30 days” to add the device you're on now.

)} {devices !== null && devices.length > 0 && ( <> )} {error &&

{error}

}
) } function formatStamp(stamp) { // The server emits SQLite `datetime('now')` strings (UTC, no // timezone marker). Parse defensively; fall back to the raw stamp // if Date can't make sense of it. if (!stamp) return '—' const d = new Date(stamp.replace(' ', 'T') + 'Z') if (Number.isNaN(d.getTime())) return stamp return d.toLocaleString() } // ── §6.2 sign-in (v0.10.0 / roadmap item #8): passcode management ────────── function SignInSection() { // Source of truth for `has_passcode` and `passcode_set_at` is the // /api/auth/me payload (v0.10.0 added both fields). We re-read after // every mutation so the surface reflects what just landed. const [me, setMe] = useState(null) const [error, setError] = useState(null) const [mode, setMode] = useState('idle') // 'idle' | 'set' | 'change' const [draft, setDraft] = useState('') const [busy, setBusy] = useState(false) const [savedNote, setSavedNote] = useState('') useEffect(() => { getMe() .then(payload => setMe(payload.user || null)) .catch(e => setError(e.message)) }, []) async function refresh() { const payload = await getMe() setMe(payload.user || null) } async function save(e) { if (e) e.preventDefault() const pc = draft.trim() if (pc.length < 4) { setError('Passcode must be at least 4 characters.') return } setBusy(true) setError(null) setSavedNote('') try { await setPasscode(pc) setDraft('') setMode('idle') setSavedNote('Passcode saved.') await refresh() } catch (err) { // 422 carries the validation message verbatim (denylist / // length); surface it as-is so the user knows what to change. setError(err.message || 'Could not save passcode. Try a different one.') } finally { setBusy(false) setTimeout(() => setSavedNote(''), 2000) } } async function remove() { if (!confirm('Remove your passcode? You will sign in with a one-time code next time.')) { return } setBusy(true) setError(null) setSavedNote('') try { await clearPasscode() setSavedNote('Passcode removed.') await refresh() } catch (err) { setError(err.message || 'Could not remove passcode.') } finally { setBusy(false) setTimeout(() => setSavedNote(''), 2000) } } if (!me) return const hasPasscode = !!me.has_passcode return (
Passcode:{' '} {hasPasscode ? 'Set.' : 'Not set — you sign in with a one-time code each time.'}
{hasPasscode && me.passcode_set_at && (

Set on {me.passcode_set_at}.

)} {mode === 'idle' && (
{hasPasscode ? ( <> ) : ( )}
)} {(mode === 'set' || mode === 'change') && (
)} {savedNote &&

{savedNote}

} {error &&

{error}

}
) } // ── §14.5 cookie / privacy consent (v0.13.0 / roadmap item #11) ──────────── function PrivacyCookiesSection() { const [consent, setConsent] = useState(() => getConsent()) useEffect(() => { // Pull the server-side row on mount; if it has a recorded_at the // local snapshot is updated via hydrate. getCookieConsent() .then(record => { if (record.recorded_at) hydrateFromServer(record) }) .catch(() => {}) return onConsentChange(next => setConsent(next)) }, []) function reopenBanner() { // App.jsx listens for this event and bumps the forceOpen tick on // . The banner pre-selects the current choice // from the snapshot, so the user can revise rather than restart. window.dispatchEvent(new CustomEvent('rfc-app:cookie-consent-reopen')) } const summary = (() => { if (!consent.recorded_at) { return 'No choice recorded — the consent banner is being shown to you.' } if (consent.analytics && consent.other) { return 'Essential + analytics + other.' } if (consent.analytics) { return 'Essential + analytics.' } return 'Essential only.' })() return (
Current choice: {summary}
{consent.recorded_at && (

Recorded {consent.recorded_at}.

)}
Cookies policy Privacy policy
) } // ── §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({}) const pid = useProjectId() 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 ( ) }