Slice 6: notifications per §15
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1139,3 +1139,82 @@
|
||||
.branch-dropdown-item.pre-graduation .branch-meta {
|
||||
font-size: 10px; color: #9ca3af; margin-left: auto;
|
||||
}
|
||||
|
||||
/* ---- §15 / Slice 6: inbox, badge, toasts ---- */
|
||||
|
||||
.inbox-trigger {
|
||||
position: relative; background: transparent; border: 1px solid #e5e7eb;
|
||||
border-radius: 6px; padding: 4px 10px; cursor: pointer; font-size: 16px;
|
||||
margin-right: 12px;
|
||||
}
|
||||
.inbox-trigger:hover { background: #f9fafb; }
|
||||
.inbox-trigger .badge {
|
||||
position: absolute; top: -6px; right: -6px;
|
||||
background: #dc2626; color: white; font-size: 10px;
|
||||
border-radius: 999px; padding: 1px 5px; font-weight: 700;
|
||||
min-width: 16px; text-align: center;
|
||||
}
|
||||
|
||||
.inbox-overlay {
|
||||
position: fixed; inset: 0; background: rgba(0,0,0,0.25);
|
||||
display: flex; align-items: flex-start; justify-content: center;
|
||||
padding-top: 60px; z-index: 100;
|
||||
}
|
||||
.inbox-panel {
|
||||
background: white; border: 1px solid #e5e7eb; border-radius: 8px;
|
||||
width: 720px; max-width: 90vw; max-height: 80vh;
|
||||
display: flex; flex-direction: column; box-shadow: 0 12px 32px rgba(0,0,0,0.18);
|
||||
}
|
||||
.inbox-header {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 12px 16px; border-bottom: 1px solid #e5e7eb;
|
||||
}
|
||||
.inbox-header h2 { margin: 0; font-size: 16px; }
|
||||
.inbox-filters {
|
||||
display: flex; gap: 8px; flex-wrap: wrap;
|
||||
padding: 10px 16px; border-bottom: 1px solid #f3f4f6; align-items: center;
|
||||
}
|
||||
.inbox-filters .chip {
|
||||
font-size: 12px; padding: 4px 8px; background: #f9fafb;
|
||||
border: 1px solid #e5e7eb; border-radius: 999px;
|
||||
display: inline-flex; align-items: center; gap: 4px;
|
||||
}
|
||||
.inbox-filters .chip input[type=checkbox] { margin-right: 2px; }
|
||||
.inbox-filters select.chip { padding: 4px 8px; }
|
||||
|
||||
.inbox-body { overflow-y: auto; flex: 1; }
|
||||
.inbox-list { list-style: none; margin: 0; padding: 0; }
|
||||
.inbox-row { border-bottom: 1px solid #f3f4f6; }
|
||||
.inbox-row.unread { background: #fffbeb; }
|
||||
.inbox-row-link {
|
||||
display: flex; align-items: center; gap: 10px; padding: 10px 16px;
|
||||
text-decoration: none; color: inherit;
|
||||
}
|
||||
.inbox-row-link:hover { background: #f9fafb; }
|
||||
.inbox-cat {
|
||||
font-size: 9px; text-transform: uppercase; letter-spacing: 0.05em;
|
||||
padding: 2px 6px; border-radius: 4px; font-weight: 700;
|
||||
}
|
||||
.inbox-cat.cat-personal-direct { background: #fef3c7; color: #92400e; }
|
||||
.inbox-cat.cat-structural { background: #dbeafe; color: #1e40af; }
|
||||
.inbox-cat.cat-churn { background: #f3f4f6; color: #4b5563; }
|
||||
.inbox-summary { flex: 1; font-size: 13px; }
|
||||
.inbox-bundle-count {
|
||||
font-size: 11px; color: #6b7280;
|
||||
background: #f3f4f6; padding: 1px 6px; border-radius: 999px;
|
||||
}
|
||||
.inbox-when { font-size: 11px; color: #9ca3af; }
|
||||
|
||||
.toast-host {
|
||||
position: fixed; right: 16px; bottom: 16px;
|
||||
display: flex; flex-direction: column; gap: 8px; z-index: 200;
|
||||
}
|
||||
.toast {
|
||||
padding: 10px 14px; background: white; border: 1px solid #e5e7eb;
|
||||
border-left: 4px solid #6b7280; border-radius: 6px;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.08); font-size: 13px;
|
||||
max-width: 360px; cursor: pointer;
|
||||
}
|
||||
.toast.cat-personal-direct { border-left-color: #d97706; }
|
||||
.toast.cat-structural { border-left-color: #2563eb; }
|
||||
.toast.cat-churn { border-left-color: #6b7280; }
|
||||
|
||||
+53
-1
@@ -1,12 +1,14 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Routes, Route, Link, useNavigate } from 'react-router-dom'
|
||||
import { getMe } from './api'
|
||||
import { getMe, subscribeToNotifications } from './api'
|
||||
import Catalog from './components/Catalog.jsx'
|
||||
import Inbox from './components/Inbox.jsx'
|
||||
import RFCView from './components/RFCView.jsx'
|
||||
import PRView from './components/PRView.jsx'
|
||||
import ProposalView from './components/ProposalView.jsx'
|
||||
import ProposeModal from './components/ProposeModal.jsx'
|
||||
import Landing from './components/Landing.jsx'
|
||||
import ToastHost, { showToast } from './components/ToastHost.jsx'
|
||||
import './App.css'
|
||||
|
||||
export default function App() {
|
||||
@@ -14,6 +16,9 @@ export default function App() {
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [proposeOpen, setProposeOpen] = useState(false)
|
||||
const [catalogVersion, setCatalogVersion] = useState(0)
|
||||
const [inboxOpen, setInboxOpen] = useState(false)
|
||||
const [unreadCount, setUnreadCount] = useState(0)
|
||||
const [inboxTick, setInboxTick] = useState(0)
|
||||
const navigate = useNavigate()
|
||||
|
||||
useEffect(() => {
|
||||
@@ -23,6 +28,39 @@ export default function App() {
|
||||
.finally(() => setLoading(false))
|
||||
}, [])
|
||||
|
||||
// §15.3 — subscribe to the live SSE stream for authenticated viewers
|
||||
// so the badge counter and the toast surface stay in lockstep with
|
||||
// the inbox. Tabs that miss an event because they were closed pick
|
||||
// it up on next sign-in via the snapshot frame.
|
||||
useEffect(() => {
|
||||
if (!me?.authenticated) return undefined
|
||||
const close = subscribeToNotifications({
|
||||
onSnapshot: payload => setUnreadCount(payload.unread_count || 0),
|
||||
onNotification: payload => {
|
||||
setUnreadCount(c => c + 1)
|
||||
setInboxTick(t => t + 1)
|
||||
// §15.3: personal-direct events get a toast even when the user
|
||||
// isn't on the relevant view — they're the named subject.
|
||||
// Churn never toasts; structural toasts only when it lands on
|
||||
// a slug the user is currently viewing (URL match).
|
||||
const isPersonal = payload.category === 'personal-direct'
|
||||
const onCurrentSlug = payload.rfc_slug && window.location.pathname.includes(`/rfc/${payload.rfc_slug}`)
|
||||
if (isPersonal || onCurrentSlug) {
|
||||
showToast({
|
||||
summary: payload.summary,
|
||||
category: payload.category,
|
||||
link: payload.rfc_slug ? `/rfc/${payload.rfc_slug}` : null,
|
||||
})
|
||||
}
|
||||
},
|
||||
onRead: () => {
|
||||
setUnreadCount(c => Math.max(0, c - 1))
|
||||
setInboxTick(t => t + 1)
|
||||
},
|
||||
})
|
||||
return close
|
||||
}, [me?.authenticated])
|
||||
|
||||
if (loading) {
|
||||
return <div className="boot">Loading…</div>
|
||||
}
|
||||
@@ -38,6 +76,16 @@ export default function App() {
|
||||
<Link to="/">Wiggleverse RFCs</Link>
|
||||
</div>
|
||||
<div className="header-right">
|
||||
<button
|
||||
className="inbox-trigger"
|
||||
onClick={() => setInboxOpen(o => !o)}
|
||||
title="Notifications inbox (§15.2)"
|
||||
>
|
||||
<span aria-hidden>📮</span>
|
||||
{unreadCount > 0 && (
|
||||
<span className="badge">{unreadCount > 99 ? '99+' : unreadCount}</span>
|
||||
)}
|
||||
</button>
|
||||
<span className="user-name">{me.user.display_name}</span>
|
||||
<span className={`user-role-badge role-${me.user.role}`}>{me.user.role}</span>
|
||||
<a className="btn-link" href="/auth/logout">Sign out</a>
|
||||
@@ -67,6 +115,10 @@ export default function App() {
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{inboxOpen && (
|
||||
<Inbox onClose={() => setInboxOpen(false)} lastChangeTick={inboxTick} />
|
||||
)}
|
||||
<ToastHost />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -399,3 +399,100 @@ export async function streamChatTurn(slug, branch, threadId, { text, quote, mode
|
||||
return { assistantId, userMsgId }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// §15 / Slice 6: notifications surface
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function listNotifications({ unread, rfcSlug, category, actorUserId, bundled } = {}) {
|
||||
const params = new URLSearchParams()
|
||||
if (unread) params.set('unread', '1')
|
||||
if (rfcSlug) params.set('rfc_slug', rfcSlug)
|
||||
if (category) params.set('category', category)
|
||||
if (actorUserId) params.set('actor_user_id', actorUserId)
|
||||
if (bundled) params.set('bundled', '1')
|
||||
const qs = params.toString()
|
||||
return jsonOrThrow(await fetch(`/api/notifications${qs ? `?${qs}` : ''}`))
|
||||
}
|
||||
|
||||
export async function markNotificationRead(id) {
|
||||
return jsonOrThrow(await fetch(`/api/notifications/${id}/read`, { method: 'POST' }))
|
||||
}
|
||||
|
||||
export async function markNotificationsReadByFilter(filter) {
|
||||
return jsonOrThrow(await fetch('/api/notifications/read', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(filter || {}),
|
||||
}))
|
||||
}
|
||||
|
||||
export async function listWatches() {
|
||||
return jsonOrThrow(await fetch('/api/watches'))
|
||||
}
|
||||
|
||||
export async function setWatch(slug, state) {
|
||||
return jsonOrThrow(await fetch(`/api/rfcs/${slug}/watch`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ state }),
|
||||
}))
|
||||
}
|
||||
|
||||
export async function getNotificationPreferences() {
|
||||
return jsonOrThrow(await fetch('/api/users/me/notification-preferences'))
|
||||
}
|
||||
|
||||
export async function setNotificationPreferences(prefs) {
|
||||
return jsonOrThrow(await fetch('/api/users/me/notification-preferences', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(prefs),
|
||||
}))
|
||||
}
|
||||
|
||||
export async function getQuietHours() {
|
||||
return jsonOrThrow(await fetch('/api/users/me/quiet-hours'))
|
||||
}
|
||||
|
||||
export async function setQuietHours({ start, end, timezone } = {}) {
|
||||
return jsonOrThrow(await fetch('/api/users/me/quiet-hours', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ start: start || null, end: end || null, timezone: timezone || null }),
|
||||
}))
|
||||
}
|
||||
|
||||
export async function muteUser(userId) {
|
||||
return jsonOrThrow(await fetch(`/api/users/${userId}/notification-mute`, { method: 'POST' }))
|
||||
}
|
||||
|
||||
export async function unmuteUser(userId) {
|
||||
return jsonOrThrow(await fetch(`/api/users/${userId}/notification-mute`, { method: 'DELETE' }))
|
||||
}
|
||||
|
||||
export async function advanceChatSeen(slug, branch, lastSeenMessageId) {
|
||||
return jsonOrThrow(await fetch(`/api/rfcs/${slug}/branches/${branch}/chat-seen`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ last_seen_message_id: lastSeenMessageId || null }),
|
||||
}))
|
||||
}
|
||||
|
||||
// SSE subscription helper. Returns a close() function. The handler
|
||||
// surface mirrors §15.3: a snapshot event on open, then per-notification
|
||||
// `notification` events, plus `read` events when another tab marks a row.
|
||||
export function subscribeToNotifications({ onSnapshot, onNotification, onRead, onError } = {}) {
|
||||
const source = new EventSource('/api/notifications/stream')
|
||||
source.addEventListener('snapshot', e => {
|
||||
try { onSnapshot?.(JSON.parse(e.data)) } catch {}
|
||||
})
|
||||
source.addEventListener('notification', e => {
|
||||
try { onNotification?.(JSON.parse(e.data)) } catch {}
|
||||
})
|
||||
source.addEventListener('read', e => {
|
||||
try { onRead?.(JSON.parse(e.data)) } catch {}
|
||||
})
|
||||
source.onerror = err => { onError?.(err) }
|
||||
return () => source.close()
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
// §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 (
|
||||
<div className="inbox-overlay" onClick={onClose}>
|
||||
<div className="inbox-panel" onClick={e => e.stopPropagation()}>
|
||||
<header className="inbox-header">
|
||||
<h2>Inbox</h2>
|
||||
<button className="btn-link" onClick={onClose}>Close</button>
|
||||
</header>
|
||||
|
||||
<div className="inbox-filters">
|
||||
<label className="chip">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={filters.unread}
|
||||
onChange={e => setFilters(f => ({ ...f, unread: e.target.checked }))}
|
||||
/>
|
||||
Unread only
|
||||
</label>
|
||||
|
||||
<select
|
||||
value={filters.rfcSlug}
|
||||
onChange={e => setFilters(f => ({ ...f, rfcSlug: e.target.value }))}
|
||||
className="chip"
|
||||
>
|
||||
<option value="">All RFCs</option>
|
||||
{rfcOptions.map(([slug, title]) => (
|
||||
<option key={slug} value={slug}>{title}</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<select
|
||||
value={filters.category}
|
||||
onChange={e => setFilters(f => ({ ...f, category: e.target.value }))}
|
||||
className="chip"
|
||||
>
|
||||
{CATEGORIES.map(c => (
|
||||
<option key={c.value} value={c.value}>{c.label}</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<label className="chip">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={filters.bundled}
|
||||
onChange={e => setFilters(f => ({ ...f, bundled: e.target.checked }))}
|
||||
/>
|
||||
Bundle by RFC
|
||||
</label>
|
||||
|
||||
<button
|
||||
className="btn-link"
|
||||
onClick={markAllUnderFilter}
|
||||
disabled={items.every(i => i.read_at)}
|
||||
>
|
||||
Mark all read (under filter)
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="inbox-body">
|
||||
{loading && <p className="muted">Loading…</p>}
|
||||
{!loading && items.length === 0 && (
|
||||
<p className="muted">No notifications match. Try a different filter, or come back later.</p>
|
||||
)}
|
||||
<ul className="inbox-list">
|
||||
{items.map(item => (
|
||||
<InboxRow key={item.id} item={item} onClick={handleRowClick} onClose={onClose} />
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function InboxRow({ item, onClick, onClose }) {
|
||||
const unread = !item.read_at
|
||||
const target = deepLink(item)
|
||||
const handle = async () => {
|
||||
await onClick(item)
|
||||
if (target) onClose?.()
|
||||
}
|
||||
return (
|
||||
<li className={`inbox-row ${unread ? 'unread' : ''}`}>
|
||||
<Link to={target || '#'} onClick={handle} className="inbox-row-link">
|
||||
<span className={`inbox-cat cat-${item.category || 'unknown'}`}>{item.category || '·'}</span>
|
||||
<span className="inbox-summary">{item.summary}</span>
|
||||
{item.bundled_count > 1 && (
|
||||
<span className="inbox-bundle-count">+{item.bundled_count - 1}</span>
|
||||
)}
|
||||
<span className="inbox-when">{formatWhen(item.created_at)}</span>
|
||||
</Link>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
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`
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
// §15.3 — the toast surface.
|
||||
//
|
||||
// Toasts fire for the user's own actions completing and for events
|
||||
// landing on the exact view the user is currently looking at. The
|
||||
// inbox row still lands for the same event; the toast just carries
|
||||
// the "something just happened here" beat per §15.3.
|
||||
//
|
||||
// This component is a simple stack with a small cap (4 visible at
|
||||
// once). Newer toasts queue behind the visible ones rather than
|
||||
// stacking endlessly. Auto-dismiss after a short interval; click to
|
||||
// dismiss early.
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
const MAX_VISIBLE = 4
|
||||
const AUTO_DISMISS_MS = 6000
|
||||
|
||||
let _emit = null
|
||||
|
||||
export function showToast({ summary, category, link }) {
|
||||
if (_emit) _emit({ id: Math.random().toString(36).slice(2), summary, category, link, ts: Date.now() })
|
||||
}
|
||||
|
||||
export default function ToastHost() {
|
||||
const [toasts, setToasts] = useState([])
|
||||
|
||||
useEffect(() => {
|
||||
_emit = (t) => setToasts(prev => [...prev, t])
|
||||
return () => { _emit = null }
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (toasts.length === 0) return
|
||||
const t = setTimeout(() => {
|
||||
setToasts(prev => prev.slice(1))
|
||||
}, AUTO_DISMISS_MS)
|
||||
return () => clearTimeout(t)
|
||||
}, [toasts])
|
||||
|
||||
const visible = toasts.slice(0, MAX_VISIBLE)
|
||||
return (
|
||||
<div className="toast-host">
|
||||
{visible.map(t => (
|
||||
<div
|
||||
key={t.id}
|
||||
className={`toast cat-${t.category || 'unknown'}`}
|
||||
onClick={() => setToasts(prev => prev.filter(x => x.id !== t.id))}
|
||||
>
|
||||
{t.summary}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user