From 25db9d0eee9179e77e8df64bf7564dbd9e899496 Mon Sep 17 00:00:00 2001 From: gosangam Date: Thu, 18 Jun 2026 22:45:40 +0530 Subject: [PATCH] feat: add friend request tab in notifications --- app/(app)/notifications/page.tsx | 186 +++++++++++++++++++++++++++---- app/api/track/route.ts | 4 - lib/social.ts | 5 + 3 files changed, 170 insertions(+), 25 deletions(-) diff --git a/app/(app)/notifications/page.tsx b/app/(app)/notifications/page.tsx index c3c46873..c41652be 100644 --- a/app/(app)/notifications/page.tsx +++ b/app/(app)/notifications/page.tsx @@ -1,12 +1,13 @@ "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 - * (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. + * • "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. @@ -15,13 +16,24 @@ import { useEffect, useState } from "react"; import { useRouter } from "next/navigation"; 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 { useAuthGate } from "@/components/auth/auth-gate"; 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 { 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(); @@ -29,7 +41,9 @@ export default function NotificationsPage() { 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. @@ -45,17 +59,17 @@ export default function NotificationsPage() { }; }, [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. + // 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; - notifications - .list() - .then((rows) => { + Promise.all([notifications.list(), relationships.incomingRequests()]) + .then(([rows, reqs]) => { if (!cancelled) { setItems(rows ?? []); + setRequests(reqs?.users ?? []); 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 (

Notifications

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

Could not load notifications. Please try again.

) : state === "loading" ? (

Loading…

- ) : items.length === 0 ? ( - + ) : tab === "all" ? ( + feedItems.length === 0 ? ( + } + text="No notifications yet." + /> + ) : ( +
    + {feedItems.map((n) => ( + + ))} +
+ ) + ) : requests.length === 0 ? ( + } + text="No pending friend requests." + /> ) : (
    - {items.map((n) => ( - + {requests.map((u) => ( + ))}
)} @@ -108,6 +158,101 @@ export default function NotificationsPage() { ); } +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, @@ -212,12 +357,11 @@ function messageFor(n: AppNotification): string { } } -function Empty() { +function Empty({ icon, text }: { icon: React.ReactNode; text: string }) { return (
    - -

    No notifications yet.

    + {icon} +

    {text}

    ); } - diff --git a/app/api/track/route.ts b/app/api/track/route.ts index 312d2c33..3f3dc38f 100644 --- a/app/api/track/route.ts +++ b/app/api/track/route.ts @@ -8,10 +8,6 @@ export async function POST(req: Request) { headers.set("x-forwarded-for", ip); headers.set("x-real-ip", ip); 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`, { method: "POST", diff --git a/lib/social.ts b/lib/social.ts index 18d96a8c..c5e2078f 100644 --- a/lib/social.ts +++ b/lib/social.ts @@ -741,6 +741,11 @@ export const relationships = { `/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(`/follow-requests?${followListQuery(opts)}`); + }, }; /** Lightweight user row in a followers / following list. */