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 (

    {unread > 0 ? `${unread} unread` : 'Everything is read.'}{' '} Notification settings

    {error && (

    {error}

    )} {items.length === 0 ? (

    {unreadOnly ? 'Nothing unread.' : 'Nothing here yet. Anything the shard or your guilds want to tell you will show up on this page.'}

    ) : (
      {items.map((item) => ( ))}
    )} {hasMore && ( )}
    ) }