feat: add friend request tab in notifications

This commit is contained in:
gosangam
2026-06-18 22:45:40 +05:30
parent 0178f89f2b
commit 25db9d0eee
3 changed files with 170 additions and 25 deletions
+165 -21
View File
@@ -1,12 +1,13 @@
"use client"; "use client";
/** /**
* /notifications — the signed-in user's notification feed. * /notifications — the signed-in user's notifications, split into two tabs:
* *
* Lists everything newest-first and marks the whole feed read on open * • "Notifications" — the activity feed (likes, comments, mentions, accepted
* (fire-and-forget), so the header bell's unread dot clears after a * follows, admin messages). Marked read on open so the header bell clears.
* visit. Each row links to the actor's profile, where follow requests can * • "Friend Requests" — pending incoming follow requests, accepted/rejected
* be 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 * Anonymous visitors are bounced through the auth gate (then home if they
* dismiss it), mirroring /u/me. * dismiss it), mirroring /u/me.
@@ -15,13 +16,24 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import Link from "next/link"; import Link from "next/link";
import { Bell } from "lucide-react"; import { Bell, Check, UserPlus, X } from "lucide-react";
import { toast } from "sonner";
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 { useNotifications } from "@/components/notifications/notifications-provider";
import { notifications, type AppNotification } from "@/lib/social"; import {
notifications,
relationships,
SocialError,
type AppNotification,
type UserSummary,
} from "@/lib/social";
import { UserAvatar } from "@/components/ui/user-avatar"; import { UserAvatar } from "@/components/ui/user-avatar";
import { Button } from "@/components/ui/button";
import { timeAgo } from "@/lib/time"; import { timeAgo } from "@/lib/time";
import { cn } from "@/lib/utils";
type Tab = "all" | "requests";
export default function NotificationsPage() { export default function NotificationsPage() {
const { user, loading } = useAuth(); const { user, loading } = useAuth();
@@ -29,7 +41,9 @@ export default function NotificationsPage() {
const { lastEventAt, clearUnread } = useNotifications(); const { lastEventAt, clearUnread } = useNotifications();
const router = useRouter(); const router = useRouter();
const [tab, setTab] = useState<Tab>("all");
const [items, setItems] = useState<AppNotification[]>([]); const [items, setItems] = useState<AppNotification[]>([]);
const [requests, setRequests] = useState<UserSummary[]>([]);
const [state, setState] = useState<"loading" | "ready" | "error">("loading"); const [state, setState] = useState<"loading" | "ready" | "error">("loading");
// Auth gate — same pattern as /u/me. // Auth gate — same pattern as /u/me.
@@ -45,17 +59,17 @@ export default function NotificationsPage() {
}; };
}, [loading, user, requireAuth, router]); }, [loading, user, requireAuth, router]);
// Load the feed + mark everything read once we have a user, and again // Load the feed + pending requests, and mark the feed read, once we have a
// whenever a realtime notification arrives while the page is open // user — and again whenever a realtime notification arrives while the page
// (lastEventAt bumps). Marking read here also clears the header badge. // is open (lastEventAt bumps). Marking read here clears the header badge.
useEffect(() => { useEffect(() => {
if (!user) return; if (!user) return;
let cancelled = false; let cancelled = false;
notifications Promise.all([notifications.list(), relationships.incomingRequests()])
.list() .then(([rows, reqs]) => {
.then((rows) => {
if (!cancelled) { if (!cancelled) {
setItems(rows ?? []); setItems(rows ?? []);
setRequests(reqs?.users ?? []);
setState("ready"); setState("ready");
} }
}) })
@@ -83,24 +97,60 @@ export default function NotificationsPage() {
); );
} }
// 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 ( return (
<main className="flex-1 max-w-2xl w-full mx-auto px-4 sm:px-6 py-6 space-y-6"> <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"> <h1 className="font-[family-name:var(--font-title)] text-3xl font-semibold text-foreground">
Notifications Notifications
</h1> </h1>
{/* Tabs */}
<div className="flex gap-1 rounded-lg bg-muted p-1">
<TabButton active={tab === "all"} onClick={() => setTab("all")}>
Notifications
</TabButton>
<TabButton
active={tab === "requests"}
onClick={() => setTab("requests")}
badge={requests.length}
>
Friend Requests
</TabButton>
</div>
{state === "error" ? ( {state === "error" ? (
<p className="text-sm text-destructive"> <p className="text-sm text-destructive">
Could not load notifications. Please try again. Could not load notifications. Please try again.
</p> </p>
) : state === "loading" ? ( ) : state === "loading" ? (
<p className="text-sm text-muted-foreground">Loading…</p> <p className="text-sm text-muted-foreground">Loading…</p>
) : items.length === 0 ? ( ) : tab === "all" ? (
<Empty /> feedItems.length === 0 ? (
<Empty
icon={<Bell className="size-8" />}
text="No notifications yet."
/>
) : (
<ul className="divide-y divide-border rounded-2xl border border-border overflow-hidden">
{feedItems.map((n) => (
<NotificationRow key={n.id} n={n} selfHandle={user.handle} />
))}
</ul>
)
) : requests.length === 0 ? (
<Empty
icon={<UserPlus className="size-8" />}
text="No pending friend requests."
/>
) : ( ) : (
<ul className="divide-y divide-border rounded-2xl border border-border overflow-hidden"> <ul className="divide-y divide-border rounded-2xl border border-border overflow-hidden">
{items.map((n) => ( {requests.map((u) => (
<NotificationRow key={n.id} n={n} selfHandle={user.handle} /> <RequestRow key={u.handle} u={u} onResolve={resolveRequest} />
))} ))}
</ul> </ul>
)} )}
@@ -108,6 +158,101 @@ export default function NotificationsPage() {
); );
} }
function TabButton({
active,
onClick,
badge,
children,
}: {
active: boolean;
onClick: () => void;
badge?: number;
children: React.ReactNode;
}) {
return (
<button
type="button"
onClick={onClick}
className={cn(
"flex-1 inline-flex items-center justify-center gap-2 h-9 rounded-md text-sm font-medium transition-colors",
active
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground",
)}
>
{children}
{badge && badge > 0 ? (
<span className="inline-flex items-center justify-center min-w-5 h-5 px-1.5 rounded-full bg-primary text-primary-foreground dark:text-white text-xs font-semibold">
{badge}
</span>
) : null}
</button>
);
}
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 (
<li className="flex items-center gap-3 px-4 py-3">
<Link href={`/u/${u.handle}`} className="shrink-0">
<UserAvatar src={u.avatar_url} name={display} />
</Link>
<div className="min-w-0 flex-1">
<Link
href={`/u/${u.handle}`}
className="block text-sm font-semibold text-foreground truncate hover:underline"
>
{display}
</Link>
<p className="text-xs text-muted-foreground truncate">
@{u.handle} · requested to follow you
</p>
</div>
<div className="flex items-center gap-2 shrink-0">
<Button size="sm" onClick={() => act("accept")} disabled={busy}>
<Check className="size-4" />
Accept
</Button>
<Button
size="sm"
variant="outline"
onClick={() => act("reject")}
disabled={busy}
aria-label="Reject request"
>
<X className="size-4" />
</Button>
</div>
</li>
);
}
function NotificationRow({ function NotificationRow({
n, n,
selfHandle, selfHandle,
@@ -212,12 +357,11 @@ function messageFor(n: AppNotification): string {
} }
} }
function Empty() { function Empty({ icon, text }: { icon: React.ReactNode; text: string }) {
return ( return (
<div className="flex flex-col items-center text-center gap-3 py-16 text-muted-foreground"> <div className="flex flex-col items-center text-center gap-3 py-16 text-muted-foreground">
<Bell className="size-8" /> {icon}
<p className="text-sm">No notifications yet.</p> <p className="text-sm">{text}</p>
</div> </div>
); );
} }
-4
View File
@@ -8,10 +8,6 @@ export async function POST(req: Request) {
headers.set("x-forwarded-for", ip); headers.set("x-forwarded-for", ip);
headers.set("x-real-ip", ip); headers.set("x-real-ip", ip);
headers.delete("host"); // avoid sending your app's Host to the Rybbit backend headers.delete("host"); // avoid sending your app's Host to the Rybbit backend
console.log("XFF:", req.headers.get("x-forwarded-for"));
console.log("XRI:", req.headers.get("x-real-ip"));
console.log("resolved ip:", ip);
const res = await fetch(`${process.env.NEXT_PUBLIC_RYBBIT_HOST}/api/track`, { const res = await fetch(`${process.env.NEXT_PUBLIC_RYBBIT_HOST}/api/track`, {
method: "POST", method: "POST",
+5
View File
@@ -741,6 +741,11 @@ export const relationships = {
`/u/${encodeURIComponent(handle)}/following?${followListQuery(opts)}`, `/u/${encodeURIComponent(handle)}/following?${followListQuery(opts)}`,
); );
}, },
/** The caller's pending incoming follow requests (people who asked to follow
* you) — the notifications "Friend Requests" tab. Same shape as followers. */
incomingRequests(opts: FollowListParams = {}) {
return call<FollowList>(`/follow-requests?${followListQuery(opts)}`);
},
}; };
/** Lightweight user row in a followers / following list. */ /** Lightweight user row in a followers / following list. */