feat(teams): the web surface — a notifications screen that did not exist

This is phase 6's first finding, and it changed the phase's shape.

TEAMS.md §6.3 says the per-Team mute list is surfaced "under the existing
notification settings screen". There was no such screen. `/auth/me/notifications/*`
was built for the Android app in M7 and had ZERO web consumers — a browser could
not see the stream catalog or its own subscriptions at all. That is tolerable
while push is the only sink, because push needs the app anyway. It is not
tolerable for email, whose entire argument is the web-only user who runs neither
the app nor Discord, so the sink and the screen to configure it had to ship
together.

`/account/notifications` carries all three: what to be told about, which Teams,
and whether any of it reaches a mailbox — in the order a user actually reasons
about them.

The mute toggle goes in a THIRD module-declared slot, above the roster, because
muting is an action ON the guild page while the feed and forum are content IN it.
It renders nothing for a viewer with no preference available, which is a privacy
property rather than a tidiness one: whether a preference EXISTS for a Team
answers "is this person in it", and the guild page is public.

`/unsubscribe/:token` is public and POSTs on mount — the link the user clicked was
a GET, and a GET that mutated would be triggered by every mail-client link scanner.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-08-18 14:35:08 -05:00
parent 2a56cbf22a
commit b458c1f46f
8 changed files with 563 additions and 0 deletions

View File

