"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 PRIVATE channel `private-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. * * The channel is private so a subscription requires a signature the BFF only * issues to that user; on the old public channel anyone who knew a user's id * could listen in. Signing goes through `channelAuthorizer` (see * lib/realtime.ts). The BFF still publishes to the old public channel too, so * a service-worker-cached bundle running this file's previous version keeps * working until it updates. * * 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 { channelAuthorizer } from "@/lib/realtime"; import { feed, notifications } from "@/lib/social"; import { sadhna } from "@/lib/sadhna"; 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; /** * Last chat message seen over realtime, or null. Chat deliberately does NOT * flow through the notification tray — a new message highlights its row in * the inbox instead — so it rides the same private channel under its own * event name and is surfaced here rather than bumping `unread`. */ lastChatActivity: ChatActivity | null; }; /** Payload of the BFF's `chat_activity` event. */ export type ChatActivity = { conversation_id: string; seq: number; sender_id: string; /** Client-side receipt time, so consumers can react to repeats. */ at: number; }; const NotificationsContext = createContext( null, ); type IncomingEvent = { kind?: string; actor_name?: string; actor_handle?: string; actor_avatar_url?: string | null; body?: 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`; case "post_like": return `${who} liked your post`; case "post_mention": return `${who} mentioned you in a post`; case "post_comment": return `${who} commented on your post`; case "comment_reply": return `${who} replied to your comment`; case "comment_mention": return `${who} mentioned you in a comment`; case "disciple_joined": return `${who}, a direct disciple of Śrīla Prabhupāda, has joined`; case "admin_message": return e.body || "A message from Prabhupāda Says"; 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 [lastChatActivity, setLastChatActivity] = useState( null, ); 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?.id) return; refreshUnread(); }, [user?.id, refreshUnread]); // Report the browser's timezone (once per signed-in session) so sādhana // reminders fire at the user's local clock time. Best-effort. useEffect(() => { if (!user?.id) return; try { const tz = Intl.DateTimeFormat().resolvedOptions().timeZone; if (tz) void sadhna.setTimezone(tz).catch(() => {}); } catch { /* Intl unavailable — leave the server default (UTC) */ } }, [user?.id]); const userId = user?.id; // When the session ends — the page unloads / the realtime socket drops as // the user navigates away or closes the tab — clear the home-feed seen-set // so it doesn't linger in Redis. `keepalive` lets the request outlive the // page. (Genuine network drops with no unload are caught by the set's TTL; // we deliberately don't clear on transient socket blips, which would re-show // posts mid-scroll.) useEffect(() => { if (!userId) return; const onLeave = () => { void feed.reset().catch(() => {}); }; window.addEventListener("pagehide", onLeave); return () => window.removeEventListener("pagehide", onLeave); }, [userId]); // Open the realtime connection while signed in. useEffect(() => { if (!userId) 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 = `private-notifications.${userId}`; 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: "", // Private channels are refused without a signature from the BFF. channelAuthorization: { customHandler: channelAuthorizer }, }); const channel = pusher.subscribe(channelName); // Authorization can legitimately fail (expired session, realtime not // configured). Realtime is a nudge, not the source of truth, so degrade // quietly — the bell keeps working off its fetched count. channel.bind("pusher:subscription_error", (status: unknown) => { console.warn("notifications: realtime subscription failed", status); }); // Chat activity: no toast, no unread bump, nothing in the tray. The // inbox subscribes to this to highlight the affected row. channel.bind( "chat_activity", (data: Omit) => { if (!data?.conversation_id) return; setLastChatActivity({ ...data, at: Date.now() }); }, ); channel.bind("notification", (data: IncomingEvent) => { setUnread((u) => u + 1); setLastEventAt(Date.now()); toast(messageFor(data ?? {}), { duration: 4000 }); }); // Sādhana reminders are ephemeral nudges — toast only, no unread bump // (they aren't persisted to the notification center). channel.bind( "reminder", (data: { title?: string; body?: string }) => { toast(data?.body || data?.title || "A gentle reminder", { duration: 6000, }); }, ); cleanup = () => { channel.unbind_all(); pusher.unsubscribe(channelName); pusher.disconnect(); }; }); return () => { cancelled = true; cleanup?.(); }; }, [userId]); return ( {children} ); } export function useNotifications(): NotificationsContextValue { const ctx = useContext(NotificationsContext); if (!ctx) { throw new Error( "useNotifications must be used inside ", ); } return ctx; }