feat: notification center frontend core (PR-3)
- Bell icon in icon registry (Phosphor Bell) - NotificationProvider context with polling (15s unread / 30s list) - useNotifications() hook with optimistic updates - NotificationCenter component: bell + badge + dropdown panel - NotificationItem component: severity icon, title, relative time, actions - AppShell integration: mount in header-actions, hidden on mobile - CSS styles: dropdown, items, unread/read states, empty state - formatRelativeTime utility (custom, no new deps) - 25 frontend tests (9 hook + 6 item + 10 center) Quality gates: vitest 25 passed, tsc clean, eslint clean
This commit is contained in:
@@ -0,0 +1,286 @@
|
||||
import React, {
|
||||
createContext,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import {
|
||||
getNotifications,
|
||||
getUnreadCount,
|
||||
markNotificationRead,
|
||||
markAllNotificationsRead,
|
||||
dismissNotification,
|
||||
} from "../api/notifications";
|
||||
import type { NotificationItem } from "../api/notifications";
|
||||
|
||||
export interface NotificationContextValue {
|
||||
notifications: NotificationItem[];
|
||||
unreadCount: number;
|
||||
isLoading: boolean;
|
||||
error: Error | null;
|
||||
markRead: (id: string) => Promise<void>;
|
||||
markAllRead: () => Promise<void>;
|
||||
dismiss: (id: string) => Promise<void>;
|
||||
refreshList: () => Promise<void>;
|
||||
isDropdownOpen: boolean;
|
||||
setIsDropdownOpen: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export const NotificationContext =
|
||||
createContext<NotificationContextValue | null>(null);
|
||||
|
||||
const UNREAD_POLL_MS = 15000;
|
||||
const LIST_POLL_MS = 30000;
|
||||
|
||||
export function NotificationProvider({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const [notifications, setNotifications] = useState<NotificationItem[]>([]);
|
||||
const [unreadCount, setUnreadCount] = useState(0);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
|
||||
|
||||
const stateRef = useRef({
|
||||
notifications,
|
||||
unreadCount,
|
||||
isDropdownOpen,
|
||||
stopped: false,
|
||||
});
|
||||
stateRef.current = {
|
||||
notifications,
|
||||
unreadCount,
|
||||
isDropdownOpen,
|
||||
stopped: false,
|
||||
};
|
||||
|
||||
const unreadIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const listIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
const fetchUnreadCount = useCallback(async () => {
|
||||
if (stateRef.current.stopped) return;
|
||||
try {
|
||||
const count = await getUnreadCount();
|
||||
if (!stateRef.current.stopped) {
|
||||
setUnreadCount(count);
|
||||
}
|
||||
} catch (err) {
|
||||
const status = (err as { response?: { status?: number } })?.response
|
||||
?.status;
|
||||
if (status === 401) {
|
||||
stateRef.current.stopped = true;
|
||||
if (unreadIntervalRef.current) {
|
||||
clearInterval(unreadIntervalRef.current);
|
||||
unreadIntervalRef.current = null;
|
||||
}
|
||||
if (listIntervalRef.current) {
|
||||
clearInterval(listIntervalRef.current);
|
||||
listIntervalRef.current = null;
|
||||
}
|
||||
}
|
||||
// Silently log other errors; next cycle proceeds
|
||||
// eslint-disable-next-line no-console
|
||||
console.error("Notification unread count poll failed", err);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const fetchList = useCallback(async () => {
|
||||
if (stateRef.current.stopped) return;
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const data = await getNotifications();
|
||||
if (!stateRef.current.stopped) {
|
||||
setNotifications(data.items);
|
||||
}
|
||||
} catch (err) {
|
||||
const status = (err as { response?: { status?: number } })?.response
|
||||
?.status;
|
||||
if (status === 401) {
|
||||
stateRef.current.stopped = true;
|
||||
if (unreadIntervalRef.current) {
|
||||
clearInterval(unreadIntervalRef.current);
|
||||
unreadIntervalRef.current = null;
|
||||
}
|
||||
if (listIntervalRef.current) {
|
||||
clearInterval(listIntervalRef.current);
|
||||
listIntervalRef.current = null;
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line no-console
|
||||
console.error("Notification list poll failed", err);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const startPolling = useCallback(() => {
|
||||
if (stateRef.current.stopped) return;
|
||||
|
||||
if (!unreadIntervalRef.current) {
|
||||
void fetchUnreadCount();
|
||||
unreadIntervalRef.current = setInterval(() => {
|
||||
void fetchUnreadCount();
|
||||
}, UNREAD_POLL_MS);
|
||||
}
|
||||
|
||||
if (!listIntervalRef.current && !stateRef.current.isDropdownOpen) {
|
||||
void fetchList();
|
||||
listIntervalRef.current = setInterval(() => {
|
||||
if (!stateRef.current.isDropdownOpen) {
|
||||
void fetchList();
|
||||
}
|
||||
}, LIST_POLL_MS);
|
||||
}
|
||||
}, [fetchUnreadCount, fetchList]);
|
||||
|
||||
const stopPolling = useCallback(() => {
|
||||
if (unreadIntervalRef.current) {
|
||||
clearInterval(unreadIntervalRef.current);
|
||||
unreadIntervalRef.current = null;
|
||||
}
|
||||
if (listIntervalRef.current) {
|
||||
clearInterval(listIntervalRef.current);
|
||||
listIntervalRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Handle visibility changes
|
||||
useEffect(() => {
|
||||
const handleVisibilityChange = () => {
|
||||
if (document.hidden) {
|
||||
stopPolling();
|
||||
} else {
|
||||
startPolling();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("visibilitychange", handleVisibilityChange);
|
||||
return () => {
|
||||
document.removeEventListener("visibilitychange", handleVisibilityChange);
|
||||
};
|
||||
}, [startPolling, stopPolling]);
|
||||
|
||||
// Start/stop polling based on dropdown state
|
||||
useEffect(() => {
|
||||
if (isDropdownOpen) {
|
||||
if (listIntervalRef.current) {
|
||||
clearInterval(listIntervalRef.current);
|
||||
listIntervalRef.current = null;
|
||||
}
|
||||
void fetchList();
|
||||
} else {
|
||||
if (
|
||||
!listIntervalRef.current &&
|
||||
!document.hidden &&
|
||||
unreadIntervalRef.current
|
||||
) {
|
||||
listIntervalRef.current = setInterval(() => {
|
||||
if (!stateRef.current.isDropdownOpen) {
|
||||
void fetchList();
|
||||
}
|
||||
}, LIST_POLL_MS);
|
||||
}
|
||||
}
|
||||
}, [isDropdownOpen, fetchList]);
|
||||
|
||||
// Initial start
|
||||
useEffect(() => {
|
||||
startPolling();
|
||||
return () => {
|
||||
stopPolling();
|
||||
};
|
||||
}, [startPolling, stopPolling]);
|
||||
|
||||
const markRead = useCallback(async (id: string) => {
|
||||
const { notifications: currentNotifications, unreadCount: currentCount } =
|
||||
stateRef.current;
|
||||
const target = currentNotifications.find((n) => n.id === id);
|
||||
const wasUnread = target ? target.read_at === null : false;
|
||||
|
||||
setNotifications(
|
||||
currentNotifications.map((n) =>
|
||||
n.id === id ? { ...n, read_at: new Date().toISOString() } : n,
|
||||
),
|
||||
);
|
||||
if (wasUnread) {
|
||||
setUnreadCount((c) => Math.max(0, c - 1));
|
||||
}
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
await markNotificationRead(id);
|
||||
} catch (err) {
|
||||
setNotifications(currentNotifications);
|
||||
setUnreadCount(currentCount);
|
||||
setError(err as Error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const markAllRead = useCallback(async () => {
|
||||
const { notifications: currentNotifications, unreadCount: currentCount } =
|
||||
stateRef.current;
|
||||
|
||||
setNotifications(
|
||||
currentNotifications.map((n) =>
|
||||
n.read_at === null ? { ...n, read_at: new Date().toISOString() } : n,
|
||||
),
|
||||
);
|
||||
setUnreadCount(0);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
await markAllNotificationsRead();
|
||||
} catch (err) {
|
||||
setNotifications(currentNotifications);
|
||||
setUnreadCount(currentCount);
|
||||
setError(err as Error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const dismiss = useCallback(async (id: string) => {
|
||||
const { notifications: currentNotifications, unreadCount: currentCount } =
|
||||
stateRef.current;
|
||||
const target = currentNotifications.find((n) => n.id === id);
|
||||
const wasUnread = target ? target.read_at === null : false;
|
||||
|
||||
setNotifications(currentNotifications.filter((n) => n.id !== id));
|
||||
if (wasUnread) {
|
||||
setUnreadCount((c) => Math.max(0, c - 1));
|
||||
}
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
await dismissNotification(id);
|
||||
} catch (err) {
|
||||
setNotifications(currentNotifications);
|
||||
setUnreadCount(currentCount);
|
||||
setError(err as Error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const refreshList = useCallback(async () => {
|
||||
await fetchList();
|
||||
}, [fetchList]);
|
||||
|
||||
const value: NotificationContextValue = {
|
||||
notifications,
|
||||
unreadCount,
|
||||
isLoading,
|
||||
error,
|
||||
markRead,
|
||||
markAllRead,
|
||||
dismiss,
|
||||
refreshList,
|
||||
isDropdownOpen,
|
||||
setIsDropdownOpen,
|
||||
};
|
||||
|
||||
return (
|
||||
<NotificationContext.Provider value={value}>
|
||||
{children}
|
||||
</NotificationContext.Provider>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user