"use client"; /** * NotificationsProvider — holds the live unread-notification count and a * WebSocket connection to our Soketi (Pusher-compatible) server so the * bell badge + a toast update in realtime. * * Transport: subscribes to the PUBLIC channel `notifications.{userId}` and * binds the `notification` event the BFF triggers on each follow-request / * follow-accept. The event is a lightweight nudge — we bump the unread * count locally and toast — while the authoritative list/count still come * from the REST endpoints. (Public channel for now; once Soketi App auth * is configured, switch to a `private-` channel + a /broadcasting/auth * endpoint — only the channel name and a Pusher `authEndpoint` change.) * * Config via NEXT_PUBLIC_SOKETI_* env vars; when the key/host are absent * realtime silently disables and the bell falls back to its fetched count. */ import { createContext, useCallback, useContext, useEffect, useState, } from "react"; import { toast } from "sonner"; import { useAuth } from "@/components/auth/auth-provider"; import { notifications } from "@/lib/social"; type NotificationsContextValue = { /** Live unread count for the header bell. */ unread: number; /** Bumps on each realtime event; pages can depend on it to refetch. */ lastEventAt: number; /** Re-fetch the authoritative unread count from the BFF. */ refreshUnread: () => void; /** Zero the badge locally (after the page marks everything read). */ clearUnread: () => void; }; const NotificationsContext = createContext( null, ); type IncomingEvent = { kind?: string; actor_name?: string; actor_handle?: string; actor_avatar_url?: string | null; }; function messageFor(e: IncomingEvent): string { const who = e.actor_name || (e.actor_handle ? `@${e.actor_handle}` : "Someone"); switch (e.kind) { case "follow_request": return `${who} requested to follow you`; case "follow_accepted": return `${who} accepted your follow request`; default: return "You have a new notification"; } } export function NotificationsProvider({ children, }: { children: React.ReactNode; }) { const { user } = useAuth(); const [unread, setUnread] = useState(0); const [lastEventAt, setLastEventAt] = useState(0); const refreshUnread = useCallback(() => { notifications .unreadCount() .then((r) => setUnread(r?.count ?? 0)) .catch(() => { /* badge is non-critical */ }); }, []); const clearUnread = useCallback(() => setUnread(0), []); // Seed the count whenever the signed-in user changes (async — no // synchronous setState in the effect body). useEffect(() => { if (!user) return; refreshUnread(); }, [user?.id, refreshUnread]); // Open the realtime connection while signed in. useEffect(() => { if (!user) return; const key = process.env.NEXT_PUBLIC_SOKETI_KEY; const host = process.env.NEXT_PUBLIC_SOKETI_HOST; if (!key || !host) return; // realtime not configured const port = Number(process.env.NEXT_PUBLIC_SOKETI_PORT || "443"); const forceTLS = (process.env.NEXT_PUBLIC_SOKETI_FORCE_TLS ?? "true") !== "false"; const channelName = `notifications.${user.id}`; let cancelled = false; // Dynamic import keeps pusher-js out of SSR (it touches window) and the // initial bundle. let cleanup: (() => void) | undefined; void import("pusher-js").then(({ default: Pusher }) => { if (cancelled) return; const pusher = new Pusher(key, { wsHost: host, wsPort: port, wssPort: port, forceTLS, enabledTransports: ["ws", "wss"], disableStats: true, // Ignored when wsHost is set, but required by the type. cluster: "", }); const channel = pusher.subscribe(channelName); channel.bind("notification", (data: IncomingEvent) => { setUnread((u) => u + 1); setLastEventAt(Date.now()); toast(messageFor(data ?? {}), { duration: 4000 }); }); cleanup = () => { channel.unbind_all(); pusher.unsubscribe(channelName); pusher.disconnect(); }; }); return () => { cancelled = true; cleanup?.(); }; }, [user?.id]); return ( {children} ); } export function useNotifications(): NotificationsContextValue { const ctx = useContext(NotificationsContext); if (!ctx) { throw new Error( "useNotifications must be used inside ", ); } return ctx; }