Files
spsays-training/components/notifications/notifications-provider.tsx
T
2026-06-09 16:34:10 +05:30

184 lines
5.5 KiB
TypeScript

"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";
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;
};
const NotificationsContext = createContext<NotificationsContextValue | null>(
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`;
case "post_like":
return `${who} liked your post`;
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?.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]);
// Open the realtime connection while signed in.
const userId = user?.id;
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 = `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: "",
});
const channel = pusher.subscribe(channelName);
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 (
<NotificationsContext.Provider
value={{ unread, lastEventAt, refreshUnread, clearUnread }}
>
{children}
</NotificationsContext.Provider>
);
}
export function useNotifications(): NotificationsContextValue {
const ctx = useContext(NotificationsContext);
if (!ctx) {
throw new Error(
"useNotifications must be used inside <NotificationsProvider>",
);
}
return ctx;
}