"use client"; /** * useNotifications — the in-app bell's state (TASK-0094 R5/R7). * * - the unread count (the authoritative source is the API — * `GET /notifications/unread-count`, indexed on (userId, readAt)), * - the realtime fast path: the `notification.push` frame over the * `user:{userId}` control room (the server fans it to all of this user's * sockets — no in-band subscribe needed) → the count bumps + a toast for * the toast-policy types (MENTION / ASSIGNMENT — the shared * `NOTIFICATION_POLICY`, the single source of truth), * - the 30 s poll fallback (a missed push = the row is still there; the * poll catches up, the row is the truth). * * The hook is a SINGLETON across the app (the topbar bell + the inbox page * share one socket subscription + one query key). It also exposes * `invalidate` (mark-read / mark-all read → refetch the count) and * `toastOnPush` (the optional toast per the policy table). */ import { useEffect, useMemo, useRef, useState } from "react"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { NOTIFICATION_POLICY } from "@factory/shared/realtime/notification-policy"; import { notificationApi } from "@/lib/api"; import { realtime } from "@/lib/realtime"; import { useAuthStore } from "@/lib/auth-store"; import { toast } from "@/stores/ui-store"; import type { Notification } from "@/lib/types"; /** The `notification.push` frame's `kind` is the policy kind (lower-case, * e.g. "mention"). The toast decision is derived from the shared policy * table (single source of truth) — only the toast-policy kinds fire a toast. * (An unknown kind → no toast, the count still bumps — R7.) */ const TOAST_KINDS = new Set( Object.values(NOTIFICATION_POLICY) .filter((p) => p.toast) .map((p) => p.kind) ); export interface NotificationsState { /** The unread count (0 until the first fetch). */ unreadCount: number; isLoading: boolean; /** Refetch the count (call after mark-read / mark-all read). */ invalidate: () => void; /** Whether a `notification.push` frame's `kind` toasts (the policy table). */ frameToasts: (kind: string) => boolean; } const POLL_MS = 30_000; export function useNotifications(): NotificationsState { const qc = useQueryClient(); const userId = useAuthStore((s) => s.user?.id ?? null); const [count, setCount] = useState(0); const [ready, setReady] = useState(false); // The count query (the bell + the inbox share the key). const query = useQuery({ queryKey: ["notifications", "unread-count"], queryFn: async () => { const res = await notificationApi.unreadCount(); return res.count; }, enabled: userId !== null, refetchInterval: POLL_MS }); // Keep the local count in sync with the query (both the poll and the // realtime push path update it). useEffect(() => { if (query.data !== undefined) { setCount(query.data); setReady(true); } }, [query.data]); // The realtime fast path: a `notification.push` for this user → bump the // count + (per policy) a toast. The frame's `notificationId` is not in the // cached list yet, so the bump is optimistic; the next poll reconciles // (mark-read decrements, a missed push is caught by the poll). const lastPushed = useRef>(new Set()); useEffect(() => { if (userId === null) return; const off = realtime.on("notification.push", (frame) => { if (frame.type !== "notification.push") return; // De-dup a re-delivery of the same notification (reconnect re-fan). if (lastPushed.current.has(frame.notificationId)) return; lastPushed.current.add(frame.notificationId); setCount((c) => c + 1); setReady(true); // Live-update the inbox list too (a new row appears while the inbox is // open) — the count query is reconciled by the next poll. void qc.invalidateQueries({ queryKey: ["notifications", "list"] }); if (TOAST_KINDS.has(frame.kind)) { toast("info", frame.actorName ?? "New notification", frame.title); } }); return () => { off(); lastPushed.current.clear(); }; }, [userId]); const invalidate = useMemo( () => () => void qc.invalidateQueries({ queryKey: ["notifications", "unread-count"] }), [qc] ); return useMemo( () => ({ unreadCount: count, isLoading: !ready, invalidate, frameToasts: (kind: string) => TOAST_KINDS.has(kind) }), [count, ready, invalidate] ); } /** The inbox's list query (Unread + Read sections — TASK-0094 R5). */ export function useNotificationList() { return useQuery({ queryKey: ["notifications", "list", "all"], queryFn: async () => (await notificationApi.list({ limit: 50 })).data }); } export type { Notification };