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>
)
}

View File

@@ -35,6 +35,7 @@ function Icon({ children, size = 16 }) {
}
const IconGear = () => <Icon><circle cx="12" cy="12" r="3" /><path d="M12 2v3M12 19v3M2 12h3M19 12h3M4.9 4.9l2.1 2.1M17 17l2.1 2.1M19.1 4.9L17 7M7 17l-2.1 2.1" /></Icon>
const IconShield = () => <Icon><path d="M12 3l7 3v5c0 5-3.5 8-7 10-3.5-2-7-5-7-10V6z" /><path d="M9 12l2 2 4-4" /></Icon>
const IconBell = () => <Icon><path d="M18 8a6 6 0 10-12 0c0 7-3 9-3 9h18s-3-2-3-9" /><path d="M13.7 21a2 2 0 01-3.4 0" /></Icon>
// Exported because Admin -> Navigation edits this list. It stays declared here;
// the editor may only relabel, reorder and hide what it finds (§7). No CORE row
@@ -47,6 +48,7 @@ const IconShield = () => <Icon><path d="M12 3l7 3v5c0 5-3.5 8-7 10-3.5-2-7-5-7-1
// with `order: 0`.
export const NAV = [
{ to: '/account/appeals', label: 'Appeals', icon: IconShield },
{ to: '/account/notifications', label: 'Notifications', icon: IconBell },
{ to: '/account', label: 'Account', end: true, icon: IconGear },
]
@@ -56,6 +58,7 @@ export const NAV = [
const TITLES = {
'/account': 'Account',
'/account/appeals': 'Appeals',
'/account/notifications': 'Notifications',
}
function moduleTitle(baseNav, pathname) {

View File

@@ -0,0 +1,69 @@
import { useEffect, useRef, useState } from 'react'
import { Link, useParams } from 'react-router-dom'
import PublicLayout from '../../components/PublicLayout.jsx'
import PageHeader from '../../components/PageHeader.jsx'
import { api } from '../../api/client.js'
// The landing page for the unsubscribe link in a Team notification email
// (TEAMS.md §6.4).
//
// **Public, and it must be**: the person reading it is in their mail client, not
// signed in, and an unsubscribe that first demands a login is one most people do
// not complete. The token in the path is what stands in for the session.
//
// **The page POSTs; the link the user clicked was a GET.** A GET must not mutate —
// mail clients and security scanners follow links in messages, and one that did
// would silently mute Teams nobody asked to leave. So the link lands here, this
// runs one POST, and the API route that shares the path answers GET with a
// redirect to exactly this page.
//
// **It says the same thing whatever the token was.** A page that distinguished a
// valid token from a forged one would be an oracle for which (user, Team) pairs
// exist, on a surface with no session behind it. The server always answers 200 and
// this always says the same sentence.
export default function Unsubscribe() {
const { token } = useParams()
const [state, setState] = useState('working')
// React 18 StrictMode mounts an effect twice in development. The POST is
// idempotent (it sets a boolean), so a second call is harmless — but it is
// still a second request for no reason, and the guard keeps the network panel
// honest for anyone debugging this page.
const fired = useRef(false)
useEffect(() => {
if (fired.current) return
fired.current = true
api.unsubscribeTeam(token)
.then(() => setState('done'))
// A network failure is the ONE case worth distinguishing, because it is the
// one where trying again helps. A rejected token is not: the server does not
// tell us, deliberately.
.catch(() => setState('failed'))
}, [token])
return (
<PublicLayout section="website" shell="narrow">
<PageHeader eyebrow="Notifications" title="Unsubscribe" />
{state === 'working' && <p className="sans dim">One moment</p>}
{state === 'done' && (
<>
<p className="sans" style={{ color: 'var(--ink)' }}>
You will not receive further notification emails about this team.
</p>
<p className="sans dim" style={{ fontSize: '0.9rem' }}>
This muted the team rather than switching off your account&rsquo;s email, so your other
teams are unaffected. You can turn it back on any time under{' '}
<Link to="/account/notifications">notification settings</Link>.
</p>
</>
)}
{state === 'failed' && (
<p className="sans" style={{ color: 'var(--ink)' }}>
We could not reach the site to record that. Please try the link again, or change the
setting yourself under <Link to="/account/notifications">notification settings</Link>.
</p>
)}
</PublicLayout>
)
}