feat: add friend request tab in notifications
This commit is contained in:
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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. */
|
||||||
|
|||||||
Reference in New Issue
Block a user