// §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 {
acceptContributionRequest,
acceptJoinRequest,
declineContributionRequest,
declineJoinRequest,
listNotifications,
markNotificationRead,
markNotificationsReadByFilter,
} from '../api.js'
import { entryPath, entryPrPath, useProjectId } from '../lib/entryPaths'
import './Inbox.css'
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 markOneRead(item) {
if (item.read_at) return
await markNotificationRead(item.id)
setItems(prev => prev.map(p => p.id === item.id ? { ...p, read_at: new Date().toISOString() } : p))
setUnreadCount(c => Math.max(0, c - 1))
}
async function handleRowClick(item) {
await markOneRead(item)
}
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 your inbox…
}
{!loading && items.length === 0 && (
You're all caught up.
{filters.unread || filters.rfcSlug || filters.category
? 'Nothing matches the current filters. Clear them to see everything.'
: 'New activity on RFCs you follow will show up here.'}
)}
{items.map(item => (
))}
)
}
// #28 Part 3: the contribute-request row is the first actionable inbox
// kind — it renders the requester's who/why/use-case inline and an
// Accept/Decline pair that fire the owner's decision (accept reuses #12's
// invite flow on the backend).
function ContributionRequestRow({ item, onMarkRead }) {
const unread = !item.read_at
const x = item.extras || {}
const [outcome, setOutcome] = useState(null) // 'accepted' | 'declined'
const [busy, setBusy] = useState(false)
const [error, setError] = useState(null)
async function act(accept) {
if (busy || outcome) return
setBusy(true)
setError(null)
try {
if (accept) await acceptContributionRequest(item.rfc_slug, x.request_id)
else await declineContributionRequest(item.rfc_slug, x.request_id)
setOutcome(accept ? 'accepted' : 'declined')
await onMarkRead(item)
} catch (err) {
setError(err.message || 'Action failed.')
} finally {
setBusy(false)
}
}
return (