feat(engagement): the in-app channel, core and web (engagement Phase 7)
All checks were successful
PR Checks / client-build (pull_request) Successful in 37s
PR Checks / server-tests (pull_request) Successful in 3m27s
PR Checks / bot-tests (pull_request) Successful in 8m36s

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>
This commit is contained in:
2026-08-31 02:07:10 -05:00
parent 5168446c53
commit 24a3cd85b3
34 changed files with 3153 additions and 112 deletions

View File

@@ -2,6 +2,7 @@ import { useEffect, useMemo, useState } from 'react'
import { NavLink, Outlet, useNavigate, useLocation } from 'react-router-dom'
import MoonDot from '../../components/MoonDot.jsx'
import BrandLogo from '../../components/BrandLogo.jsx'
import NotificationBell from '../../components/NotificationBell.jsx'
import { useAuth } from '../../contexts/AuthContext.jsx'
import { useSite } from '../../contexts/SiteContext.jsx'
import { applyNavOverrides } from '../../lib/navOverrides.js'
@@ -43,6 +44,7 @@ const IconKey = () => <Icon><circle cx="8" cy="12" r="4" /><path d="M12 12h9M18
const IconBot = () => <Icon><rect x="4" y="8" width="16" height="11" rx="2" /><path d="M12 8V4M8 13h.01M16 13h.01M9 17h6" /></Icon>
const IconPulse = () => <Icon><path d="M3 12h3l2 6 4-14 2 8h7" /></Icon>
const IconUser = () => <Icon><circle cx="12" cy="8" r="4" /><path d="M4 21a8 8 0 0 1 16 0" /></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>
const IconNav = () => <Icon><path d="M4 6h16M4 12h16M4 18h10" /><circle cx="18" cy="18" r="2.5" /></Icon>
const IconPalette = () => <Icon><path d="M12 3a9 9 0 1 0 0 18 2 2 0 0 0 1.6-3.2 2 2 0 0 1 1.6-3.2H18a3 3 0 0 0 3-3 9 9 0 0 0-9-8.6z" /><circle cx="7.5" cy="11.5" r="1" /><circle cx="10.5" cy="7.5" r="1" /><circle cx="15" cy="8.5" r="1" /></Icon>
const IconModules = () => <Icon><path d="M12 3l8 4.5-8 4.5-8-4.5z" /><path d="M4 12l8 4.5 8-4.5" /><path d="M4 16.5L12 21l8-4.5" /></Icon>
@@ -129,6 +131,11 @@ export const NAV = [
},
{
items: [
// No `end`: `allowedPathsFor` turns an `end` row into an EXACT match, so
// marking this one exact would leave `/admin/notifications/settings`
// outside the allowlist and bounce a staff member off their own
// preferences screen. The row covering its sub-routes is the point.
{ to: '/admin/notifications', label: 'Notifications', icon: IconBell },
{ to: '/admin/account', label: 'Account', icon: IconUser },
],
},
@@ -177,6 +184,8 @@ const TITLES = {
'/admin/users': 'Users',
'/admin/invites': 'Invites',
'/admin/account': 'Account Security',
'/admin/notifications': 'Notifications',
'/admin/notifications/settings': 'Notification settings',
'/admin/engagement/rules': 'Engagement Rules',
'/admin/engagement/audiences': 'Engagement Audiences',
'/admin/engagement/templates': 'Message Templates',
@@ -443,6 +452,11 @@ export default function AdminLayout() {
{title}
</h1>
<div className="sans" style={{ display: 'flex', alignItems: 'center', gap: 14, fontSize: '0.84rem', color: 'var(--muted)' }}>
{/* Staff have an inbox like anyone else — `/auth/me/notifications`
is role-agnostic — and `RequirePlayer` keeps them out of the
player portal, so without this the one place they spend their
time is the one place the bell is missing. */}
<NotificationBell />
<a href="/" target="_blank" rel="noreferrer" style={{ color: 'var(--accent)', textDecoration: 'none' }}>
View site
</a>

View File

@@ -0,0 +1,264 @@
import { useCallback, useEffect, useState } from 'react'
import { Link, useNavigate } from 'react-router-dom'
import { Loading, ErrorState } from '../../components/PageState.jsx'
import { api } from '../../api/client.js'
import { useAuth } from '../../contexts/AuthContext.jsx'
import { notificationSettingsPath, inboxPath } from '../../lib/notificationPaths.js'
// The in-app inbox (ENGAGEMENT.md Phase 7), at `/account/notifications`.
//
// **It took that path from the preferences screen, which moved to
// `/account/notifications/settings`.** The two are different kinds of thing —
// one is content addressed to this person, the other is how they would like to
// be reached — and the word "notifications" belongs to the first: it is what a
// person means when they say it, and what the bell in the header opens. The
// server's routes make the same split at the same place.
//
// Everything a row can carry is TEXT. `body` is stored as the text part of the
// in-app template's blocks and rendered with `white-space: pre-line`, never as
// markup; `url` is site-relative by the time it is stored, checked against the
// same character class `pageUrlTemplate` uses. So there is no sanitizing to do
// here — there is nothing on this screen that could be markup.
const PAGE = 30
function ago(iso) {
const then = new Date(iso).getTime()
if (!Number.isFinite(then)) return ''
const secs = Math.max(0, Math.round((Date.now() - then) / 1000))
if (secs < 60) return 'just now'
if (secs < 3600) return `${Math.floor(secs / 60)} min ago`
if (secs < 86400) return `${Math.floor(secs / 3600)} h ago`
if (secs < 30 * 86400) return `${Math.floor(secs / 86400)} d ago`
return new Date(iso).toLocaleDateString()
}
function Item({ item, onOpen, onMark }) {
const body = (
<>
<div style={{ display: 'flex', alignItems: 'baseline', gap: 10, flexWrap: 'wrap' }}>
<strong
className="sans"
style={{
fontSize: '0.95rem',
color: item.read ? 'var(--muted)' : 'var(--head)',
fontWeight: item.read ? 500 : 700,
}}
>
{item.title}
</strong>
<span className="sans dim" style={{ fontSize: '0.76rem' }}>{ago(item.createdAt)}</span>
</div>
{item.body && (
<p
className="sans dim"
style={{ margin: '6px 0 0', fontSize: '0.86rem', whiteSpace: 'pre-line' }}
>
{item.body}
</p>
)}
</>
)
return (
<li
style={{
display: 'flex',
alignItems: 'flex-start',
gap: 12,
padding: '14px 16px',
borderRadius: 'var(--radius-card)',
border: '1px solid var(--line-soft)',
// The one visual difference between read and unread, plus the weight
// above. A dot alone is easy to miss on a long list.
background: item.read ? 'transparent' : 'var(--panel)',
}}
>
<div style={{ flex: 1, minWidth: 0 }}>
{item.url ? (
<button
type="button"
onClick={() => onOpen(item)}
style={{
display: 'block',
width: '100%',
textAlign: 'left',
background: 'none',
border: 'none',
padding: 0,
cursor: 'pointer',
}}
>
{body}
</button>
) : (
body
)}
</div>
{!item.read && (
<button
type="button"
onClick={() => onMark(item)}
className="sans"
style={{
background: 'none',
border: 'none',
padding: 0,
cursor: 'pointer',
color: 'var(--accent)',
fontSize: '0.78rem',
whiteSpace: 'nowrap',
}}
>
Mark read
</button>
)}
</li>
)
}
export default function PlayerInbox() {
const [items, setItems] = useState([])
const [unread, setUnread] = useState(0)
const [hasMore, setHasMore] = useState(false)
const [unreadOnly, setUnreadOnly] = useState(false)
const [loading, setLoading] = useState(true)
const [busy, setBusy] = useState(false)
const [error, setError] = useState('')
const navigate = useNavigate()
const { user } = useAuth()
const load = useCallback(async (only) => {
setLoading(true)
setError('')
try {
const res = await api.notifications({ limit: PAGE, unread: only })
setItems(res.items || [])
setHasMore(!!res.hasMore)
setUnread(res.unread || 0)
} catch (err) {
setError(err.message || 'Could not load your notifications')
} finally {
setLoading(false)
}
}, [])
useEffect(() => { load(unreadOnly) }, [load, unreadOnly])
// The cursor is the last item's id, not a page number: the list gains rows at
// the top while it is being read, and an offset under those conditions repeats
// or skips items.
const more = async () => {
if (!items.length) return
setBusy(true)
try {
const res = await api.notifications({
limit: PAGE,
before: items[items.length - 1].id,
unread: unreadOnly,
})
setItems((list) => [...list, ...(res.items || [])])
setHasMore(!!res.hasMore)
} catch (err) {
setError(err.message || 'Could not load more')
} finally {
setBusy(false)
}
}
const mark = async (item) => {
try {
const res = await api.markNotificationRead(item.id)
setUnread(res.unread ?? Math.max(0, unread - 1))
// Filtered to unread, a marked item leaves the list; unfiltered it stays
// and goes quiet. Either way the list matches what it says it is showing.
setItems((list) =>
unreadOnly
? list.filter((i) => i.id !== item.id)
: list.map((i) => (i.id === item.id ? { ...i, read: true } : i)),
)
} catch (err) {
setError(err.message || 'Could not mark it read')
}
}
const open = async (item) => {
if (!item.read) await mark(item)
if (item.url) navigate(item.url)
}
const markAll = async () => {
setBusy(true)
try {
await api.markAllNotificationsRead()
setUnread(0)
setItems((list) => (unreadOnly ? [] : list.map((i) => ({ ...i, read: true }))))
} catch (err) {
setError(err.message || 'Could not mark them read')
} finally {
setBusy(false)
}
}
if (loading) return <Loading label="Loading your notifications…" />
if (error && !items.length) return <ErrorState message={error} />
return (
<div>
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 12,
flexWrap: 'wrap',
marginBottom: 18,
}}
>
<p className="sans dim" style={{ margin: 0, fontSize: '0.88rem' }}>
{unread > 0 ? `${unread} unread` : 'Everything is read.'}{' '}
<Link to={notificationSettingsPath(user)} className="dim">
Notification settings
</Link>
</p>
<div style={{ display: 'flex', gap: 8 }}>
<button
type="button"
className="pill"
onClick={() => setUnreadOnly((v) => !v)}
style={unreadOnly ? { background: 'var(--accent)', color: 'var(--bg-deep)', borderColor: 'var(--accent)' } : {}}
>
{unreadOnly ? 'Showing unread' : 'Show unread only'}
</button>
<button type="button" className="pill" onClick={markAll} disabled={busy || unread === 0}>
Mark all read
</button>
</div>
</div>
{error && (
<p className="sans" style={{ margin: '0 0 12px', color: '#d98b84', fontSize: '0.85rem' }}>{error}</p>
)}
{items.length === 0 ? (
<p className="sans dim" style={{ fontSize: '0.9rem' }}>
{unreadOnly
? 'Nothing unread.'
: 'Nothing here yet. Anything the shard or your guilds want to tell you will show up on this page.'}
</p>
) : (
<ul style={{ listStyle: 'none', margin: 0, padding: 0, display: 'flex', flexDirection: 'column', gap: 10 }}>
{items.map((item) => (
<Item key={item.id} item={item} onOpen={open} onMark={mark} />
))}
</ul>
)}
{hasMore && (
<button type="button" className="pill" onClick={more} disabled={busy} style={{ marginTop: 16 }}>
{busy ? 'Loading…' : 'Load older'}
</button>
)}
</div>
)
}

