import { useCallback, useEffect, useRef, useState } from 'react' import { Link, useLocation, useNavigate } from 'react-router-dom' import { useAuth } from '../contexts/AuthContext.jsx' import { api } from '../api/client.js' import { inboxPath } from '../lib/notificationPaths.js' // The in-app inbox's header surface (ENGAGEMENT.md Phase 7): a bell with an // unread badge, and a panel with the most recent items. // // **The badge is polled, not pushed**, and the reason is that there is nothing // to push over. The site's two SSE streams are the shard's; neither is // per-user, and adding a third authenticated stream to carry an integer would // mean one open connection per signed-in tab for the rest of the deployment's // life. A minute-granular badge on a page somebody is already looking at is the // same answer for a fraction of that. The poll pauses while the tab is hidden — // a background tab has nobody to show a badge to — and refreshes the moment it // comes back, which is also the moment it would be most wrong. // // **The panel shows a handful and links out.** Paging belongs on the page; a // dropdown that scrolls is a list in the wrong place. // // Dismissal follows `NavDropdown`'s contract exactly — Escape closes and // returns focus, an outside `mousedown` closes, navigating closes — because // this sits beside it in the same header and two menus that dismiss differently // is a bug nobody files. const POLL_MS = 60_000 const PANEL_ITEMS = 6 function BellIcon({ size = 17 }) { return ( ) } // "3m", "4h", "6d" — a relative stamp, because the only question a reader has // about an inbox item's time is how fresh it is. function ago(iso) { const then = new Date(iso).getTime() if (!Number.isFinite(then)) return '' const secs = Math.max(0, Math.round((Date.now() - then) / 1000)) if (secs < 60) return 'now' if (secs < 3600) return `${Math.floor(secs / 60)}m` if (secs < 86400) return `${Math.floor(secs / 3600)}h` return `${Math.floor(secs / 86400)}d` } export default function NotificationBell() { const { user } = useAuth() const [unread, setUnread] = useState(0) const [items, setItems] = useState([]) const [open, setOpen] = useState(false) const [error, setError] = useState('') const wrapRef = useRef(null) const triggerRef = useRef(null) const location = useLocation() const navigate = useNavigate() // Every read here swallows its failure. A count that could not be fetched is // a bell with no badge, which is what a bell with nothing to report looks // like anyway — the alternative is an error banner in the site header for a // number nobody asked for. const refreshCount = useCallback(async () => { if (!user) return try { const res = await api.notificationsUnreadCount() setUnread(res.unread || 0) } catch { /* leave the badge as it was */ } }, [user]) useEffect(() => { if (!user) return undefined refreshCount() const timer = setInterval(() => { if (document.visibilityState === 'visible') refreshCount() }, POLL_MS) const onVisible = () => { if (document.visibilityState === 'visible') refreshCount() } document.addEventListener('visibilitychange', onVisible) return () => { clearInterval(timer) document.removeEventListener('visibilitychange', onVisible) } }, [user, refreshCount]) // The panel's items are fetched when it opens, never kept warm: a list nobody // has asked to see is a request per minute for content nobody is reading. const load = useCallback(async () => { setError('') try { const res = await api.notifications({ limit: PANEL_ITEMS }) setItems(res.items || []) setUnread(res.unread || 0) } catch (err) { setError(err.message || 'Could not load notifications') } }, []) useEffect(() => setOpen(false), [location.pathname]) useEffect(() => { if (!open) return undefined const onKey = (e) => { if (e.key !== 'Escape') return setOpen(false) triggerRef.current?.focus() } const onOutside = (e) => { if (!wrapRef.current?.contains(e.target)) setOpen(false) } document.addEventListener('keydown', onKey) document.addEventListener('mousedown', onOutside) return () => { document.removeEventListener('keydown', onKey) document.removeEventListener('mousedown', onOutside) } }, [open]) if (!user) return null const toggle = () => { const next = !open setOpen(next) if (next) load() } // Opening an item marks it read and then goes where it points. The mark is // awaited rather than fired off, so the badge the next screen renders is the // one this click produced; a failed mark still navigates, because the item's // link is the thing the user asked for. const openItem = async (item) => { setOpen(false) if (!item.read) { try { const res = await api.markNotificationRead(item.id) setUnread(res.unread ?? Math.max(0, unread - 1)) } catch { /* the link still works */ } } navigate(item.url || inboxPath(user)) } const markAll = async () => { try { await api.markAllNotificationsRead() setUnread(0) setItems((list) => list.map((i) => ({ ...i, read: true }))) } catch (err) { setError(err.message || 'Could not mark them read') } } return (
{open && (
Notifications {unread > 0 && ( )}
{error && (

{error}

)} {!error && items.length === 0 && (

Nothing here yet.

)} {items.map((item) => ( ))} setOpen(false)} className="sans" style={{ display: 'block', marginTop: 4, padding: '8px 10px', borderTop: '1px solid var(--line-soft)', fontSize: '0.8rem', color: 'var(--accent)', textDecoration: 'none', }} > See all notifications →
)}
) }