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 = (
<>
{item.title}
{ago(item.createdAt)}
{item.body && (
{item.body}
)}
>
)
return (
{item.url ? (
) : (
body
)}
{!item.read && (
)}
)
}
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
if (error && !items.length) return
return (