// §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 (
  • {item.category || '·'} {item.summary} {formatWhen(item.created_at)}
    {x.who_i_am &&

    Who: {x.who_i_am}

    } {x.why &&

    Why: {x.why}

    } {x.use_case &&

    Use case: {x.use_case}

    }
    {error &&

    {error}

    } {outcome ? (

    {outcome === 'accepted' ? 'Accepted — an invitation has been sent.' : 'Declined.'}

    ) : (
    )}
  • ) } // §22.8: the cross-collection inbox row — a request to join a scope, surfaced // to that scope's Owners across the subtree. Mirrors ContributionRequestRow: // the requester's role + message inline, an Accept/Decline pair that writes (or // refuses) the membership grant. function JoinRequestRow({ 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 acceptJoinRequest(x.scope_type, x.scope_id, x.request_id) else await declineJoinRequest(x.scope_type, x.scope_id, x.request_id) setOutcome(accept ? 'accepted' : 'declined') await onMarkRead(item) } catch (err) { setError(err.message || 'Action failed.') } finally { setBusy(false) } } const roleLabel = x.requested_role === 'owner' ? 'Owner' : 'RFC Contributor' return (
  • {item.category || '·'} {item.summary} {formatWhen(item.created_at)}

    Requested role: {roleLabel}

    {x.message &&

    Message: {x.message}

    }
    {error &&

    {error}

    } {outcome ? (

    {outcome === 'accepted' ? `Accepted — they're now a member as ${roleLabel}.` : 'Declined.'}

    ) : (
    )}
  • ) } function InboxRow({ item, onClick, onMarkRead, onClose }) { const pid = useProjectId() if (item.event_kind === 'contribution_request_on_pending_rfc') { return } if (item.event_kind === 'join_request_on_scope') { return } const unread = !item.read_at const target = deepLink(item, pid) const handle = async () => { await onClick(item) if (target) onClose?.() } const handleMarkRead = async (e) => { // Don't let the row's Link fire — this affordance only marks read, // it never navigates. e.preventDefault() e.stopPropagation() await onMarkRead(item) } return (
  • {item.category || '·'} {item.summary} {item.bundled_count > 1 && ( +{item.bundled_count - 1} )} {formatWhen(item.created_at)} {unread && ( )}
  • ) } function deepLink(item, pid) { if (item.rfc_slug && item.pr_number) return entryPrPath(pid, item.rfc_slug, item.pr_number) if (item.rfc_slug && item.branch_name) return `${entryPath(pid, item.rfc_slug)}?branch=${item.branch_name}` if (item.rfc_slug) return entryPath(pid, 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` }