feat: add websocket and analytics
This commit is contained in:
@@ -0,0 +1,15 @@
|
|||||||
|
# BFF API Server (server-side fetches)
|
||||||
|
BFF_URL=http://localhost:8080
|
||||||
|
|
||||||
|
# BFF API Server (browser-side fetches; must be publicly reachable in prod)
|
||||||
|
NEXT_PUBLIC_BFF_URL=http://localhost:8080
|
||||||
|
|
||||||
|
# RTF Viewer (development only)
|
||||||
|
ALLOW_RTF=true
|
||||||
|
NEXT_PUBLIC_OAUTH_ENABLED=true
|
||||||
|
|
||||||
|
# SOKETI
|
||||||
|
NEXT_PUBLIC_SOKETI_KEY=spsays
|
||||||
|
NEXT_PUBLIC_SOKETI_HOST=ws.prabhupadasays.org
|
||||||
|
NEXT_PUBLIC_SOKETI_PORT=443
|
||||||
|
NEXT_PUBLIC_SOKETI_FORCE_TLS=true
|
||||||
+1
-1
@@ -31,7 +31,7 @@ yarn-error.log*
|
|||||||
.pnpm-debug.log*
|
.pnpm-debug.log*
|
||||||
|
|
||||||
# env files (can opt-in for committing if needed)
|
# env files (can opt-in for committing if needed)
|
||||||
.env*
|
.env
|
||||||
|
|
||||||
# vercel
|
# vercel
|
||||||
.vercel
|
.vercel
|
||||||
|
|||||||
@@ -19,11 +19,13 @@ import Link from "next/link";
|
|||||||
import { Bell } from "lucide-react";
|
import { Bell } from "lucide-react";
|
||||||
import { useAuth } from "@/components/auth/auth-provider";
|
import { useAuth } from "@/components/auth/auth-provider";
|
||||||
import { useAuthGate } from "@/components/auth/auth-gate";
|
import { useAuthGate } from "@/components/auth/auth-gate";
|
||||||
|
import { useNotifications } from "@/components/notifications/notifications-provider";
|
||||||
import { notifications, type AppNotification } from "@/lib/social";
|
import { notifications, type AppNotification } from "@/lib/social";
|
||||||
|
|
||||||
export default function NotificationsPage() {
|
export default function NotificationsPage() {
|
||||||
const { user, loading } = useAuth();
|
const { user, loading } = useAuth();
|
||||||
const { requireAuth } = useAuthGate();
|
const { requireAuth } = useAuthGate();
|
||||||
|
const { lastEventAt, clearUnread } = useNotifications();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const [items, setItems] = useState<AppNotification[]>([]);
|
const [items, setItems] = useState<AppNotification[]>([]);
|
||||||
@@ -42,7 +44,9 @@ export default function NotificationsPage() {
|
|||||||
};
|
};
|
||||||
}, [loading, user, requireAuth, router]);
|
}, [loading, user, requireAuth, router]);
|
||||||
|
|
||||||
// Load the feed + mark everything read once we have a user.
|
// Load the feed + mark everything read once we have a user, and again
|
||||||
|
// whenever a realtime notification arrives while the page is open
|
||||||
|
// (lastEventAt bumps). Marking read here also clears the header badge.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!user) return;
|
if (!user) return;
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
@@ -57,13 +61,16 @@ export default function NotificationsPage() {
|
|||||||
.catch(() => {
|
.catch(() => {
|
||||||
if (!cancelled) setState("error");
|
if (!cancelled) setState("error");
|
||||||
});
|
});
|
||||||
notifications.markAllRead().catch(() => {
|
notifications
|
||||||
/* non-critical */
|
.markAllRead()
|
||||||
});
|
.then(() => clearUnread())
|
||||||
|
.catch(() => {
|
||||||
|
/* non-critical */
|
||||||
|
});
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [user]);
|
}, [user, lastEventAt, clearUnread]);
|
||||||
|
|
||||||
if (loading || !user) {
|
if (loading || !user) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
+14
-2
@@ -1,4 +1,5 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
|
import Script from "next/script";
|
||||||
import { Geist, Geist_Mono, Lora, Bricolage_Grotesque } from "next/font/google";
|
import { Geist, Geist_Mono, Lora, Bricolage_Grotesque } from "next/font/google";
|
||||||
import { ogDefault } from "@/lib/og";
|
import { ogDefault } from "@/lib/og";
|
||||||
import { ThemeProvider } from "@/components/theme-provider";
|
import { ThemeProvider } from "@/components/theme-provider";
|
||||||
@@ -6,6 +7,7 @@ import { TooltipProvider } from "@/components/ui/tooltip";
|
|||||||
import { AudioPlayerProvider, FloatingPlayer } from "@/components/audio-player";
|
import { AudioPlayerProvider, FloatingPlayer } from "@/components/audio-player";
|
||||||
import { AuthProvider } from "@/components/auth/auth-provider";
|
import { AuthProvider } from "@/components/auth/auth-provider";
|
||||||
import { AuthGateProvider } from "@/components/auth/auth-gate";
|
import { AuthGateProvider } from "@/components/auth/auth-gate";
|
||||||
|
import { NotificationsProvider } from "@/components/notifications/notifications-provider";
|
||||||
import { SharePostProvider } from "@/components/social/share-post-provider";
|
import { SharePostProvider } from "@/components/social/share-post-provider";
|
||||||
import { Toaster } from "@/components/ui/toaster";
|
import { Toaster } from "@/components/ui/toaster";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
@@ -73,7 +75,8 @@ export default function RootLayout({
|
|||||||
disableTransitionOnChange
|
disableTransitionOnChange
|
||||||
>
|
>
|
||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
<AuthGateProvider>
|
<NotificationsProvider>
|
||||||
|
<AuthGateProvider>
|
||||||
<AudioPlayerProvider>
|
<AudioPlayerProvider>
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<SharePostProvider>
|
<SharePostProvider>
|
||||||
@@ -86,9 +89,18 @@ export default function RootLayout({
|
|||||||
</SharePostProvider>
|
</SharePostProvider>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
</AudioPlayerProvider>
|
</AudioPlayerProvider>
|
||||||
</AuthGateProvider>
|
</AuthGateProvider>
|
||||||
|
</NotificationsProvider>
|
||||||
</AuthProvider>
|
</AuthProvider>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
|
{/* Rybbit analytics — loads after hydration (afterInteractive is
|
||||||
|
next/script's defer-equivalent). Site-wide via the root layout,
|
||||||
|
loaded once across navigations. */}
|
||||||
|
<Script
|
||||||
|
src="https://analytics.krispire.cloud/api/script.js"
|
||||||
|
data-site-id="1"
|
||||||
|
strategy="afterInteractive"
|
||||||
|
/>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,33 +2,19 @@
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* NotificationBell — header affordance for signed-in users. Links to the
|
* NotificationBell — header affordance for signed-in users. Links to the
|
||||||
* /notifications page and shows a dot when there are unread items. The
|
* /notifications page and shows a dot when there are unread items.
|
||||||
* unread count is fetched once on mount (good enough until websocket/push
|
*
|
||||||
* delivery lands); visiting the page marks everything read.
|
* 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 Link from "next/link";
|
||||||
import { Bell } from "lucide-react";
|
import { Bell } from "lucide-react";
|
||||||
import { notifications } from "@/lib/social";
|
import { useNotifications } from "@/components/notifications/notifications-provider";
|
||||||
|
|
||||||
export function NotificationBell() {
|
export function NotificationBell() {
|
||||||
const [unread, setUnread] = useState(0);
|
const { unread } = useNotifications();
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let cancelled = false;
|
|
||||||
notifications
|
|
||||||
.unreadCount()
|
|
||||||
.then((r) => {
|
|
||||||
if (!cancelled) setUnread(r?.count ?? 0);
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
/* badge is non-critical — ignore */
|
|
||||||
});
|
|
||||||
return () => {
|
|
||||||
cancelled = true;
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link
|
<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;
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@
|
|||||||
"lucide-react": "^1.8.0",
|
"lucide-react": "^1.8.0",
|
||||||
"next": "16.2.2",
|
"next": "16.2.2",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
|
"pusher-js": "^8.5.0",
|
||||||
"react": "19.2.4",
|
"react": "19.2.4",
|
||||||
"react-dom": "19.2.4",
|
"react-dom": "19.2.4",
|
||||||
"react-icons": "^5.6.0",
|
"react-icons": "^5.6.0",
|
||||||
|
|||||||
@@ -4188,6 +4188,13 @@ punycode@^2.1.0:
|
|||||||
resolved "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz"
|
resolved "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz"
|
||||||
integrity sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==
|
integrity sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==
|
||||||
|
|
||||||
|
pusher-js@^8.5.0:
|
||||||
|
version "8.5.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/pusher-js/-/pusher-js-8.5.0.tgz#329d4fb4b5e4ae0a5e0a558b6da1a852c4badae4"
|
||||||
|
integrity sha512-V7uzGi9bqOOOyM/6IkJdpFyjGZj7llz1v0oWnYkZKcYLvbz6VcHVLmzKqkvegjuMumpfIEKGLmWHwFb39XFCpw==
|
||||||
|
dependencies:
|
||||||
|
tweetnacl "^1.0.3"
|
||||||
|
|
||||||
qs@^6.14.0, qs@^6.14.1:
|
qs@^6.14.0, qs@^6.14.1:
|
||||||
version "6.15.1"
|
version "6.15.1"
|
||||||
resolved "https://registry.npmjs.org/qs/-/qs-6.15.1.tgz"
|
resolved "https://registry.npmjs.org/qs/-/qs-6.15.1.tgz"
|
||||||
@@ -4938,6 +4945,11 @@ tw-animate-css@^1.4.0:
|
|||||||
resolved "https://registry.npmjs.org/tw-animate-css/-/tw-animate-css-1.4.0.tgz"
|
resolved "https://registry.npmjs.org/tw-animate-css/-/tw-animate-css-1.4.0.tgz"
|
||||||
integrity sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ==
|
integrity sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ==
|
||||||
|
|
||||||
|
tweetnacl@^1.0.3:
|
||||||
|
version "1.0.3"
|
||||||
|
resolved "https://registry.yarnpkg.com/tweetnacl/-/tweetnacl-1.0.3.tgz#ac0af71680458d8a6378d0d0d050ab1407d35596"
|
||||||
|
integrity sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw==
|
||||||
|
|
||||||
type-check@^0.4.0, type-check@~0.4.0:
|
type-check@^0.4.0, type-check@~0.4.0:
|
||||||
version "0.4.0"
|
version "0.4.0"
|
||||||
resolved "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz"
|
resolved "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz"
|
||||||
|
|||||||
Reference in New Issue
Block a user