feat(engagement): the in-app channel, core and web (engagement Phase 7)
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:
@@ -64,6 +64,7 @@ import AcceptInvite from './routes/player/AcceptInvite.jsx'
|
||||
import PlayerPortalLayout, { PlayerIndex } from './routes/player/PlayerPortalLayout.jsx'
|
||||
import PlayerAccount from './routes/player/PlayerAccount.jsx'
|
||||
import PlayerNotifications from './routes/player/PlayerNotifications.jsx'
|
||||
import PlayerInbox from './routes/player/PlayerInbox.jsx'
|
||||
import Unsubscribe from './routes/player/Unsubscribe.jsx'
|
||||
import PlayerAppeals from './routes/player/PlayerAppeals.jsx'
|
||||
|
||||
@@ -209,6 +210,13 @@ export default function App() {
|
||||
<Route path="sends" element={<EngagementSendLog />} />
|
||||
</Route>
|
||||
<Route path="account" element={<AccountAdmin />} />
|
||||
{/* Staff have an inbox and channel preferences like anyone else —
|
||||
`/auth/me/notifications` is behind requireAuth only — but
|
||||
`RequirePlayer` sends them out of the player portal, so the two
|
||||
screens are mounted here as well. Same components, same API,
|
||||
two paths; `lib/notificationPaths.js` is the one mapping. */}
|
||||
<Route path="notifications" element={<PlayerInbox />} />
|
||||
<Route path="notifications/settings" element={<PlayerNotifications />} />
|
||||
{/* Installed modules' admin pages, at /admin/<id>/…, already inside
|
||||
RequireAuth + AdminLayout. A module cannot supply its own auth
|
||||
wrapper — only an optional { roles }, which core applies as the
|
||||
@@ -252,7 +260,14 @@ export default function App() {
|
||||
<Route path="/player" element={<PlayerIndex />} />
|
||||
<Route path="/account" element={<PlayerAccount />} />
|
||||
<Route path="/account/appeals" element={<PlayerAppeals />} />
|
||||
<Route path="/account/notifications" element={<PlayerNotifications />} />
|
||||
{/* The inbox took `/account/notifications` in engagement Phase 7
|
||||
and the preferences screen moved under it. Content and
|
||||
settings are different kinds of thing, and the plain word
|
||||
belongs to the one a person means when they say it — which is
|
||||
also what the bell in the header opens. The server's routes
|
||||
split at the same place. */}
|
||||
<Route path="/account/notifications" element={<PlayerInbox />} />
|
||||
<Route path="/account/notifications/settings" element={<PlayerNotifications />} />
|
||||
{/* Installed modules' player-portal pages, at /player/<id>/…. This
|
||||
group's own routes are absolute (its layout route has no path),
|
||||
so the prefix is written here rather than inherited — the one
|
||||
|
||||
@@ -230,6 +230,25 @@ export const api = {
|
||||
// field, so clearing the last subscription must not become an absent key.
|
||||
setNotificationSubscriptions: (streams) =>
|
||||
req('/auth/me/notifications/subscriptions', { method: 'PUT', body: { streams } }),
|
||||
// Per-channel preferences (ENGAGEMENT.md Phase 3). A SPARSE update: only the
|
||||
// (id, channel) pairs sent are written, so a screen managing one channel need
|
||||
// not know what the others hold. Shipped with no surface at all until Phase 7.
|
||||
notificationChannelPrefs: () => req('/auth/me/notifications/channels'),
|
||||
setNotificationChannelPrefs: (prefs) =>
|
||||
req('/auth/me/notifications/channels', { method: 'PUT', body: { prefs } }),
|
||||
// The in-app inbox (ENGAGEMENT.md Phase 7). `before` is a keyset cursor — the
|
||||
// id of the last item on the previous page — not an offset: the list gains
|
||||
// rows at the top while it is being read.
|
||||
notifications: ({ limit, before, unread } = {}) => {
|
||||
const qs = new URLSearchParams()
|
||||
if (limit) qs.set('limit', String(limit))
|
||||
if (before) qs.set('before', String(before))
|
||||
if (unread) qs.set('unread', 'true')
|
||||
return req(`/auth/me/notifications${withQs(qs.toString())}`)
|
||||
},
|
||||
notificationsUnreadCount: () => req('/auth/me/notifications/unread-count'),
|
||||
markNotificationRead: (id) => req(`/auth/me/notifications/${id}/read`, { method: 'POST' }),
|
||||
markAllNotificationsRead: () => req('/auth/me/notifications/read-all', { method: 'POST' }),
|
||||
teamNotificationPrefs: () => req('/auth/me/notifications/teams'),
|
||||
setTeamNotificationPrefs: (teams) =>
|
||||
req('/auth/me/notifications/teams', { method: 'PUT', body: { teams } }),
|
||||
|
||||
353
client/src/components/NotificationBell.jsx
Normal file
353
client/src/components/NotificationBell.jsx
Normal file
@@ -0,0 +1,353 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { Link, useLocation, useNavigate } from 'react-router-dom'
|
||||
import { useAuth } from '../contexts/AuthContext.jsx'
|
||||
import { api } from '../api/client.js'
|
||||
import { inboxPath } from '../lib/notificationPaths.js'
|
||||
|
||||
// The in-app inbox's header surface (ENGAGEMENT.md Phase 7): a bell with an
|
||||
// unread badge, and a panel with the most recent items.
|
||||
//
|
||||
// **The badge is polled, not pushed**, and the reason is that there is nothing
|
||||
// to push over. The site's two SSE streams are the shard's; neither is
|
||||
// per-user, and adding a third authenticated stream to carry an integer would
|
||||
// mean one open connection per signed-in tab for the rest of the deployment's
|
||||
// life. A minute-granular badge on a page somebody is already looking at is the
|
||||
// same answer for a fraction of that. The poll pauses while the tab is hidden —
|
||||
// a background tab has nobody to show a badge to — and refreshes the moment it
|
||||
// comes back, which is also the moment it would be most wrong.
|
||||
//
|
||||
// **The panel shows a handful and links out.** Paging belongs on the page; a
|
||||
// dropdown that scrolls is a list in the wrong place.
|
||||
//
|
||||
// Dismissal follows `NavDropdown`'s contract exactly — Escape closes and
|
||||
// returns focus, an outside `mousedown` closes, navigating closes — because
|
||||
// this sits beside it in the same header and two menus that dismiss differently
|
||||
// is a bug nobody files.
|
||||
|
||||
const POLL_MS = 60_000
|
||||
const PANEL_ITEMS = 6
|
||||
|
||||
function BellIcon({ size = 17 }) {
|
||||
return (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
focusable="false"
|
||||
>
|
||||
<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" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
// "3m", "4h", "6d" — a relative stamp, because the only question a reader has
|
||||
// about an inbox item's time is how fresh it is.
|
||||
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 'now'
|
||||
if (secs < 3600) return `${Math.floor(secs / 60)}m`
|
||||
if (secs < 86400) return `${Math.floor(secs / 3600)}h`
|
||||
return `${Math.floor(secs / 86400)}d`
|
||||
}
|
||||
|
||||
export default function NotificationBell() {
|
||||
const { user } = useAuth()
|
||||
const [unread, setUnread] = useState(0)
|
||||
const [items, setItems] = useState([])
|
||||
const [open, setOpen] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const wrapRef = useRef(null)
|
||||
const triggerRef = useRef(null)
|
||||
const location = useLocation()
|
||||
const navigate = useNavigate()
|
||||
|
||||
// Every read here swallows its failure. A count that could not be fetched is
|
||||
// a bell with no badge, which is what a bell with nothing to report looks
|
||||
// like anyway — the alternative is an error banner in the site header for a
|
||||
// number nobody asked for.
|
||||
const refreshCount = useCallback(async () => {
|
||||
if (!user) return
|
||||
try {
|
||||
const res = await api.notificationsUnreadCount()
|
||||
setUnread(res.unread || 0)
|
||||
} catch {
|
||||
/* leave the badge as it was */
|
||||
}
|
||||
}, [user])
|
||||
|
||||
useEffect(() => {
|
||||
if (!user) return undefined
|
||||
refreshCount()
|
||||
const timer = setInterval(() => {
|
||||
if (document.visibilityState === 'visible') refreshCount()
|
||||
}, POLL_MS)
|
||||
const onVisible = () => {
|
||||
if (document.visibilityState === 'visible') refreshCount()
|
||||
}
|
||||
document.addEventListener('visibilitychange', onVisible)
|
||||
return () => {
|
||||
clearInterval(timer)
|
||||
document.removeEventListener('visibilitychange', onVisible)
|
||||
}
|
||||
}, [user, refreshCount])
|
||||
|
||||
// The panel's items are fetched when it opens, never kept warm: a list nobody
|
||||
// has asked to see is a request per minute for content nobody is reading.
|
||||
const load = useCallback(async () => {
|
||||
setError('')
|
||||
try {
|
||||
const res = await api.notifications({ limit: PANEL_ITEMS })
|
||||
setItems(res.items || [])
|
||||
setUnread(res.unread || 0)
|
||||
} catch (err) {
|
||||
setError(err.message || 'Could not load notifications')
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => setOpen(false), [location.pathname])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return undefined
|
||||
const onKey = (e) => {
|
||||
if (e.key !== 'Escape') return
|
||||
setOpen(false)
|
||||
triggerRef.current?.focus()
|
||||
}
|
||||
const onOutside = (e) => {
|
||||
if (!wrapRef.current?.contains(e.target)) setOpen(false)
|
||||
}
|
||||
document.addEventListener('keydown', onKey)
|
||||
document.addEventListener('mousedown', onOutside)
|
||||
return () => {
|
||||
document.removeEventListener('keydown', onKey)
|
||||
document.removeEventListener('mousedown', onOutside)
|
||||
}
|
||||
}, [open])
|
||||
|
||||
if (!user) return null
|
||||
|
||||
const toggle = () => {
|
||||
const next = !open
|
||||
setOpen(next)
|
||||
if (next) load()
|
||||
}
|
||||
|
||||
// Opening an item marks it read and then goes where it points. The mark is
|
||||
// awaited rather than fired off, so the badge the next screen renders is the
|
||||
// one this click produced; a failed mark still navigates, because the item's
|
||||
// link is the thing the user asked for.
|
||||
const openItem = async (item) => {
|
||||
setOpen(false)
|
||||
if (!item.read) {
|
||||
try {
|
||||
const res = await api.markNotificationRead(item.id)
|
||||
setUnread(res.unread ?? Math.max(0, unread - 1))
|
||||
} catch {
|
||||
/* the link still works */
|
||||
}
|
||||
}
|
||||
navigate(item.url || inboxPath(user))
|
||||
}
|
||||
|
||||
const markAll = async () => {
|
||||
try {
|
||||
await api.markAllNotificationsRead()
|
||||
setUnread(0)
|
||||
setItems((list) => list.map((i) => ({ ...i, read: true })))
|
||||
} catch (err) {
|
||||
setError(err.message || 'Could not mark them read')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={wrapRef} style={{ position: 'relative' }}>
|
||||
<button
|
||||
ref={triggerRef}
|
||||
type="button"
|
||||
className="pill"
|
||||
aria-haspopup="true"
|
||||
aria-expanded={open}
|
||||
// The count is in the label, not only in the badge: a screen reader gets
|
||||
// "Notifications, 3 unread" rather than "Notifications" and a number it
|
||||
// has no way to relate to it.
|
||||
aria-label={unread ? `Notifications, ${unread} unread` : 'Notifications'}
|
||||
onClick={toggle}
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
position: 'relative',
|
||||
...(open ? { background: 'var(--accent)', color: 'var(--bg-deep)', borderColor: 'var(--accent)' } : {}),
|
||||
}}
|
||||
>
|
||||
<BellIcon />
|
||||
{unread > 0 && (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="sans"
|
||||
style={{
|
||||
minWidth: 17,
|
||||
height: 17,
|
||||
padding: '0 4px',
|
||||
borderRadius: 9,
|
||||
background: 'var(--accent)',
|
||||
color: 'var(--bg-deep)',
|
||||
fontSize: '0.68rem',
|
||||
fontWeight: 700,
|
||||
lineHeight: '17px',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
{unread > 99 ? '99+' : unread}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div
|
||||
role="menu"
|
||||
aria-label="Notifications"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 'calc(100% + 6px)',
|
||||
right: 0,
|
||||
width: 320,
|
||||
maxWidth: 'calc(100vw - 24px)',
|
||||
padding: 6,
|
||||
borderRadius: 'var(--radius-card)',
|
||||
border: '1px solid var(--line)',
|
||||
background: 'var(--panel-flat)',
|
||||
boxShadow: 'var(--shadow-card)',
|
||||
zIndex: 40,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: 10,
|
||||
padding: '4px 8px 8px',
|
||||
}}
|
||||
>
|
||||
<strong className="sans" style={{ fontSize: '0.82rem', color: 'var(--head)' }}>
|
||||
Notifications
|
||||
</strong>
|
||||
{unread > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={markAll}
|
||||
className="sans"
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
padding: 0,
|
||||
cursor: 'pointer',
|
||||
color: 'var(--accent)',
|
||||
fontSize: '0.78rem',
|
||||
}}
|
||||
>
|
||||
Mark all read
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className="sans" style={{ margin: '0 8px 8px', fontSize: '0.8rem', color: '#d98b84' }}>
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{!error && items.length === 0 && (
|
||||
<p className="sans dim" style={{ margin: '0 8px 10px', fontSize: '0.82rem' }}>
|
||||
Nothing here yet.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{items.map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
role="menuitem"
|
||||
onClick={() => openItem(item)}
|
||||
className="sans"
|
||||
style={{
|
||||
display: 'block',
|
||||
width: '100%',
|
||||
textAlign: 'left',
|
||||
padding: '8px 10px',
|
||||
borderRadius: 'var(--radius-input)',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
background: item.read ? 'transparent' : 'var(--panel)',
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
display: 'block',
|
||||
fontSize: '0.85rem',
|
||||
color: item.read ? 'var(--muted)' : 'var(--head)',
|
||||
fontWeight: item.read ? 400 : 600,
|
||||
}}
|
||||
>
|
||||
{item.title}
|
||||
</span>
|
||||
{item.body && (
|
||||
<span
|
||||
className="dim"
|
||||
style={{
|
||||
fontSize: '0.78rem',
|
||||
marginTop: 2,
|
||||
// The body is stored and rendered as TEXT, never as markup —
|
||||
// `white-space: pre-line` is what keeps the template's own
|
||||
// line breaks without ever interpreting anything.
|
||||
whiteSpace: 'pre-line',
|
||||
// Two lines, then an ellipsis. `-webkit-box` is the only
|
||||
// clamp with real support; it is also why there is no second
|
||||
// `display: block` above it.
|
||||
display: '-webkit-box',
|
||||
overflow: 'hidden',
|
||||
WebkitLineClamp: 2,
|
||||
WebkitBoxOrient: 'vertical',
|
||||
}}
|
||||
>
|
||||
{item.body}
|
||||
</span>
|
||||
)}
|
||||
<span className="dim" style={{ display: 'block', fontSize: '0.72rem', marginTop: 3 }}>
|
||||
{ago(item.createdAt)}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
|
||||
<Link
|
||||
to={inboxPath(user)}
|
||||
role="menuitem"
|
||||
onClick={() => setOpen(false)}
|
||||
className="sans"
|
||||
style={{
|
||||
display: 'block',
|
||||
marginTop: 4,
|
||||
padding: '8px 10px',
|
||||
borderTop: '1px solid var(--line-soft)',
|
||||
fontSize: '0.8rem',
|
||||
color: 'var(--accent)',
|
||||
textDecoration: 'none',
|
||||
}}
|
||||
>
|
||||
See all notifications →
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import BrandLogo from './BrandLogo.jsx'
|
||||
import { useAuth } from '../contexts/AuthContext.jsx'
|
||||
import { useSite } from '../contexts/SiteContext.jsx'
|
||||
import NavDropdown from './NavDropdown.jsx'
|
||||
import NotificationBell from './NotificationBell.jsx'
|
||||
import { buildPublicNav, pruneNav } from '../lib/navOverrides.js'
|
||||
import { parseJsonSetting } from '../lib/settingsJson.js'
|
||||
import { withModuleNav } from '../modules/nav.js'
|
||||
@@ -107,6 +108,10 @@ export default function SiteHeader() {
|
||||
</NavLink>
|
||||
),
|
||||
)}
|
||||
{/* Renders nothing when signed out, so the header keeps its shape for
|
||||
a visitor. It is here rather than only in the portal because an
|
||||
inbox item is worth seeing from the page you are already on. */}
|
||||
{!loading && <NotificationBell />}
|
||||
{!loading && (
|
||||
<NavLink
|
||||
to={account.to}
|
||||
|
||||
21
client/src/lib/notificationPaths.js
Normal file
21
client/src/lib/notificationPaths.js
Normal file
@@ -0,0 +1,21 @@
|
||||
// Where a given account's notification screens live.
|
||||
//
|
||||
// **Staff and players reach the same two screens at different paths, and that is
|
||||
// this file's whole reason to exist.** `/auth/me/notifications` is role-agnostic
|
||||
// — behind `requireAuth` only, like every other `/auth/me` route — but the WEB
|
||||
// has two logged-in shells: `RequirePlayer` sends anyone who is not a player to
|
||||
// the admin area, where staff manage their own account under `/admin/account`.
|
||||
// So a bell that always pointed at `/account/notifications` would, for every
|
||||
// staff member, point at a page that redirects.
|
||||
//
|
||||
// Discovered in the Phase 7 rig: signed in as an admin, the inbox was simply
|
||||
// unreachable on the web. Two routes, one pair of components, one mapping here.
|
||||
|
||||
export const isStaff = (user) => !!(user && user.role && user.role !== 'player')
|
||||
|
||||
/** The inbox — what the bell opens. */
|
||||
export const inboxPath = (user) => (isStaff(user) ? '/admin/notifications' : '/account/notifications')
|
||||
|
||||
/** The per-channel preferences screen. */
|
||||
export const notificationSettingsPath = (user) =>
|
||||
isStaff(user) ? '/admin/notifications/settings' : '/account/notifications/settings'
|
||||
@@ -96,7 +96,7 @@ export default function TeamNotifyToggle({ externalId, moduleId }) {
|
||||
{/* The one link off this control, because "mute" is a blunt answer to a
|
||||
question the account screen asks properly — which streams, and whether
|
||||
email is on at all. */}
|
||||
<Link to="/account/notifications" className="dim">All notification settings</Link>
|
||||
<Link to="/account/notifications/settings" className="dim">All notification settings</Link>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
264
client/src/routes/player/PlayerInbox.jsx
Normal file
264
client/src/routes/player/PlayerInbox.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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%' }}>
|
||||
|
||||
@@ -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’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>
|
||||
|
||||
42
client/test/notificationPaths.test.js
Normal file
42
client/test/notificationPaths.test.js
Normal file
@@ -0,0 +1,42 @@
|
||||
// ── Where each account's notification screens live ─────────────────────────
|
||||
//
|
||||
// ENGAGEMENT.md Phase 7. Three assertions for a nine-line module, because the
|
||||
// defect they pin was invisible to every other check: `/auth/me/notifications`
|
||||
// is role-agnostic (behind `requireAuth` only, like the rest of `/auth/me`), so
|
||||
// the server, the tests and the API all agreed a staff member had an inbox —
|
||||
// and on the web they could not reach it, because `RequirePlayer` sends anyone
|
||||
// who is not a player back out of `/account`. The bell pointed at a redirect.
|
||||
//
|
||||
// Found in the Phase 7 rig, signed in as an admin. What stops it coming back is
|
||||
// this file plus the two admin routes it maps onto.
|
||||
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
|
||||
import { isStaff, inboxPath, notificationSettingsPath } from '../src/lib/notificationPaths.js'
|
||||
|
||||
test('a player gets the portal paths', () => {
|
||||
const user = { role: 'player' }
|
||||
assert.equal(isStaff(user), false)
|
||||
assert.equal(inboxPath(user), '/account/notifications')
|
||||
assert.equal(notificationSettingsPath(user), '/account/notifications/settings')
|
||||
})
|
||||
|
||||
test('every non-player role gets the admin paths, not just admin', () => {
|
||||
for (const role of ['admin', 'editor', 'moderator']) {
|
||||
const user = { role }
|
||||
assert.equal(isStaff(user), true, role)
|
||||
assert.equal(inboxPath(user), '/admin/notifications', role)
|
||||
assert.equal(notificationSettingsPath(user), '/admin/notifications/settings', role)
|
||||
}
|
||||
})
|
||||
|
||||
// The bell renders nothing when signed out, so these are never asked for a null
|
||||
// user in practice — but a default that guessed "staff" would send a signed-out
|
||||
// visitor at the admin area the moment that changed.
|
||||
test('no user, or a user with no role, falls back to the player paths', () => {
|
||||
for (const user of [null, undefined, {}, { role: '' }]) {
|
||||
assert.equal(isStaff(user), false)
|
||||
assert.equal(inboxPath(user), '/account/notifications')
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user