218 lines
6.4 KiB
TypeScript
218 lines
6.4 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 Image from "next/image";
|
|
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";
|
|
|
|
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} />
|
|
))}
|
|
</ul>
|
|
)}
|
|
</main>
|
|
);
|
|
}
|
|
|
|
function NotificationRow({ n }: { n: AppNotification }) {
|
|
const display = n.actor_name || (n.actor_handle ? `@${n.actor_handle}` : "Someone");
|
|
const message = messageFor(n.kind);
|
|
const unread = !n.read_at;
|
|
|
|
const body = (
|
|
<div
|
|
className={cnRow(unread)}
|
|
>
|
|
<Avatar src={n.actor_avatar_url ?? null} name={display} />
|
|
<div className="min-w-0 flex-1">
|
|
<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>
|
|
);
|
|
|
|
// Link to the actor's profile when we know their handle.
|
|
return n.actor_handle ? (
|
|
<li>
|
|
<Link href={`/u/${n.actor_handle}`} 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(kind: string): string {
|
|
switch (kind) {
|
|
case "follow_request":
|
|
return "requested to follow you.";
|
|
case "follow_accepted":
|
|
return "accepted your follow request.";
|
|
default:
|
|
return "sent you a notification.";
|
|
}
|
|
}
|
|
|
|
function Avatar({ src, name }: { src: string | null; name: string }) {
|
|
if (src) {
|
|
return (
|
|
<Image
|
|
src={src}
|
|
alt={name}
|
|
width={40}
|
|
height={40}
|
|
className="size-10 rounded-full object-cover ring-1 ring-foreground/10 shrink-0"
|
|
unoptimized
|
|
/>
|
|
);
|
|
}
|
|
const initials =
|
|
name
|
|
.replace(/^@/, "")
|
|
.split(/\s+/)
|
|
.map((p) => p[0])
|
|
.slice(0, 2)
|
|
.join("")
|
|
.toUpperCase() || "?";
|
|
return (
|
|
<div className="size-10 shrink-0 rounded-full bg-primary/15 text-primary flex items-center justify-center text-sm font-semibold ring-1 ring-foreground/10">
|
|
{initials}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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>
|
|
);
|
|
}
|
|
|
|
/** Compact relative time ("just now", "5m", "3h", "2d"), falling back to
|
|
* a date for anything older than a week. */
|
|
function timeAgo(iso: string): string {
|
|
const then = new Date(iso).getTime();
|
|
if (Number.isNaN(then)) return "";
|
|
const secs = Math.max(0, Math.floor((Date.now() - then) / 1000));
|
|
if (secs < 45) return "just now";
|
|
const mins = Math.floor(secs / 60);
|
|
if (mins < 60) return `${mins}m ago`;
|
|
const hours = Math.floor(mins / 60);
|
|
if (hours < 24) return `${hours}h ago`;
|
|
const days = Math.floor(hours / 24);
|
|
if (days < 7) return `${days}d ago`;
|
|
return new Date(iso).toLocaleDateString();
|
|
}
|