View File

@@ -1,8 +1,15 @@
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 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
@@ -18,6 +25,14 @@ import { api } from '../../api/client.js'
// 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' },
@@ -48,48 +63,125 @@ function Note({ msg, error }) {
)
}
// ── What to be told about ──────────────────────────────────────────────────
// ── 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 Streams({ streams, subscribed, onSave, busy, msg, error }) {
const [set, setSet] = useState(() => new Set(subscribed))
useEffect(() => { setSet(new Set(subscribed)) }, [subscribed])
function Channels({ channels, items, onSave, busy, msg, error }) {
const [edits, setEdits] = useState({})
useEffect(() => setEdits({}), [items])
const toggle = (id) => {
const next = new Set(set)
if (next.has(id)) next.delete(id)
else next.add(id)
setSet(next)
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 = streams.filter((s) => isTeamStream(s.id))
const rest = streams.filter((s) => !isTeamStream(s.id))
const team = items.filter((i) => isTeamStream(i.id))
const rest = items.filter((i) => !isTeamStream(i.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>
)
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. Notifications are delivered to the app; the website itself does not pop anything up."
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={{ 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={{ 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} onClick={() => onSave([...set])}>
<button type="button" className="btn btn-primary btn-sq" disabled={busy || changed.length === 0} onClick={save}>
{busy ? 'Saving…' : 'Save'}
</button>
</div>
@@ -176,27 +268,29 @@ function Teams({ teams, onSave, busy, msg, error }) {
// ── Page ───────────────────────────────────────────────────────────────────
export default function PlayerNotifications() {
const { user } = useAuth()
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [streams, setStreams] = useState([])
const [subscribed, setSubscribed] = useState([])
const [channels, setChannels] = useState([])
const [items, setItems] = useState([])
const [teams, setTeams] = useState([])
const [saving, setSaving] = useState({ streams: false, teams: false })
const [notes, setNotes] = useState({ streams: '', teams: '', streamsError: '', teamsError: '' })
const [saving, setSaving] = useState({ channels: false, teams: false })
const [notes, setNotes] = useState({ channels: '', teams: '', channelsError: '', 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(),
// 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(),
])
setStreams(cat.streams || [])
setSubscribed(subs.streams || [])
setTeams(prefs.teams || [])
setChannels(prefs.channels || [])
setItems(prefs.items || [])
setTeams(teamPrefs.teams || [])
setError('')
} catch {
setError('Could not load your notification settings.')
@@ -207,17 +301,23 @@ export default function PlayerNotifications() {
useEffect(() => { load() }, [load])
const saveStreams = useCallback(async (ids) => {
setSaving((s) => ({ ...s, streams: true }))
setNotes((n) => ({ ...n, streams: '', streamsError: '' }))
const saveChannels = useCallback(async (prefs) => {
if (prefs.length === 0) return
setSaving((s) => ({ ...s, channels: true }))
setNotes((n) => ({ ...n, channels: '', channelsError: '' }))
try {
const { streams: stored } = await api.setNotificationSubscriptions(ids)
setSubscribed(stored || [])
setNotes((n) => ({ ...n, streams: 'Saved.' }))
// 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, streamsError: 'Could not save that.' }))
setNotes((n) => ({ ...n, channelsError: 'Could not save that.' }))
} finally {
setSaving((s) => ({ ...s, streams: false }))
setSaving((s) => ({ ...s, channels: false }))
}
}, [])
@@ -245,16 +345,17 @@ export default function PlayerNotifications() {
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.
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>
<Streams
streams={streams}
subscribed={subscribed}
onSave={saveStreams}
busy={saving.streams}
msg={notes.streams}
error={notes.streamsError}
<Channels
channels={channels}
items={items}
onSave={saveChannels}
busy={saving.channels}
msg={notes.channels}
error={notes.channelsError}
/>
<Teams
teams={teams}

View File

@@ -2,6 +2,7 @@ import { useMemo } from 'react'
import { NavLink, Navigate, Outlet, useNavigate, useLocation } from 'react-router-dom'
import MoonDot from '../../components/MoonDot.jsx'
import BrandLogo from '../../components/BrandLogo.jsx'
import NotificationBell from '../../components/NotificationBell.jsx'
import { useAuth } from '../../contexts/AuthContext.jsx'
import { useSite } from '../../contexts/SiteContext.jsx'
import { applyNavOverrides } from '../../lib/navOverrides.js'
@@ -36,6 +37,9 @@ 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>
// The settings row's own icon: a bell would make the two rows read as the same
// destination twice, which is exactly the confusion the split was meant to end.
const IconBellGear = () => <Icon><path d="M18 8a6 6 0 10-12 0c0 7-3 9-3 9h11" /><circle cx="18" cy="18" r="3" /><path d="M18 14v1M18 21v1M14 18h1M21 18h1" /></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
@@ -48,7 +52,8 @@ const IconBell = () => <Icon><path d="M18 8a6 6 0 10-12 0c0 7-3 9-3 9h18s-3-2-3-
// with `order: 0`.
export const NAV = [
{ to: '/account/appeals', label: 'Appeals', icon: IconShield },
{ to: '/account/notifications', label: 'Notifications', icon: IconBell },
{ to: '/account/notifications', label: 'Notifications', end: true, icon: IconBell },
{ to: '/account/notifications/settings', label: 'Notification settings', icon: IconBellGear },
{ to: '/account', label: 'Account', end: true, icon: IconGear },
]
@@ -59,6 +64,7 @@ const TITLES = {
'/account': 'Account',
'/account/appeals': 'Appeals',
'/account/notifications': 'Notifications',
'/account/notifications/settings': 'Notification settings',
}
function moduleTitle(baseNav, pathname) {
@@ -186,9 +192,12 @@ export default function PlayerPortalLayout() {
<h1 className="display" style={{ margin: 0, fontSize: '1.5rem', color: 'var(--head)' }}>
{title}
</h1>
<a href="/" style={{ color: 'var(--accent)', textDecoration: 'none', fontSize: '0.84rem', fontFamily: 'var(--sans)' }}>
Site
</a>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<NotificationBell />
<a href="/" style={{ color: 'var(--accent)', textDecoration: 'none', fontSize: '0.84rem', fontFamily: 'var(--sans)' }}>
Site
</a>
</div>
</header>
<div style={{ flex: 1, padding: '30px 32px 60px', maxWidth: 900, width: '100%' }}>

View File

@@ -54,14 +54,14 @@ export default function Unsubscribe() {
<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>.
<Link to="/account/notifications/settings">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>.
setting yourself under <Link to="/account/notifications/settings">notification settings</Link>.
</p>
)}
</PublicLayout>