261 lines
8.6 KiB
TypeScript
261 lines
8.6 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 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<NotificationsContextValue | null>(
|
|
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<ChatActivity | null>(
|
|
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<ChatActivity, "at">) => {
|
|
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 (
|
|
<NotificationsContext.Provider
|
|
value={{
|
|
unread,
|
|
lastEventAt,
|
|
refreshUnread,
|
|
clearUnread,
|
|
lastChatActivity,
|
|
}}
|
|
>
|
|
{children}
|
|
</NotificationsContext.Provider>
|
|
);
|
|
}
|
|
|
|
export function useNotifications(): NotificationsContextValue {
|
|
const ctx = useContext(NotificationsContext);
|
|
if (!ctx) {
|
|
throw new Error(
|
|
"useNotifications must be used inside <NotificationsProvider>",
|
|
);
|
|
}
|
|
return ctx;
|
|
}
|