ENGAGEMENT.md Phase 7. `user_notifications`, the in-app DeliveryChannel, the
four inbox routes, and the web surface — plus the two pieces earlier phases
assigned here that Phase 7's own acceptance line omits.
Four decisions settled by the org lead before any code:
1. `inapp` defaults to `instant` — the only channel that does. Push wakes a
device somebody is holding and email leaves the building, so both are asked
for; an inbox item is a row on a page the user chose to open. Left `off` the
channel ships dead.
2. The phase takes push's `deliver` (§2603) and the web per-channel preferences
screen (Phase 3's as-built), neither of which its own bullets mention.
3. The inbox takes `/auth/me/notifications` and `/account/notifications`; the
preferences screen moves to `…/settings`. The plain word belongs to the
content, which is what the bell opens.
4. `ctx.inbox.push` honours the user's in-app preference when `triggerId` names
a registered trigger, and writes when it does not.
Server
- `user_notifications` + `model/userNotifications/`. The dedupe UNIQUE is scoped
to the USER, narrower than the outbox's `(rule, user, channel)`: an inbox has
no channel dimension, so two rows for one event would be one item shown twice.
- `engagement/inappChannel.js` — renders by block ROLE (first heading → title,
first button → url, the rest → body) and inserts. `pushChannel.js` — a
content-free `{stream, ref}` tickle whose ref deep-links the inbox row.
- `engine.liveChannels` orders `inapp` first (`CHANNEL_ORDER`) so that ref
resolves on the first sweep. An ordering, not a dependency.
- `templates.renderInappByKey` + `resolveTemplate` extracted from `renderByKey`,
so both channels take the same fallback chain.
- `inapp.event` seed → seedVersion 2: it named `body`/`url`, which nothing
supplies. Renamed to the structural vocabulary the projection fills in.
- `utils/userNotificationsPrune.js` — nightly, READ items only, horizon in
`settings.user_notifications_retain_days` (default 90).
- `GET /auth/me/notifications`, `…/unread-count`, `POST …/:id/read`,
`POST …/read-all`. Swagger + route manifest + four component schemas.
Web
- `NotificationBell` in all three headers, polling its badge once a minute and
pausing while the tab is hidden. `PlayerInbox` at `/account/notifications`.
- The preferences screen becomes a channel matrix over
`/auth/me/notifications/channels` — a strict superset of the push-only stream
list it replaces. The two legacy endpoints are untouched, so the shipped
Android app keeps its wire shape.
- Staff get the same two screens at `/admin/notifications…`: `RequirePlayer`
keeps them out of `/account`, so without this the inbox was unreachable for
every non-player account. `lib/notificationPaths.js` is the one mapping.
Verified: 28 new server tests (5 of them against a real MariaDB, for the three
index/statement properties that are a server contract rather than a reading of
this code) + 3 client. Server suite green, client 327 green. A live rig walked
the whole path: two rules on one event produced three outbox rows and exactly
one inbox item, the tickle carried `ref: notification:2`, and the retention
sweep dropped an aged read row while keeping an equally aged unread one.
Docs: RunicGateway/docs#TBD, RunicGateway/runicgateway.com#TBD
Co-Authored-By: Claude <noreply@anthropic.com>
370 lines
15 KiB
JavaScript
370 lines
15 KiB
JavaScript
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 (
|
|
<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, 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 (
|
|
<Section title="What to notify me about">
|
|
<p className="sans dim" style={{ fontSize: '0.9rem', margin: 0 }}>
|
|
There is nothing to configure yet.
|
|
</p>
|
|
</Section>
|
|
)
|
|
}
|
|
|
|
const team = items.filter((i) => isTeamStream(i.id))
|
|
const rest = items.filter((i) => !isTeamStream(i.id))
|
|
|
|
const rows = (list) =>
|
|
list.map((item) => (
|
|
<tr key={item.id} style={{ borderTop: '1px solid var(--line-soft)' }}>
|
|
<td className="sans" style={{ padding: '10px', color: 'var(--ink)' }}>
|
|
{item.label}
|
|
{item.description && (
|
|
<span className="dim" style={{ display: 'block', fontSize: '0.8rem' }}>{item.description}</span>
|
|
)}
|
|
</td>
|
|
{channels.map((c) => (
|
|
<td key={c.id} style={{ padding: '10px' }}>
|
|
{item.channels.includes(c.id) ? (
|
|
<select
|
|
className="input"
|
|
aria-label={`${item.label} — ${c.label}`}
|
|
value={modeOf(item, c.id)}
|
|
onChange={(e) => set(item.id, c.id, e.target.value)}
|
|
style={{ fontSize: '0.86rem' }}
|
|
>
|
|
{modesFor(c).map((m) => <option key={m.value} value={m.value}>{m.label}</option>)}
|
|
</select>
|
|
) : (
|
|
// 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.
|
|
<span className="dim" style={{ fontSize: '0.86rem' }}>—</span>
|
|
)}
|
|
</td>
|
|
))}
|
|
</tr>
|
|
))
|
|
|
|
return (
|
|
<Section
|
|
title="What to notify me about"
|
|
hint="Applies to every device you have signed in on. On the site means an item in your notification inbox; push wakes the app, which then fetches the content."
|
|
>
|
|
<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' }}>Notification</th>
|
|
{channels.map((c) => (
|
|
<th key={c.id} style={{ padding: '8px 10px' }} title={c.description || undefined}>{c.label}</th>
|
|
))}
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{rows(rest)}
|
|
{team.length > 0 && (
|
|
<tr>
|
|
<td colSpan={channels.length + 1} className="sans dim" style={{ padding: '18px 10px 6px', fontSize: '0.74rem', textTransform: 'uppercase', letterSpacing: '0.06em' }}>
|
|
Teams — set site-wide here, then per team below
|
|
</td>
|
|
</tr>
|
|
)}
|
|
{rows(team)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
<div style={{ marginTop: 18 }}>
|
|
<button type="button" className="btn btn-primary btn-sq" disabled={busy || changed.length === 0} onClick={save}>
|
|
{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 { 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 <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. Email and push are off until you switch them on;
|
|
items on the site go to your <Link to={inboxPath(user)}>notification inbox</Link>,
|
|
which you can turn off here per notification.
|
|
</p>
|
|
<Channels
|
|
channels={channels}
|
|
items={items}
|
|
onSave={saveChannels}
|
|
busy={saving.channels}
|
|
msg={notes.channels}
|
|
error={notes.channelsError}
|
|
/>
|
|
<Teams
|
|
teams={teams}
|
|
onSave={saveTeams}
|
|
busy={saving.teams}
|
|
msg={notes.teams}
|
|
error={notes.teamsError}
|
|
/>
|
|
</div>
|
|
)
|
|
}
|