Files
spsays-training/app/(app)/notifications/page.tsx
T
2026-06-16 14:48:29 +05:30

224 lines
6.8 KiB
TypeScript

"use client";
/**
* /notifications — the signed-in user's notification feed.
*
* Lists everything newest-first and marks the whole feed read on open
* (fire-and-forget), so the header bell's unread dot clears after a
* visit. Each row links to the actor's profile, where follow requests can
* be accepted/rejected.
*
* 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 } from "lucide-react";
import { useAuth } from "@/components/auth/auth-provider";
import { useAuthGate } from "@/components/auth/auth-gate";
import { useNotifications } from "@/components/notifications/notifications-provider";
import { notifications, type AppNotification } from "@/lib/social";
import { UserAvatar } from "@/components/ui/user-avatar";
import { timeAgo } from "@/lib/time";
export default function NotificationsPage() {
const { user, loading } = useAuth();
const { requireAuth } = useAuthGate();
const { lastEventAt, clearUnread } = useNotifications();
const router = useRouter();
const [items, setItems] = useState<AppNotification[]>([]);
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 + 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(() => {
if (!user) return;
let cancelled = false;
notifications
.list()
.then((rows) => {
if (!cancelled) {
setItems(rows ?? []);
setState("ready");
}
})
.catch(() => {
if (!cancelled) setState("error");
});
notifications
.markAllRead()
.then(() => clearUnread())
.catch(() => {
/* non-critical */
});
return () => {
cancelled = true;
};
}, [user, lastEventAt, clearUnread]);
if (loading || !user) {
return (
<main className="flex-1 flex items-center justify-center px-4 py-12">
<p className="text-sm text-muted-foreground">
{loading ? "Loading…" : "Sign in to view your notifications."}
</p>
</main>
);
}
return (
<main className="flex-1 max-w-2xl w-full mx-auto px-4 sm:px-6 py-6 space-y-6">
<h1 className="font-[family-name:var(--font-title)] text-3xl font-semibold text-foreground">
Notifications
</h1>
{state === "error" ? (
<p className="text-sm text-destructive">
Could not load notifications. Please try again.
</p>
) : state === "loading" ? (
<p className="text-sm text-muted-foreground">Loading…</p>
) : items.length === 0 ? (
<Empty />
) : (
<ul className="divide-y divide-border rounded-2xl border border-border overflow-hidden">
{items.map((n) => (
<NotificationRow key={n.id} n={n} selfHandle={user.handle} />
))}
</ul>
)}
</main>
);
}
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 = (
<div
className={cnRow(unread)}
>
<UserAvatar src={isAdmin ? null : n.actor_avatar_url} name={display} />
<div className="min-w-0 flex-1">
{isAdmin ? (
<p className="text-sm text-foreground leading-snug whitespace-pre-line">
<span className="font-semibold">{display}</span>
{n.body ? <span className="block mt-0.5">{n.body}</span> : null}
</p>
) : (
<p className="text-sm text-foreground leading-snug">
<span className="font-semibold">{display}</span> {message}
</p>
)}
<p className="text-xs text-muted-foreground mt-0.5">
{timeAgo(n.created_at)}
</p>
</div>
{unread ? (
<span className="mt-1.5 size-2 shrink-0 rounded-full bg-primary" />
) : null}
</div>
);
// 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 ? (
<li>
<Link href={href} className="block">
{body}
</Link>
</li>
) : (
<li>{body}</li>
);
}
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() {
return (
<div className="flex flex-col items-center text-center gap-3 py-16 text-muted-foreground">
<Bell className="size-8" />
<p className="text-sm">No notifications yet.</p>
</div>
);
}