diff --git a/app/(app)/notifications/page.tsx b/app/(app)/notifications/page.tsx new file mode 100644 index 00000000..8f231a83 --- /dev/null +++ b/app/(app)/notifications/page.tsx @@ -0,0 +1,210 @@ +"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 { notifications, type AppNotification } from "@/lib/social"; + +export default function NotificationsPage() { + const { user, loading } = useAuth(); + const { requireAuth } = useAuthGate(); + const router = useRouter(); + + const [items, setItems] = useState([]); + 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. + useEffect(() => { + if (!user) return; + let cancelled = false; + notifications + .list() + .then((rows) => { + if (!cancelled) { + setItems(rows ?? []); + setState("ready"); + } + }) + .catch(() => { + if (!cancelled) setState("error"); + }); + notifications.markAllRead().catch(() => { + /* non-critical */ + }); + return () => { + cancelled = true; + }; + }, [user]); + + if (loading || !user) { + return ( +
+

+ {loading ? "Loading…" : "Sign in to view your notifications."} +

+
+ ); + } + + return ( +
+

+ Notifications +

+ + {state === "error" ? ( +

+ Could not load notifications. Please try again. +

+ ) : state === "loading" ? ( +

Loading…

+ ) : items.length === 0 ? ( + + ) : ( +
    + {items.map((n) => ( + + ))} +
+ )} +
+ ); +} + +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 = ( +
+ +
+

+ {display} {message} +

+

+ {timeAgo(n.created_at)} +

+
+ {unread ? ( + + ) : null} +
+ ); + + // Link to the actor's profile when we know their handle. + return n.actor_handle ? ( +
  • + + {body} + +
  • + ) : ( +
  • {body}
  • + ); +} + +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 ( + {name} + ); + } + const initials = + name + .replace(/^@/, "") + .split(/\s+/) + .map((p) => p[0]) + .slice(0, 2) + .join("") + .toUpperCase() || "?"; + return ( +
    + {initials} +
    + ); +} + +function Empty() { + return ( +
    + +

    No notifications yet.

    +
    + ); +} + +/** 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(); +} diff --git a/app/(app)/u/[handle]/layout.tsx b/app/(app)/u/[handle]/layout.tsx index 66450320..79304ae3 100644 --- a/app/(app)/u/[handle]/layout.tsx +++ b/app/(app)/u/[handle]/layout.tsx @@ -120,11 +120,17 @@ export default function PublicProfileLayout(props: { return (
    diff --git a/app/(app)/u/me/layout.tsx b/app/(app)/u/me/layout.tsx index 504de606..ec784856 100644 --- a/app/(app)/u/me/layout.tsx +++ b/app/(app)/u/me/layout.tsx @@ -13,7 +13,7 @@ * via a query param (kept simple — no new route for the form). */ -import { Suspense, useEffect } from "react"; +import { Suspense, useEffect, useState } from "react"; import { useRouter, useSearchParams } from "next/navigation"; import { AtSign, @@ -23,6 +23,7 @@ import { } from "lucide-react"; import { useAuth } from "@/components/auth/auth-provider"; import { useAuthGate } from "@/components/auth/auth-gate"; +import { profile } from "@/lib/social"; import { ProfileHeader } from "@/components/profile/profile-header"; import { ProfileTabs, type ProfileTab } from "@/components/profile/profile-tabs"; @@ -43,6 +44,37 @@ export default function MeLayout({ children }: { children: React.ReactNode }) { }; }, [loading, user, requireAuth, router]); + // Own profile counts (posts / followers / following) for the header + // stat line. /auth/me doesn't carry these, so we read them from the + // public profile endpoint (which returns no relationship for self). + const [counts, setCounts] = useState<{ + posts: number; + followers: number; + following: number; + } | null>(null); + const handle = user?.handle; + useEffect(() => { + if (!handle) return; + let cancelled = false; + profile + .get(handle) + .then((p) => { + if (!cancelled) { + setCounts({ + posts: p.post_count, + followers: p.follower_count, + following: p.following_count, + }); + } + }) + .catch(() => { + /* counts are non-critical; leave the stat line minimal on error */ + }); + return () => { + cancelled = true; + }; + }, [handle]); + if (loading || !user) { return (
    @@ -80,11 +112,16 @@ export default function MeLayout({ children }: { children: React.ReactNode }) { return (
    {/* Tab strip hides on /u/me?edit=1 (see ConditionalTabs). The Suspense boundary is required by Next.js because useSearchParams diff --git a/components/notification-bell.tsx b/components/notification-bell.tsx new file mode 100644 index 00000000..1969d495 --- /dev/null +++ b/components/notification-bell.tsx @@ -0,0 +1,47 @@ +"use client"; + +/** + * NotificationBell — header affordance for signed-in users. Links to the + * /notifications page and shows a dot when there are unread items. The + * unread count is fetched once on mount (good enough until websocket/push + * delivery lands); visiting the page marks everything read. + */ + +import { useEffect, useState } from "react"; +import Link from "next/link"; +import { Bell } from "lucide-react"; +import { notifications } from "@/lib/social"; + +export function NotificationBell() { + const [unread, setUnread] = useState(0); + + useEffect(() => { + let cancelled = false; + notifications + .unreadCount() + .then((r) => { + if (!cancelled) setUnread(r?.count ?? 0); + }) + .catch(() => { + /* badge is non-critical — ignore */ + }); + return () => { + cancelled = true; + }; + }, []); + + return ( + 0 ? `Notifications (${unread} unread)` : "Notifications" + } + className="relative size-9 inline-flex items-center justify-center rounded-lg text-foreground hover:bg-muted transition-colors outline-none focus-visible:ring-2 focus-visible:ring-ring" + > + + {unread > 0 ? ( + + ) : null} + + ); +} diff --git a/components/profile/profile-actions.tsx b/components/profile/profile-actions.tsx new file mode 100644 index 00000000..f19d3db5 --- /dev/null +++ b/components/profile/profile-actions.tsx @@ -0,0 +1,853 @@ +"use client"; + +/** + * ProfileActions — the action row on someone else's profile header. + * + * Renders a primary follow button whose label/behaviour is derived from + * the viewer's RelationshipState toward the target, plus a shield + * overflow button opening a menu (Remove from followers / Block / Report). + * + * Follows are REQUEST-BASED: pressing "Follow" sends a request that the + * target accepts or rejects later, so the button flips to "Requested" + * (tap again to cancel) rather than straight to "Following". + * + * Primary button by state (viewer → target): + * • they've requested you → "Accept" + "Reject" + * • you follow them → "Unfollow" + * • you've requested them → "Requested" (tap = cancel) + * • they follow you → "Follow back" + * • no edge → "Follow" + * • you blocked / they blocked → no primary button (menu only) + * + * Overflow menu: + * • "Remove from followers" — only when they currently follow you + * • "Block user" / "Unblock user" + * • "Report user" + * + * Block confirms in a dialog (desktop) / drawer (mobile); Report collects + * a category + reason in the same responsive container. Each successful + * action calls onResult so the header can refresh its live counts. + */ + +import { useCallback, useEffect, useRef, useState } from "react"; +import { createPortal } from "react-dom"; +import { + Ban, + CalendarDays, + Check, + Clock, + Flag, + Globe, + Info, + Loader2, + Shield, + UserCheck, + UserMinus, + UserPlus, + X, +} from "lucide-react"; +import { toast } from "sonner"; +import { + relationships, + SocialError, + type ReportCategory, + type RelationshipResult, + type RelationshipState, +} from "@/lib/social"; +import { countryName } from "@/lib/countries"; +import { useAuth } from "@/components/auth/auth-provider"; +import { useAuthGate } from "@/components/auth/auth-gate"; +import { useMediaQuery } from "@/components/selection-toolbar/shared"; +import { + Drawer, + DrawerContent, + DrawerDescription, + DrawerHeader, + DrawerTitle, +} from "@/components/ui/drawer"; +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; + +export function ProfileActions({ + handle, + name, + joinedAt, + country, + relationship, + onResult, +}: { + handle: string; + /** Display name for menu labels / dialog copy. */ + name: string; + /** ISO timestamp the account was created — shown in the "About this + * account" (More info) modal. */ + joinedAt?: string; + /** ISO-2 account country, shown in the "About this account" modal. */ + country?: string | null; + relationship: RelationshipState | null; + /** Called after any successful mutation with the recomputed result so + * the parent header can update its follower/following counts and the + * relationship it passes back in. */ + onResult: (r: RelationshipResult) => void; +}) { + const { user } = useAuth(); + const { requireAuth } = useAuthGate(); + const [busy, setBusy] = useState(false); + const [blockOpen, setBlockOpen] = useState(false); + const [reportOpen, setReportOpen] = useState(false); + const [aboutOpen, setAboutOpen] = useState(false); + + const rel = relationship; + const display = `@${handle}`; + + // Run a relationship mutation behind the auth gate, with a busy guard + // and toast on failure. `label` describes the action for error copy. + async function run( + fn: () => Promise, + onDone?: (r: RelationshipResult) => void, + ) { + if (busy) return; + if (!(await requireAuth())) return; + setBusy(true); + try { + const r = await fn(); + onResult(r); + onDone?.(r); + } catch (err) { + const msg = + err instanceof SocialError + ? err.message + : "Something went wrong. Please try again."; + toast.error(msg); + } finally { + setBusy(false); + } + } + + const follow = () => + run( + () => relationships.follow(handle), + () => toast(`Follow request sent to ${display}`, { duration: 2500 }), + ); + const unfollow = () => run(() => relationships.unfollow(handle)); + const accept = () => + run( + () => relationships.accept(handle), + () => toast(`${display} now follows you`, { duration: 2500 }), + ); + const reject = () => run(() => relationships.reject(handle)); + const removeFollower = () => + run( + () => relationships.removeFollower(handle), + () => toast(`Removed ${display} from your followers`, { duration: 2500 }), + ); + const unblock = () => + run( + () => relationships.unblock(handle), + () => toast(`Unblocked ${display}`, { duration: 2500 }), + ); + + // ── Primary button ─────────────────────────────────────────────────── + const blocked = !!rel?.blocked; + const blockedBy = !!rel?.blocked_by; + + let primary: React.ReactNode = null; + if (blocked) { + primary = ( + + ); + } else if (blockedBy) { + // They blocked the viewer — nothing to do but report (menu). + primary = null; + } else if (rel?.requests_you) { + primary = ( +
    + + +
    + ); + } else if (rel?.following) { + primary = ( + + ); + } else if (rel?.follow_requested) { + primary = ( + + ); + } else if (rel?.follows_you) { + primary = ( + + ); + } else { + primary = ( + + ); + } + + return ( + <> +
    + {primary} + {/* Block / Report / Remove are signed-in-only (their BFF routes + require auth) and only appear when `authed`. "More info" is + public, so the menu always renders with at least that item. */} + setBlockOpen(true)} + onUnblock={unblock} + onReport={() => setReportOpen(true)} + onAbout={() => setAboutOpen(true)} + /> +
    + + setBlockOpen(false)} + display={display} + onConfirm={async () => { + const r = await relationships.block(handle); + onResult(r); + toast(`Blocked ${display}`, { duration: 2500 }); + }} + /> + + setReportOpen(false)} + display={display} + onSubmit={async (category, reason) => { + await relationships.report(handle, category, reason); + toast(`Thanks — your report about ${display} was submitted`, { + duration: 3000, + }); + }} + /> + + setAboutOpen(false)} + display={display} + joinedAt={joinedAt} + country={country} + /> + + ); +} + +function Spinner() { + return ; +} + +// ─────────────────────────────────────────────────────────────────────────── +// Overflow (shield) menu — desktop popover / mobile drawer. +// ─────────────────────────────────────────────────────────────────────────── + +type MenuAction = { + key: string; + label: string; + icon: React.ReactNode; + onClick: () => void; + danger?: boolean; +}; + +function OverflowMenu({ + name, + display, + rel, + busy, + authed, + onRemoveFollower, + onBlock, + onUnblock, + onReport, + onAbout, +}: { + name: string; + display: string; + rel: RelationshipState | null; + busy: boolean; + /** Whether a viewer is signed in. Block/Report/Remove are gated on + * this; "More info" is shown to everyone. */ + authed: boolean; + onRemoveFollower: () => void; + onBlock: () => void; + onUnblock: () => void; + onReport: () => void; + onAbout: () => void; +}) { + const isMobile = useMediaQuery("(max-width: 767px)"); + const [open, setOpen] = useState(false); + const triggerRef = useRef(null); + const popoverRef = useRef(null); + const [popPos, setPopPos] = useState<{ top: number; left: number } | null>( + null, + ); + + // Desktop popover position — right-aligned under the trigger. We measure + // in event handlers (the open click + resize/scroll), never synchronously + // inside an effect body, so opening the menu can't trigger a cascading + // render. The effect below only wires the resize/scroll listeners. + const recompute = useCallback(() => { + const btn = triggerRef.current; + if (!btn) return; + const rect = btn.getBoundingClientRect(); + const w = 240; + const pad = 8; + let left = rect.right - w; + if (left < pad) left = pad; + if (left + w > window.innerWidth - pad) left = window.innerWidth - w - pad; + setPopPos({ top: rect.bottom + 6, left }); + }, []); + + useEffect(() => { + if (!open || isMobile) return; + window.addEventListener("resize", recompute); + window.addEventListener("scroll", recompute, true); + return () => { + window.removeEventListener("resize", recompute); + window.removeEventListener("scroll", recompute, true); + }; + }, [open, isMobile, recompute]); + + // Click-outside + Escape (desktop only). + useEffect(() => { + if (!open || isMobile) return; + function onPointer(e: PointerEvent) { + if (popoverRef.current?.contains(e.target as Node)) return; + if (triggerRef.current?.contains(e.target as Node)) return; + setOpen(false); + } + function onKey(e: KeyboardEvent) { + if (e.key === "Escape") setOpen(false); + } + const t = setTimeout(() => { + document.addEventListener("pointerdown", onPointer); + document.addEventListener("keydown", onKey); + }, 0); + return () => { + clearTimeout(t); + document.removeEventListener("pointerdown", onPointer); + document.removeEventListener("keydown", onKey); + }; + }, [open, isMobile]); + + const blocked = !!rel?.blocked; + const items: MenuAction[] = []; + // Signed-in-only moderation actions. + if (authed) { + if (rel?.follows_you && !blocked && !rel?.blocked_by) { + items.push({ + key: "remove", + label: "Remove from followers", + icon: , + onClick: () => { + setOpen(false); + onRemoveFollower(); + }, + }); + } + items.push( + blocked + ? { + key: "unblock", + label: "Unblock user", + icon: , + onClick: () => { + setOpen(false); + onUnblock(); + }, + } + : { + key: "block", + label: "Block user", + icon: , + danger: true, + onClick: () => { + setOpen(false); + onBlock(); + }, + }, + ); + items.push({ + key: "report", + label: "Report user", + icon: , + danger: true, + onClick: () => { + setOpen(false); + onReport(); + }, + }); + } + // Public — always at the bottom. + items.push({ + key: "about", + label: "More info", + icon: , + onClick: () => { + setOpen(false); + onAbout(); + }, + }); + + const trigger = ( + + ); + + if (isMobile) { + return ( + <> + {trigger} + + + + {display} + +
    + {items.map((it) => ( + + ))} +
    +
    +
    + + ); + } + + return ( + <> + {trigger} + {open && popPos + ? createPortal( +
    + {items.map((it) => ( + + ))} +
    , + document.body, + ) + : null} + + ); +} + +function MenuButton({ item, mobile = false }: { item: MenuAction; mobile?: boolean }) { + return ( + + ); +} + +// ─────────────────────────────────────────────────────────────────────────── +// ResponsiveModal — dialog on desktop, bottom drawer on mobile. +// ─────────────────────────────────────────────────────────────────────────── + +function ResponsiveModal({ + open, + onClose, + title, + description, + children, +}: { + open: boolean; + onClose: () => void; + title: string; + description?: string; + children: React.ReactNode; +}) { + const isMobile = useMediaQuery("(max-width: 767px)"); + + // Desktop: Escape + body-scroll lock (the drawer handles its own). + useEffect(() => { + if (!open || isMobile) return; + function onKey(e: KeyboardEvent) { + if (e.key === "Escape") onClose(); + } + document.addEventListener("keydown", onKey); + const prev = document.body.style.overflow; + document.body.style.overflow = "hidden"; + return () => { + document.removeEventListener("keydown", onKey); + document.body.style.overflow = prev; + }; + }, [open, isMobile, onClose]); + + if (!open) return null; + + if (isMobile) { + return ( + !o && onClose()}> + + + {title} + {description ? {description} : null} + +
    {children}
    +
    +
    + ); + } + + if (typeof document === "undefined") return null; + return createPortal( + <> +
    +
    +
    +
    +
    +

    + {title} +

    + {description ? ( +

    + {description} +

    + ) : null} +
    + +
    +
    {children}
    +
    +
    + , + document.body, + ); +} + +// ─────────────────────────────────────────────────────────────────────────── +// Block confirm +// ─────────────────────────────────────────────────────────────────────────── + +function BlockModal({ + open, + onClose, + display, + onConfirm, +}: { + open: boolean; + onClose: () => void; + display: string; + onConfirm: () => Promise; +}) { + const [working, setWorking] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + if (open) { + setWorking(false); + setError(null); + } + }, [open]); + + async function confirm() { + setWorking(true); + setError(null); + try { + await onConfirm(); + onClose(); + } catch (err) { + setError( + err instanceof Error ? err.message : "Something went wrong.", + ); + } finally { + setWorking(false); + } + } + + return ( + !working && onClose()} + title={`Block ${display}?`} + description={`They won't be able to follow you or see that you follow them. Any existing follow between you will be removed. You can unblock them later.`} + > + {error ?

    {error}

    : null} +
    + + +
    +
    + ); +} + +// ─────────────────────────────────────────────────────────────────────────── +// Report form +// ─────────────────────────────────────────────────────────────────────────── + +const REPORT_CATEGORIES: { value: ReportCategory; label: string }[] = [ + { value: "spam", label: "Spam or misleading" }, + { value: "harassment", label: "Harassment or bullying" }, + { value: "impersonation", label: "Impersonation" }, + { value: "inappropriate", label: "Inappropriate content" }, + { value: "other", label: "Something else" }, +]; + +function ReportModal({ + open, + onClose, + display, + onSubmit, +}: { + open: boolean; + onClose: () => void; + display: string; + onSubmit: (category: ReportCategory, reason: string) => Promise; +}) { + const [category, setCategory] = useState("spam"); + const [reason, setReason] = useState(""); + const [working, setWorking] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + if (open) { + setCategory("spam"); + setReason(""); + setWorking(false); + setError(null); + } + }, [open]); + + async function submit() { + if (reason.trim() === "") { + setError("Please add a brief reason."); + return; + } + setWorking(true); + setError(null); + try { + await onSubmit(category, reason.trim()); + onClose(); + } catch (err) { + setError( + err instanceof Error ? err.message : "Something went wrong.", + ); + } finally { + setWorking(false); + } + } + + return ( + !working && onClose()} + title={`Report ${display}`} + description="Reports are private and reviewed by our team." + > +
    +
    + +
    + {REPORT_CATEGORIES.map((c) => ( + + ))} +
    +
    +
    + +