"use client"; /** * /notifications — the signed-in user's notifications, split into two tabs: * * • "Notifications" — the activity feed (likes, comments, mentions, accepted * follows, admin messages). Marked read on open so the header bell clears. * • "Friend Requests" — pending incoming follow requests, accepted/rejected * inline. Sourced from the live `follows` table (not the notifications * feed), so handled requests disappear and never go stale. * * Anonymous visitors are bounced through the auth gate (then home if they * dismiss it), mirroring /u/me. */ import { useEffect, useState } from "react"; import { useRouter } from "next/navigation"; import Link from "next/link"; import { Bell, Check, UserPlus, X } from "lucide-react"; import { toast } from "sonner"; import { useAuth } from "@/components/auth/auth-provider"; import { useAuthGate } from "@/components/auth/auth-gate"; import { useNotifications } from "@/components/notifications/notifications-provider"; import { notifications, relationships, SocialError, type AppNotification, type UserSummary, } from "@/lib/social"; import { UserAvatar } from "@/components/ui/user-avatar"; import { Button } from "@/components/ui/button"; import { timeAgo } from "@/lib/time"; import { cn } from "@/lib/utils"; type Tab = "all" | "requests"; export default function NotificationsPage() { const { user, loading } = useAuth(); const { requireAuth } = useAuthGate(); const { lastEventAt, clearUnread } = useNotifications(); const router = useRouter(); const [tab, setTab] = useState("all"); const [items, setItems] = useState([]); const [requests, setRequests] = useState([]); const [state, setState] = useState<"loading" | "ready" | "error">("loading"); // Auth gate — same pattern as /u/me. useEffect(() => { if (loading || user) return; let cancelled = false; (async () => { const ok = await requireAuth(); if (!ok && !cancelled) router.replace("/"); })(); return () => { cancelled = true; }; }, [loading, user, requireAuth, router]); // Load the feed + pending requests, and mark the feed read, once we have a // user — and again whenever a realtime notification arrives while the page // is open (lastEventAt bumps). Marking read here clears the header badge. useEffect(() => { if (!user) return; let cancelled = false; Promise.all([notifications.list(), relationships.incomingRequests()]) .then(([rows, reqs]) => { if (!cancelled) { setItems(rows ?? []); setRequests(reqs?.users ?? []); setState("ready"); } }) .catch(() => { if (!cancelled) setState("error"); }); notifications .markAllRead() .then(() => clearUnread()) .catch(() => { /* non-critical */ }); return () => { cancelled = true; }; }, [user, lastEventAt, clearUnread]); if (loading || !user) { return (

{loading ? "Loading…" : "Sign in to view your notifications."}

); } // Follow requests live in their own tab — keep them out of the activity feed. const feedItems = items.filter((n) => n.kind !== "follow_request"); const resolveRequest = (handle: string) => setRequests((prev) => prev.filter((r) => r.handle !== handle)); return (

Notifications

{/* Tabs */}
setTab("all")}> Notifications setTab("requests")} badge={requests.length} > Friend Requests
{state === "error" ? (

Could not load notifications. Please try again.

) : state === "loading" ? (

Loading…

) : tab === "all" ? ( feedItems.length === 0 ? ( } text="No notifications yet." /> ) : (
    {feedItems.map((n) => ( ))}
) ) : requests.length === 0 ? ( } text="No pending friend requests." /> ) : (
    {requests.map((u) => ( ))}
)}
); } function TabButton({ active, onClick, badge, children, }: { active: boolean; onClick: () => void; badge?: number; children: React.ReactNode; }) { return ( ); } function RequestRow({ u, onResolve, }: { u: UserSummary; onResolve: (handle: string) => void; }) { const [busy, setBusy] = useState(false); const display = u.name || `@${u.handle}`; async function act(kind: "accept" | "reject") { if (busy) return; setBusy(true); try { await relationships[kind](u.handle); if (kind === "accept") { toast(`${display} now follows you`, { duration: 2500 }); } onResolve(u.handle); } catch (err) { toast.error( err instanceof SocialError ? err.message : "Could not update request", { duration: 3000 }, ); setBusy(false); } } return (
  • {display}

    @{u.handle} · requested to follow you

  • ); } function NotificationRow({ n, selfHandle, }: { n: AppNotification; selfHandle: string; }) { // admin_message rows have no actor — they're a direct message from the team. const isAdmin = n.kind === "admin_message"; const display = isAdmin ? "Prabhupāda Says" : n.actor_name || (n.actor_handle ? `@${n.actor_handle}` : "Someone"); const message = messageFor(n); const unread = !n.read_at; const body = (
    {isAdmin ? (

    {display} {n.body ? {n.body} : null}

    ) : (

    {display} {message}

    )}

    {timeAgo(n.created_at)}

    {unread ? ( ) : null}
    ); // A like links to your own post; a mention links to the actor's post you // were tagged in; everything else links to the actor's profile. const href = n.kind === "post_like" && n.post_id ? `/u/${selfHandle}/posts#post-${n.post_id}` : n.kind === "post_mention" && n.post_id && n.actor_handle ? `/u/${n.actor_handle}/posts#post-${n.post_id}` : (n.kind === "post_comment" || n.kind === "comment_reply" || n.kind === "comment_mention") && n.post_id && n.post_author_handle ? `/u/${n.post_author_handle}/posts#post-${n.post_id}` : n.actor_handle ? `/u/${n.actor_handle}` : null; return href ? (
  • {body}
  • ) : (
  • {body}
  • ); } function cnRow(unread: boolean) { return [ "flex items-start gap-3 px-4 py-3 transition-colors hover:bg-muted/50", unread ? "bg-primary/5" : "", ].join(" "); } /** Human-readable tail for each notification kind. Add cases as new * notification kinds are introduced on the backend. */ function messageFor(n: AppNotification): string { switch (n.kind) { case "follow_request": return "requested to follow you."; case "follow_accepted": return "accepted your follow request."; case "post_like": { const others = (n.actor_count ?? 1) - 1; if (others <= 0) return "liked your post."; return `and ${others} ${others === 1 ? "other" : "others"} liked your post.`; } case "post_mention": return "mentioned you in a post."; case "post_comment": return "commented on your post."; case "comment_reply": return "replied to your comment."; case "comment_mention": return "mentioned you in a comment."; case "admin_message": return n.body ?? "sent you a message."; default: return "sent you a notification."; } } function Empty({ icon, text }: { icon: React.ReactNode; text: string }) { return (
    {icon}

    {text}

    ); }