import { useCallback, useEffect, useState } from 'react' import { Link } from 'react-router-dom' import { Loading, ErrorState } from '../../components/PageState.jsx' import { api } from '../../api/client.js' import { useAuth } from '../../contexts/AuthContext.jsx' import { inboxPath } from '../../lib/notificationPaths.js' // The account's notification settings (TEAMS.md §6.3/§6.4, phase 6; the // per-channel matrix is ENGAGEMENT.md Phase 3, surfaced in Phase 7). // // **It moved to `/account/notifications/settings` in Phase 7**, because the // inbox took the plain path. See `PlayerInbox.jsx`. // // **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. // The three modes a per-channel preference can take, labelled for a person. The // set a given channel actually offers comes from its `supportsDigest` flag. const MODES = [ { value: 'off', label: 'Off' }, { value: 'instant', label: 'As it happens' }, { value: 'digest', label: 'Daily digest' }, ] 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, and how ───────────────────────────────────────── // // **This replaced the push-only checkbox list, and it is a strict superset of // it.** `GET /auth/me/notifications/channels` returns every subscribable id — // every push stream and every event trigger, one namespace (§7.2) — with the // EFFECTIVE mode on each channel that applies. A trigger with nothing // registered to push it simply has no push cell; core does not have to explain // which kind of id a row is, and neither does a reader. // // The old whole-set endpoints are untouched and are now this surface's push // projection: the shipped Android app keeps its wire shape, and a `push` entry // written here is mirrored back into `notification_subscriptions` server-side. // // The update is SPARSE: only the cells that changed are sent. That is what lets // this screen manage three channels without a whole-set PUT that could clobber // a preference a newer client set. function Channels({ channels, items, onSave, busy, msg, error }) { const [edits, setEdits] = useState({}) useEffect(() => setEdits({}), [items]) const key = (id, channel) => `${id}|${channel}` const modeOf = (item, channel) => edits[key(item.id, channel)] ?? item.modes[channel] const set = (id, channel, mode) => setEdits((e) => ({ ...e, [key(id, channel)]: mode })) // A channel that supports digest offers three modes; one that does not offers // two. Read off the registry rather than hardcoded, so a channel added later // shows the right options without touching this file. const modesFor = (c) => (c.supportsDigest ? MODES : MODES.filter((m) => m.value !== 'digest')) const changed = Object.entries(edits).filter(([k, mode]) => { const [id, channel] = k.split('|') const item = items.find((i) => i.id === id) return item && item.modes[channel] !== mode }) const save = () => onSave( changed.map(([k, mode]) => { const [id, channel] = k.split('|') return { id, channel, mode } }), ) if (items.length === 0) { return (

There is nothing to configure yet.

) } const team = items.filter((i) => isTeamStream(i.id)) const rest = items.filter((i) => !isTeamStream(i.id)) const rows = (list) => list.map((item) => ( {item.label} {item.description && ( {item.description} )} {channels.map((c) => ( {item.channels.includes(c.id) ? ( ) : ( // Not "off" — a dash. Nothing is registered to push this id, so // there is no preference to hold, and an `off` select would invite // somebody to switch on a channel that has no sender behind it. )} ))} )) return (
{channels.map((c) => ( ))} {rows(rest)} {team.length > 0 && ( )} {rows(team)}
Notification{c.label}
Teams — set site-wide here, then per team below
) } // ── 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 { user } = useAuth() const [loading, setLoading] = useState(true) const [error, setError] = useState('') const [channels, setChannels] = useState([]) const [items, setItems] = useState([]) const [teams, setTeams] = useState([]) const [saving, setSaving] = useState({ channels: false, teams: false }) const [notes, setNotes] = useState({ channels: '', teams: '', channelsError: '', teamsError: '' }) const load = useCallback(async () => { setLoading(true) try { // Two reads in parallel, where there used to be three: the per-channel // surface already carries the catalog and this user's effective modes, so // the streams+subscriptions pair it replaced is one request fewer as well // as one concept fewer. const [prefs, teamPrefs] = await Promise.all([ api.notificationChannelPrefs(), api.teamNotificationPrefs(), ]) setChannels(prefs.channels || []) setItems(prefs.items || []) setTeams(teamPrefs.teams || []) setError('') } catch { setError('Could not load your notification settings.') } finally { setLoading(false) } }, []) useEffect(() => { load() }, [load]) const saveChannels = useCallback(async (prefs) => { if (prefs.length === 0) return setSaving((s) => ({ ...s, channels: true })) setNotes((n) => ({ ...n, channels: '', channelsError: '' })) try { // The endpoint echoes the FULL stored state back, not just what was sent — // so an entry it dropped (an unknown id, a channel that does not apply, a // mode that channel will not take) is visible here as a cell that did not // move, rather than as a screen that claims a save it did not make. const stored = await api.setNotificationChannelPrefs(prefs) setChannels(stored.channels || []) setItems(stored.items || []) setNotes((n) => ({ ...n, channels: 'Saved.' })) } catch { setNotes((n) => ({ ...n, channelsError: 'Could not save that.' })) } finally { setSaving((s) => ({ ...s, channels: 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. Email and push are off until you switch them on; items on the site go to your notification inbox, which you can turn off here per notification.

) }