"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) => ( ))}