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 (

{title}

{hint &&

{hint}

} {children}
) } function Note({ msg, error }) { if (!msg && !error) return null return (

{error || msg}

) } // ── 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) => ( ) return (
{rest.map(row)}
{team.length > 0 && ( <>

Teams

{team.map(row)}
)}
) } // ── 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 (

You are not in a team, and nobody has given you access to a team forum. There is nothing to configure here yet.

) } return (
{rows.map((t) => ( ))}
Team Notifications Email
{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 && · archived}
) } // ── 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 if (error) return return (

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.

) }