999c4b65ef
Implements the M3-frontend slice of the §22 multi-project track, per docs/superpowers/specs/2026-06-03-m3-frontend-design.md (design merged in #10). Completes the runtime-config cut 0.33.0 (M3-backend Plan A) began. Frontend: - DeploymentProvider boots GET /api/deployment → {name, tagline, defaultProjectId, projects}; brandTitle() neutral 'RFC' pre-fetch fallback. - /p/:projectId/* routing with generic /e/<slug> segment. ProjectLayout fetches /api/projects/:id, applies per-project theme (reset on switch), provides ProjectContext, guards the corpus (served only for the default; others get NotServedPlaceholder — decouples this slice from Plan B). - Directory at / (2+ projects) with N=1 redirect into the single project; ProjectSwitcher in deployment chrome; entry-noun by project type. - VITE_APP_NAME hard cut: removed from vite.config + index.html; the 6 brand reads now use deployment.name via context; static <title>RFC</title> + JS document.title. Internal /rfc·/proposals links → /p/<project>/e|proposals via lib/entryPaths. Backend: - GET /api/deployment returns default_project_id (the guard contract). - Server-side 308s: /rfc/<slug>, /rfc/<slug>/pr/<n>, /proposals/<n> → /p/<default>/… . nginx (testing + prod) routes /rfc/ and /proposals/ to the backend. Tests: 3 new backend redirect/deployment tests (438 pass); Vitest unit for DeploymentProvider, ProjectLayout (theme/guard/404), Directory (11 pass); clean build with no VITE_APP_NAME. Playwright e2e deferred until Tier-1 seeds a registry (see CHANGELOG 0.35.0 step 5). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
848 lines
27 KiB
React
848 lines
27 KiB
React
// /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 (
|
||
<div className="settings-page">
|
||
<header className="settings-header">
|
||
<h1>Notification settings</h1>
|
||
<p className="settings-sub">
|
||
Which signals reach you, how, and when. The rules live in §15;
|
||
this page is where you tune them.
|
||
</p>
|
||
</header>
|
||
|
||
<EmailPreferencesSection />
|
||
<DigestCadenceSection />
|
||
<QuietHoursSection />
|
||
<WatchesSection />
|
||
<MutesSection viewer={viewer} />
|
||
<SignInSection />
|
||
<DevicesSection />
|
||
<PrivacyCookiesSection />
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// ── 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 (
|
||
<SectionShell
|
||
title="Trusted devices"
|
||
subtitle="Devices where you've checked “Trust this device for 30 days.” Sign-in is automatic on these devices until the trust expires or you revoke it."
|
||
>
|
||
{devices === null && <p className="settings-note">Loading…</p>}
|
||
{devices !== null && devices.length === 0 && (
|
||
<p className="device-empty">
|
||
No trusted devices. Sign in and check “Trust this device for 30 days”
|
||
to add the device you're on now.
|
||
</p>
|
||
)}
|
||
{devices !== null && devices.length > 0 && (
|
||
<>
|
||
<ul className="device-list">
|
||
{devices.map(d => (
|
||
<li key={d.id} className="device-list-item">
|
||
<div className="device-meta">
|
||
<div className="device-ua">{d.user_agent || 'Unknown device'}</div>
|
||
<div className="device-stamps">
|
||
Trusted {formatStamp(d.created_at)} · last seen {formatStamp(d.last_seen_at)} · expires {formatStamp(d.expires_at)}
|
||
</div>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
onClick={() => onRevoke(d.id)}
|
||
disabled={busy}
|
||
title="Revoke trust on this device"
|
||
>
|
||
Revoke
|
||
</button>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
<button
|
||
type="button"
|
||
className="device-revoke-all"
|
||
onClick={onRevokeAll}
|
||
disabled={busy}
|
||
>
|
||
Revoke all devices
|
||
</button>
|
||
</>
|
||
)}
|
||
{error && <p className="settings-note warning">{error}</p>}
|
||
</SectionShell>
|
||
)
|
||
}
|
||
|
||
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 <SectionShell title="Sign-in" subtitle={error || 'Loading…'} />
|
||
|
||
const hasPasscode = !!me.has_passcode
|
||
|
||
return (
|
||
<SectionShell
|
||
title="Sign-in"
|
||
subtitle="How you sign in. A passcode lets you skip the one-time-code email; the one-time-code path is always available as a fallback (and as the recovery path if you forget your passcode)."
|
||
>
|
||
<div className="settings-row">
|
||
<span className="settings-note">
|
||
<strong>Passcode:</strong>{' '}
|
||
{hasPasscode ? 'Set.' : 'Not set — you sign in with a one-time code each time.'}
|
||
</span>
|
||
</div>
|
||
{hasPasscode && me.passcode_set_at && (
|
||
<p className="settings-note muted">Set on {me.passcode_set_at}.</p>
|
||
)}
|
||
|
||
{mode === 'idle' && (
|
||
<div className="settings-row">
|
||
{hasPasscode ? (
|
||
<>
|
||
<button
|
||
className="btn-primary"
|
||
onClick={() => { setMode('change'); setDraft(''); setError(null) }}
|
||
disabled={busy}
|
||
>
|
||
Change passcode
|
||
</button>
|
||
<button
|
||
className="btn-link-muted"
|
||
onClick={remove}
|
||
disabled={busy}
|
||
>
|
||
Remove passcode
|
||
</button>
|
||
</>
|
||
) : (
|
||
<button
|
||
className="btn-primary"
|
||
onClick={() => { setMode('set'); setDraft(''); setError(null) }}
|
||
disabled={busy}
|
||
>
|
||
Set passcode
|
||
</button>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{(mode === 'set' || mode === 'change') && (
|
||
<form className="settings-row" onSubmit={save}>
|
||
<label>
|
||
{mode === 'change' ? 'New passcode' : 'Passcode'}
|
||
<input
|
||
type="password"
|
||
autoComplete="new-password"
|
||
value={draft}
|
||
onChange={e => setDraft(e.target.value)}
|
||
placeholder="4–20 characters"
|
||
minLength={4}
|
||
maxLength={20}
|
||
required
|
||
disabled={busy}
|
||
/>
|
||
</label>
|
||
<button className="btn-primary" type="submit" disabled={busy || draft.trim().length < 4}>
|
||
{busy ? 'Saving…' : 'Save'}
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className="btn-link-muted"
|
||
onClick={() => { setMode('idle'); setDraft(''); setError(null) }}
|
||
disabled={busy}
|
||
>
|
||
Cancel
|
||
</button>
|
||
</form>
|
||
)}
|
||
|
||
{savedNote && <p className="settings-note">{savedNote}</p>}
|
||
{error && <p className="settings-note warning">{error}</p>}
|
||
</SectionShell>
|
||
)
|
||
}
|
||
|
||
// ── §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
|
||
// <CookieConsentBanner>. 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 (
|
||
<SectionShell
|
||
title="Privacy & cookies"
|
||
subtitle="What categories of cookies you've allowed. Essential cookies are always on; analytics and other categories are opt-in."
|
||
>
|
||
<div className="settings-row">
|
||
<span className="settings-note"><strong>Current choice:</strong> {summary}</span>
|
||
</div>
|
||
{consent.recorded_at && (
|
||
<p className="settings-note muted">Recorded {consent.recorded_at}.</p>
|
||
)}
|
||
<div className="settings-row">
|
||
<button className="btn-primary" onClick={reopenBanner}>
|
||
Change
|
||
</button>
|
||
<Link to="/cookies" className="btn-link-muted">Cookies policy</Link>
|
||
<Link to="/privacy" className="btn-link-muted">Privacy policy</Link>
|
||
</div>
|
||
</SectionShell>
|
||
)
|
||
}
|
||
|
||
// ── §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 <SectionShell title="Email" subtitle={error || 'Loading…'} />
|
||
|
||
return (
|
||
<SectionShell title="Email" subtitle="Categories you want delivered as email. The inbox always carries everything; email is opt-in by category.">
|
||
<Toggle
|
||
label="Personal direct"
|
||
description="You are the named subject — proposals merged, decisions, claims, named asks. Default on."
|
||
checked={!!prefs.email_personal_direct}
|
||
onChange={v => update('email_personal_direct', v)}
|
||
disabled={saving}
|
||
/>
|
||
<Toggle
|
||
label="Watched structural"
|
||
description="Decisions on RFCs you watch — merges, declines, graduation, ownership changes. Default off."
|
||
checked={!!prefs.email_watched_structural}
|
||
onChange={v => update('email_watched_structural', v)}
|
||
disabled={saving}
|
||
/>
|
||
<Toggle
|
||
label="Admin actionable"
|
||
description="Decisions only an admin can act on. Defaults on for admins/owners; ignored for contributors."
|
||
checked={!!prefs.email_admin_actionable}
|
||
onChange={v => update('email_admin_actionable', v)}
|
||
disabled={saving}
|
||
/>
|
||
<Toggle
|
||
label="Watched churn (per-commit, per-message)"
|
||
description={CHURN_REFUSAL}
|
||
checked={false}
|
||
disabled
|
||
title={CHURN_REFUSAL}
|
||
/>
|
||
{!!prefs.email_opt_out_all && (
|
||
<p className="settings-note warning">
|
||
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.
|
||
</p>
|
||
)}
|
||
<p className="settings-note">{savedNote}</p>
|
||
</SectionShell>
|
||
)
|
||
}
|
||
|
||
// ── §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 (
|
||
<SectionShell
|
||
title="Digest cadence"
|
||
subtitle="The §15.5 digest gathers churn-category activity into a single periodic email. Independent of the category toggles above."
|
||
>
|
||
<div className="settings-row">
|
||
<select
|
||
value={cadence ?? 'weekly'}
|
||
onChange={e => update(e.target.value)}
|
||
disabled={saving || cadence == null}
|
||
>
|
||
<option value="off">Off — never send a digest</option>
|
||
<option value="weekly">Weekly</option>
|
||
<option value="daily">Daily</option>
|
||
</select>
|
||
</div>
|
||
</SectionShell>
|
||
)
|
||
}
|
||
|
||
// ── §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 <SectionShell title="Quiet hours" subtitle="Loading…" />
|
||
|
||
const isSet = !!(data.start && data.end && data.timezone)
|
||
|
||
return (
|
||
<SectionShell
|
||
title="Quiet hours"
|
||
subtitle="Email holds during this window; inbox rows still land. On window-end, the §15.4 bundle email releases everything above the threshold."
|
||
>
|
||
<div className="settings-row quiet-hours-row">
|
||
<label>
|
||
Start
|
||
<input
|
||
type="time"
|
||
value={draft.start}
|
||
onChange={e => setDraft(d => ({ ...d, start: e.target.value }))}
|
||
/>
|
||
</label>
|
||
<label>
|
||
End
|
||
<input
|
||
type="time"
|
||
value={draft.end}
|
||
onChange={e => setDraft(d => ({ ...d, end: e.target.value }))}
|
||
/>
|
||
</label>
|
||
<label>
|
||
Timezone
|
||
<select
|
||
value={draft.timezone}
|
||
onChange={e => setDraft(d => ({ ...d, timezone: e.target.value }))}
|
||
>
|
||
{timezones.map(tz => <option key={tz} value={tz}>{tz}</option>)}
|
||
</select>
|
||
</label>
|
||
</div>
|
||
<div className="settings-row">
|
||
<button className="btn-primary" disabled={saving} onClick={save}>
|
||
{isSet ? 'Update quiet hours' : 'Set quiet hours'}
|
||
</button>
|
||
{isSet && (
|
||
<button className="btn-link-muted" disabled={saving} onClick={clear}>
|
||
Clear
|
||
</button>
|
||
)}
|
||
</div>
|
||
{error && <p className="settings-note warning">{error}</p>}
|
||
</SectionShell>
|
||
)
|
||
}
|
||
|
||
// ── §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 <SectionShell title="Watches" subtitle="Loading…" />
|
||
|
||
return (
|
||
<SectionShell
|
||
title="Watches"
|
||
subtitle="What you receive structural-category notifications about. Auto-set when you participate, decays after 90 days of silence. An explicit choice here exempts the row from the auto-decay."
|
||
>
|
||
{watches.length === 0 && (
|
||
<p className="muted">No watches yet. Open an RFC, propose, or join a thread — auto-watch will set one for you.</p>
|
||
)}
|
||
{watches.length > 0 && (
|
||
<table className="settings-table">
|
||
<thead>
|
||
<tr>
|
||
<th>RFC</th>
|
||
<th>State</th>
|
||
<th>Set by</th>
|
||
<th>Last participation</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{watches.map(w => (
|
||
<tr key={w.rfc_slug}>
|
||
<td>
|
||
<Link to={entryPath(pid, w.rfc_slug)}>{w.rfc_title || w.rfc_slug}</Link>
|
||
</td>
|
||
<td>
|
||
<select
|
||
value={w.state}
|
||
onChange={e => update(w.rfc_slug, e.target.value)}
|
||
disabled={!!updating[w.rfc_slug]}
|
||
>
|
||
<option value="watching">Watching</option>
|
||
<option value="following">Following</option>
|
||
<option value="muted">Muted</option>
|
||
</select>
|
||
</td>
|
||
<td>
|
||
<span className={`set-by set-by-${w.set_by}`}>
|
||
{w.set_by === 'explicit' ? 'You' : 'Auto'}
|
||
</span>
|
||
</td>
|
||
<td className="muted">
|
||
{w.last_participation_at || '—'}
|
||
</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
)}
|
||
</SectionShell>
|
||
)
|
||
}
|
||
|
||
// ── §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 (
|
||
<SectionShell
|
||
title="Muted users"
|
||
subtitle="Notifications from these users won't reach your inbox. (Mute does not gate visibility — you can still read what they post.) Adding a mute here is the catch-all path; the intended path is to mute from an inbox row's actor avatar."
|
||
>
|
||
{viewer.role === 'owner' || viewer.role === 'admin' ? (
|
||
<p className="muted">
|
||
Owners and admins cannot mute notifications — the role requires
|
||
receiving signals from everyone. (§15.8)
|
||
</p>
|
||
) : (
|
||
<MuteTypeahead onMuted={refresh} />
|
||
)}
|
||
{mutes && mutes.length === 0 && (
|
||
<p className="muted">No active mutes.</p>
|
||
)}
|
||
{mutes && mutes.length > 0 && (
|
||
<ul className="mutes-list">
|
||
{mutes.map(m => (
|
||
<li key={m.muted_user_id} className="mutes-row">
|
||
<span className="mute-handle">@{m.gitea_login}</span>
|
||
<span className="mute-name muted">{m.display_name}</span>
|
||
<span className="mute-when muted">since {m.muted_at}</span>
|
||
<button className="btn-link-muted" onClick={() => remove(m.muted_user_id)}>
|
||
Unmute
|
||
</button>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
)}
|
||
{error && <p className="settings-note warning">{error}</p>}
|
||
</SectionShell>
|
||
)
|
||
}
|
||
|
||
// 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 (
|
||
<div className="mute-typeahead">
|
||
<input
|
||
type="text"
|
||
placeholder="Mute a user by login or name…"
|
||
value={q}
|
||
onChange={e => { setQ(e.target.value); setOpen(true) }}
|
||
onFocus={() => setOpen(true)}
|
||
onBlur={() => setTimeout(() => setOpen(false), 150)}
|
||
disabled={busy}
|
||
/>
|
||
{open && results.length > 0 && (
|
||
<ul className="mute-typeahead-results">
|
||
{results.map(r => (
|
||
<li key={r.id}>
|
||
<button onClick={() => mute(r)} disabled={busy}>
|
||
<span className="mute-handle">@{r.gitea_login}</span>
|
||
<span className="muted">{r.display_name}</span>
|
||
{(r.role === 'owner' || r.role === 'admin') && (
|
||
<span className="muted">{r.role}</span>
|
||
)}
|
||
</button>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
)}
|
||
{hint && <p className="settings-note warning">{hint}</p>}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// ── Small layout primitives ────────────────────────────────────────────────
|
||
|
||
function SectionShell({ title, subtitle, children }) {
|
||
return (
|
||
<section className="settings-section">
|
||
<header>
|
||
<h2>{title}</h2>
|
||
{subtitle && <p className="settings-sub">{subtitle}</p>}
|
||
</header>
|
||
<div className="settings-section-body">{children}</div>
|
||
</section>
|
||
)
|
||
}
|
||
|
||
function Toggle({ label, description, checked, onChange, disabled, title }) {
|
||
return (
|
||
<label className={`toggle-row ${disabled ? 'disabled' : ''}`} title={title}>
|
||
<input
|
||
type="checkbox"
|
||
checked={!!checked}
|
||
onChange={e => onChange?.(e.target.checked)}
|
||
disabled={disabled}
|
||
/>
|
||
<span className="toggle-text">
|
||
<span className="toggle-label">{label}</span>
|
||
{description && <span className="toggle-desc">{description}</span>}
|
||
</span>
|
||
</label>
|
||
)
|
||
}
|