Slice 6: notifications per §15
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
+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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user