// §15.2 — the inbox panel. // // One mental space across every RFC the contributor has any relationship // to. Filter chips (Unread only, RFC: …, Category: personal-direct / // structural / churn) are AND-combined. The bundle toggle collapses // rows by (RFC, event_kind) per §15.2's per-bundle markable surface. // // The header badge in App.jsx subscribes to the same SSE stream and so // stays in lockstep with the inbox per §15.3. import { useEffect, useMemo, useState } from 'react' import { Link } from 'react-router-dom' import { listNotifications, markNotificationRead, markNotificationsReadByFilter, } from '../api.js' const CATEGORIES = [ { value: '', label: 'All categories' }, { value: 'personal-direct', label: 'Personal' }, { value: 'structural', label: 'Structural' }, { value: 'churn', label: 'Churn' }, ] export default function Inbox({ onClose, lastChangeTick }) { const [items, setItems] = useState([]) const [unreadCount, setUnreadCount] = useState(0) const [filters, setFilters] = useState({ unread: false, rfcSlug: '', category: '', bundled: false, }) const [loading, setLoading] = useState(true) useEffect(() => { setLoading(true) listNotifications({ unread: filters.unread, rfcSlug: filters.rfcSlug || undefined, category: filters.category || undefined, bundled: filters.bundled, }) .then(r => { setItems(r.items || []) setUnreadCount(r.unread_count || 0) }) .finally(() => setLoading(false)) }, [filters, lastChangeTick]) const rfcOptions = useMemo(() => { const seen = new Map() for (const it of items) { if (it.rfc_slug && !seen.has(it.rfc_slug)) { seen.set(it.rfc_slug, it.rfc_title || it.rfc_slug) } } return Array.from(seen.entries()) }, [items]) async function handleRowClick(item) { if (!item.read_at) { await markNotificationRead(item.id) setItems(prev => prev.map(p => p.id === item.id ? { ...p, read_at: new Date().toISOString() } : p)) } } async function markAllUnderFilter() { await markNotificationsReadByFilter({ rfc_slug: filters.rfcSlug || undefined, category: filters.category || undefined, }) setItems(prev => prev.map(p => ({ ...p, read_at: p.read_at || new Date().toISOString() }))) setUnreadCount(0) } return (
e.stopPropagation()}>

Inbox

{loading &&

Loading…

} {!loading && items.length === 0 && (

No notifications match. Try a different filter, or come back later.

)}
    {items.map(item => ( ))}
) } function InboxRow({ item, onClick, onClose }) { const unread = !item.read_at const target = deepLink(item) const handle = async () => { await onClick(item) if (target) onClose?.() } return (
  • {item.category || '·'} {item.summary} {item.bundled_count > 1 && ( +{item.bundled_count - 1} )} {formatWhen(item.created_at)}
  • ) } function deepLink(item) { if (item.rfc_slug && item.pr_number) return `/rfc/${item.rfc_slug}/pr/${item.pr_number}` if (item.rfc_slug && item.branch_name) return `/rfc/${item.rfc_slug}?branch=${item.branch_name}` if (item.rfc_slug) return `/rfc/${item.rfc_slug}` return '' } function formatWhen(iso) { if (!iso) return '' const dt = new Date(iso.replace(' ', 'T') + (iso.endsWith('Z') ? '' : 'Z')) if (Number.isNaN(dt.getTime())) return iso const diffMs = Date.now() - dt.getTime() const m = Math.floor(diffMs / 60000) if (m < 1) return 'just now' if (m < 60) return `${m}m` const h = Math.floor(m / 60) if (h < 24) return `${h}h` const d = Math.floor(h / 24) return `${d}d` }