src/features/inbox/notifications.tsx
Linked repository file from this build.
"use client";
import { createContext, useContext, useEffect, useMemo, useState, type ReactNode } from "react";
import { fetchJson } from "@/lib/fetch-json";
import { useSession } from "@/lib/auth/client";
import { usePathname } from "next/navigation";
import type { EventsResponse, InboxEvent } from "./types";
type Notifications = {
events: InboxEvent[];
count: number;
error: string;
setEvents: React.Dispatch<React.SetStateAction<InboxEvent[]>>;
setCount: React.Dispatch<React.SetStateAction<number>>;
setError: React.Dispatch<React.SetStateAction<string>>;
};
const NotificationContext = createContext<Notifications | null>(null);
export function NotificationProvider({
children,
testSession,
}: {
children: ReactNode;
testSession: boolean;
}) {
const { actor, ready, isSignedIn } = useSession();
const pathname = usePathname();
const readingDocs = pathname === "/docs" || pathname.startsWith("/docs/");
const scope = JSON.stringify([actor, isSignedIn]);
const [events, setEvents] = useState<InboxEvent[]>([]);
const [count, setCount] = useState(0);
const [error, setError] = useState("");
// A new account or page type starts from an empty list, reset during render.
const resetKey = JSON.stringify([ready, scope, testSession, readingDocs]);
const [loadedKey, setLoadedKey] = useState(resetKey);
if (loadedKey !== resetKey) {
setLoadedKey(resetKey);
setEvents([]);
setCount(0);
setError("");
}
useEffect(() => {
if (readingDocs || !ready || (!isSignedIn && !testSession)) return;
const controller = new AbortController();
let inFlight = false;
let lastAttempt = -Infinity;
const poll = async (force = false) => {
if (document.hidden || inFlight || (!force && Date.now() - lastAttempt < 300_000)) return;
lastAttempt = Date.now();
inFlight = true;
try {
const result = await fetchJson<EventsResponse>("/api/inbox/events", {
signal: controller.signal,
fallback: "Notifications are unavailable.",
});
if (!controller.signal.aborted) {
setEvents(result.events);
setCount(result.unreadCount);
setError("");
}
} catch {
if (!controller.signal.aborted)
setError("Notifications are unavailable. We will retry shortly.");
} finally {
inFlight = false;
}
};
void poll();
const check = () => void poll();
const refresh = () => void poll(true);
const timer = setInterval(check, 300_000);
document.addEventListener("visibilitychange", check);
window.addEventListener("focus", check);
window.addEventListener("qccrm:notifications", refresh);
return () => {
controller.abort();
clearInterval(timer);
document.removeEventListener("visibilitychange", check);
window.removeEventListener("focus", check);
window.removeEventListener("qccrm:notifications", refresh);
};
}, [ready, scope, isSignedIn, testSession, readingDocs]);
const value = useMemo(
() => ({ events, count, error, setEvents, setCount, setError }),
[events, count, error, setEvents, setCount, setError],
);
return <NotificationContext.Provider value={value}>{children}</NotificationContext.Provider>;
}
export function useNotifications() {
const value = useContext(NotificationContext);
if (!value) throw new Error("NotificationProvider is required.");
return value;
}