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:
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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user