@@ -0,0 +1,268 @@
import { useCallback, useEffect, useState } from 'react'
import { Loading, ErrorState } from '../../components/PageState.jsx'
import { api } from '../../api/client.js'
// The account's notification settings (TEAMS.md §6.3/§6.4, phase 6).
//
// **This screen did not exist before phase 6, and that was the phase's first
// finding.** §6.3 says the per-Team mute list is "surfaced under the existing
// notification settings screen" — there was no such screen on the web. The stream
// catalog and the per-stream subscriptions have been built and shipped since M7,
// with the Android app as their only consumer; a browser could not see them at
// all. That is tolerable for push, which needs the app anyway. It is not tolerable
// for email, whose whole reason for existing (§6.4) is the web-only user who runs
// neither the app nor Discord — so the sink and the screen to configure it had to
// arrive together.
//
// Three blocks, in the order a user actually reasons about them: what kinds of
// thing to be told about, then which Teams, then whether any of it should reach a
// mailbox.
const EMAIL_MODES = [
{ value: 'off', label: 'No email' },
{ value: 'digest', label: 'Daily digest' },
{ value: 'immediate', label: 'Every post' },
]
// Streams whose scoping lives in this page's second block rather than in the
// first. Shown as a group so a user does not toggle `team.forum.post` off site-
// wide when what they meant was "not this one guild".
const isTeamStream = (id) => String(id).startsWith('team.')
function Section({ title, hint, children }) {
return (
<section style={{ borderTop: '1px solid var(--line-soft)', paddingTop: 26, marginTop: 26 }}>
<h2 className="display" style={{ marginTop: 0, fontSize: '1.15rem', color: 'var(--head)' }}>{title}</h2>
{hint && <p className="sans dim" style={{ margin: '0 0 14px', fontSize: '0.86rem' }}>{hint}</p>}
{children}
</section>
)
}
function Note({ msg, error }) {
if (!msg && !error) return null
return (
<p className="sans" style={{ margin: '10px 0 0', color: error ? '#d98b84' : '#7fd0a4', fontSize: '0.85rem' }}>
{error || msg}
</p>
)
}
// ── What to be told about ──────────────────────────────────────────────────
function Streams({ streams, subscribed, onSave, busy, msg, error }) {
const [set, setSet] = useState(() => new Set(subscribed))
useEffect(() => { setSet(new Set(subscribed)) }, [subscribed])
const toggle = (id) => {
const next = new Set(set)
if (next.has(id)) next.delete(id)
else next.add(id)
setSet(next)
}
const team = streams.filter((s) => isTeamStream(s.id))
const rest = streams.filter((s) => !isTeamStream(s.id))
const row = (s) => (
<label key={s.id} className="sans" style={{ display: 'flex', gap: 10, alignItems: 'flex-start', fontSize: '0.92rem' }}>
<input type="checkbox" checked={set.has(s.id)} onChange={() => toggle(s.id)} style={{ marginTop: 3 }} />
<span>
<span style={{ color: 'var(--ink)' }}>{s.label}</span>
{s.description && <span className="dim" style={{ display: 'block', fontSize: '0.82rem' }}>{s.description}</span>}
</span>
</label>
)
return (
<Section
title="What to notify me about"
hint="Applies to every device you have signed in on. Notifications are delivered to the app; the website itself does not pop anything up."
>
<div style={{ display: 'grid', gap: 12 }}>{rest.map(row)}</div>
{team.length > 0 && (
<>
<h3 className="sans dim" style={{ fontSize: '0.74rem', textTransform: 'uppercase', letterSpacing: '0.06em', margin: '20px 0 10px' }}>
Teams
</h3>
<div style={{ display: 'grid', gap: 12 }}>{team.map(row)}</div>
</>
)}
<div style={{ marginTop: 18 }}>
<button type="button" className="btn btn-primary btn-sq" disabled={busy} onClick={() => onSave([...set])}>
{busy ? 'Saving…' : 'Save'}
</button>
</div>
<Note msg={msg} error={error} />
</Section>
)
}
// ── Which Teams, and whether by email ──────────────────────────────────────
function Teams({ teams, onSave, busy, msg, error }) {
const [rows, setRows] = useState(teams)
useEffect(() => { setRows(teams) }, [teams])
const patch = (teamId, change) =>
setRows((rs) => rs.map((r) => (r.teamId === teamId ? { ...r, ...change } : r)))
if (rows.length === 0) {
return (
<Section title="Teams">
<p className="sans dim" style={{ fontSize: '0.9rem', margin: 0 }}>
You are not in a team, and nobody has given you access to a team forum. There is nothing to
configure here yet.
</p>
</Section>
)
}
return (
<Section
title="Teams"
hint="Muting a team silences all four team notifications for it, without changing anything for your other teams. Email is off until you turn it on."
>
<div style={{ overflowX: 'auto' }}>
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead>
<tr className="sans dim" style={{ textAlign: 'left', fontSize: '0.72rem', textTransform: 'uppercase', letterSpacing: '0.06em' }}>
<th style={{ padding: '8px 10px' }}>Team</th>
<th style={{ padding: '8px 10px' }}>Notifications</th>
<th style={{ padding: '8px 10px' }}>Email</th>
</tr>
</thead>
<tbody>
{rows.map((t) => (
<tr key={t.teamId} style={{ borderTop: '1px solid var(--line-soft)' }}>
<td className="sans" style={{ padding: '10px', color: 'var(--ink)' }}>
{t.name}
{/* An archived Team is still listed when a preference exists for
it, so a mute does not silently vanish when a guild disbands
and reappear if it re-forms under the same name. */}
{t.archived && <span className="dim" style={{ fontSize: '0.78rem' }}> · archived</span>}
</td>
<td style={{ padding: '10px' }}>
<label className="sans" style={{ display: 'flex', gap: 8, alignItems: 'center', fontSize: '0.88rem' }}>
<input type="checkbox" checked={!t.muted} onChange={() => patch(t.teamId, { muted: !t.muted })} />
<span className="dim">{t.muted ? 'Muted' : 'On'}</span>
</label>
</td>
<td style={{ padding: '10px' }}>
<select
className="input"
value={t.emailMode}
onChange={(e) => patch(t.teamId, { emailMode: e.target.value })}
style={{ fontSize: '0.88rem' }}
>
{EMAIL_MODES.map((m) => <option key={m.value} value={m.value}>{m.label}</option>)}
</select>
</td>
</tr>
))}
</tbody>
</table>
</div>
<div style={{ marginTop: 18 }}>
<button type="button" className="btn btn-primary btn-sq" disabled={busy} onClick={() => onSave(rows)}>
{busy ? 'Saving…' : 'Save'}
</button>
</div>
<Note msg={msg} error={error} />
</Section>
)
}
// ── Page ───────────────────────────────────────────────────────────────────
export default function PlayerNotifications() {
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [streams, setStreams] = useState([])
const [subscribed, setSubscribed] = useState([])
const [teams, setTeams] = useState([])
const [saving, setSaving] = useState({ streams: false, teams: false })
const [notes, setNotes] = useState({ streams: '', teams: '', streamsError: '', teamsError: '' })
const load = useCallback(async () => {
setLoading(true)
try {
// Three reads in parallel: the catalog is boot-fixed, the subscriptions and
// the Team list are this user's. None depends on another.
const [cat, subs, prefs] = await Promise.all([
api.notificationStreams(),
api.notificationSubscriptions(),
api.teamNotificationPrefs(),
])
setStreams(cat.streams || [])
setSubscribed(subs.streams || [])
setTeams(prefs.teams || [])
setError('')
} catch {
setError('Could not load your notification settings.')
} finally {
setLoading(false)
}
}, [])
useEffect(() => { load() }, [load])
const saveStreams = useCallback(async (ids) => {
setSaving((s) => ({ ...s, streams: true }))
setNotes((n) => ({ ...n, streams: '', streamsError: '' }))
try {
const { streams: stored } = await api.setNotificationSubscriptions(ids)
setSubscribed(stored || [])
setNotes((n) => ({ ...n, streams: 'Saved.' }))
} catch {
setNotes((n) => ({ ...n, streamsError: 'Could not save that.' }))
} finally {
setSaving((s) => ({ ...s, streams: false }))
}
}, [])
const saveTeams = useCallback(async (rows) => {
setSaving((s) => ({ ...s, teams: true }))
setNotes((n) => ({ ...n, teams: '', teamsError: '' }))
try {
// The whole set, every time, and the array is sent even when empty — the
// endpoint requires the field (docs/android/PLAN.md §11).
const { teams: stored } = await api.setTeamNotificationPrefs(
rows.map((t) => ({ teamId: t.teamId, muted: t.muted, emailMode: t.emailMode })),
)
setTeams(stored || [])
setNotes((n) => ({ ...n, teams: 'Saved.' }))
} catch {
setNotes((n) => ({ ...n, teamsError: 'Could not save that.' }))
} finally {
setSaving((s) => ({ ...s, teams: false }))
}
}, [])
if (loading) return <Loading />
if (error) return <ErrorState message={error} />
return (
<div>
<p className="sans" style={{ margin: 0, color: 'var(--muted)', fontSize: '0.9rem' }}>
Choose what you are told about, and how. Nothing here is on by default except team
notifications to the app, which you can mute per team below.
</p>
<Streams
streams={streams}
subscribed={subscribed}
onSave={saveStreams}
busy={saving.streams}
msg={notes.streams}
error={notes.streamsError}
/>
<Teams
teams={teams}
onSave={saveTeams}
busy={saving.teams}
msg={notes.teams}
error={notes.teamsError}
/>
</div>
)
}