feat: add websocket and analytics
This commit is contained in:
@@ -2,33 +2,19 @@
|
||||
|
||||
/**
|
||||
* NotificationBell — header affordance for signed-in users. Links to the
|
||||
* /notifications page and shows a dot when there are unread items. The
|
||||
* unread count is fetched once on mount (good enough until websocket/push
|
||||
* delivery lands); visiting the page marks everything read.
|
||||
* /notifications page and shows a dot when there are unread items.
|
||||
*
|
||||
* The unread count is read from NotificationsProvider, which seeds it from
|
||||
* the REST endpoint and keeps it live over the Soketi WebSocket. Visiting
|
||||
* the page marks everything read and clears the badge.
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { Bell } from "lucide-react";
|
||||
import { notifications } from "@/lib/social";
|
||||
import { useNotifications } from "@/components/notifications/notifications-provider";
|
||||
|
||||
export function NotificationBell() {
|
||||
const [unread, setUnread] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
notifications
|
||||
.unreadCount()
|
||||
.then((r) => {
|
||||
if (!cancelled) setUnread(r?.count ?? 0);
|
||||
})
|
||||
.catch(() => {
|
||||
/* badge is non-critical — ignore */
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
const { unread } = useNotifications();
|
||||
|
||||
return (
|
||||
<Link
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
"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<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`;
|
||||
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 (
|
||||
<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;
|
||||
}
|
||||
Reference in New Issue
Block a user