"use client"; /** * PostCardMenu — three-dot overflow menu shown in the post header. * * Items rendered are decided per viewer: * * • Author (you wrote the post) — only `Delete post`. * • Authenticated non-author — `Block @author` and `Report post`. * • Anonymous non-author — `Report post` only. * * The author never sees Block / Report on their own post (you can't * block yourself, and self-report doesn't make sense). Block + Report * are placeholder coming-soon toasts today; the slot is wired up so * shipping the actual flows is a one-line swap each. * * Trigger renders a dropdown on desktop and a bottom drawer on mobile, * matching the SharePostSheet / CollectionsDrawer convention used * elsewhere in the post action UI. */ import { useCallback, useEffect, useRef, useState } from "react"; import { createPortal } from "react-dom"; import { Ban, Copy, Flag, MoreVertical, Trash2 } from "lucide-react"; import { toast } from "sonner"; import { Drawer, DrawerContent, DrawerHeader, DrawerTitle, } from "@/components/ui/drawer"; import { cn } from "@/lib/utils"; import { posts, relationships, type Post, type PostReportCategory, } from "@/lib/social"; import { useAuth } from "@/components/auth/auth-provider"; import { useAuthGate } from "@/components/auth/auth-gate"; import { useMediaQuery } from "@/components/selection-toolbar/shared"; import { ConfirmModal } from "@/components/ui/confirm-modal"; import { ReportDialog, type ReportOption } from "@/components/social/report-dialog"; import { createShortLink } from "@/lib/share"; import { structuredReferenceForPost } from "@/lib/source-reference"; // Post report categories — mirrors the BFF allow-list (postReportCategories). const POST_REPORT_OPTIONS: ReportOption[] = [ { value: "spam", label: "Spam or misleading" }, { value: "harassment", label: "Harassment or abuse" }, { value: "misinformation", label: "False information" }, { value: "inappropriate", label: "Inappropriate content" }, { value: "other", label: "Something else" }, ]; type MenuItem = { key: string; label: string; icon: React.ReactNode; onClick: () => void; variant?: "default" | "destructive"; }; export function PostCardMenu({ post, onDelete, }: { post: Post; /** Owner-only: when provided AND the viewer is the author, the menu * exposes a Delete option. Parent receives the post id and is * expected to confirm + invoke the delete itself (matches the * existing /u/me Delete-with-modal pattern). */ onDelete?: (id: string) => void; }) { const { user, loading } = useAuth(); const { requireAuth } = useAuthGate(); const isMobile = useMediaQuery("(max-width: 767px)"); const [open, setOpen] = useState(false); const [blockOpen, setBlockOpen] = useState(false); const [reportOpen, setReportOpen] = useState(false); const triggerRef = useRef(null); const popoverRef = useRef(null); const [popPos, setPopPos] = useState<{ top: number; left: number } | null>( null, ); const isOwner = !loading && !!user && user.id === post.user_id; const isAuthed = !loading && !!user; const authorHandle = post.author_handle ?? null; const authorDisplay = authorHandle ? `@${authorHandle}` : post.author_name || "this user"; // Desktop popover positioning: anchor below the trigger, right-aligned. // We measure in event handlers (the open click + resize/scroll), never // synchronously inside an effect body, so opening can't trigger a // cascading render. The effect below only wires the resize/scroll // listeners while the popover is open. const recompute = useCallback(() => { const btn = triggerRef.current; if (!btn) return; const rect = btn.getBoundingClientRect(); const popoverW = 240; const pad = 8; // Right-align the popover to the trigger's right edge — feels natural // for an overflow menu sitting in the top-right corner. let left = rect.right - popoverW; if (left < pad) left = pad; if (left + popoverW > window.innerWidth - pad) { left = window.innerWidth - popoverW - pad; } setPopPos({ top: rect.bottom + 4, 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 to close (desktop only; the drawer's own // overlay handles dismissal on mobile). useEffect(() => { if (!open || isMobile) return; function onPointer(e: PointerEvent) { const popover = popoverRef.current; const trigger = triggerRef.current; if (popover && popover.contains(e.target as Node)) return; if (trigger && trigger.contains(e.target as Node)) return; setOpen(false); } function onKey(e: KeyboardEvent) { if (e.key === "Escape") setOpen(false); } // Delay the listener install one tick so the click that opened us // doesn't immediately close us in the same task. 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]); // Copy the post's caption + source citation + a (short) link to the post — // the same payload the share sheet / footer produce. Available to every // viewer. // Build the copy payload — caption + citation + a short link to the post. // The short link is a network round-trip, so we must NOT await it before // touching the clipboard or we lose the click's transient user activation // (the write then fails on the first click and only lands on the second, // once the link is cached). See `useCopyFeedback` for the same fix. async function buildCaptionText(): Promise { const origin = window.location.origin; let url = ""; if (authorHandle) { const longPath = `/u/${encodeURIComponent(authorHandle)}/posts#post-${post.id}`; const code = await createShortLink(longPath); url = code ? new URL(`/share/${code}`, origin).toString() : new URL(longPath, origin).toString(); } const ref = structuredReferenceForPost(post.source_url, post.source_title); const lines: string[] = []; if (post.caption) lines.push(`“${post.caption}”`, ""); lines.push("Source:", ref.title); if (ref.detail) lines.push(ref.detail); if (url) lines.push(url); return lines.join("\n"); } function copyCaption() { setOpen(false); // Hand the clipboard a *Promise* of the text via ClipboardItem so the // browser keeps the gesture alive while the short link resolves — one // click copies cleanly. if (typeof ClipboardItem !== "undefined" && navigator.clipboard?.write) { try { const item = new ClipboardItem({ "text/plain": buildCaptionText().then( (s) => new Blob([s], { type: "text/plain" }), ), }); navigator.clipboard.write([item]).then( () => toast.success("Caption copied"), () => toast.error("Could not copy"), ); return; } catch { // ClipboardItem-with-promise unsupported → fall through to the // await + writeText path below. } } void buildCaptionText().then(async (text) => { try { await navigator.clipboard.writeText(text); toast.success("Caption copied"); } catch { toast.error("Could not copy"); } }); } // Build the items list AFTER all hooks have run so a conditional // early-return doesn't break the rules-of-hooks contract on // re-renders where viewer state changes. const items: MenuItem[] = []; // Copy caption — first item for everyone (owner and non-owner alike). items.push({ key: "copy", label: "Copy caption", icon: , onClick: copyCaption, }); if (isOwner) { if (onDelete) { items.push({ key: "delete", label: "Delete post", icon: , onClick: () => { setOpen(false); onDelete(post.id); }, variant: "destructive", }); } } else { // Block needs the author's handle (the block API is keyed by handle). if (isAuthed && authorHandle) { items.push({ key: "block", label: `Block ${authorDisplay}`, icon: , onClick: () => { setOpen(false); setBlockOpen(true); }, }); } items.push({ key: "report", label: "Report post", icon: , onClick: () => { setOpen(false); // Reporting requires auth (the BFF route is protected). Gate // anonymous viewers through the login modal, then open the form. void (async () => { if (await requireAuth()) setReportOpen(true); })(); }, }); } // Don't render the trigger when there's literally nothing to click — // e.g. an owner-viewer on a surface that doesn't grant delete. if (items.length === 0) return null; const trigger = ( ); // Menu surface: bottom drawer on mobile, floating popover on desktop. const menu = isMobile ? ( setOpen(o)}> Post options
{items.map((item) => ( ))}
) : open && popPos ? ( createPortal(
{items.map((item) => ( ))}
, document.body, ) ) : null; return ( <> {trigger} {menu} {/* Block confirm — only meaningful when we know the author's handle. */} {authorHandle ? ( setBlockOpen(false)} title={`Block ${authorDisplay}?`} message="They won't be able to follow you or see that you follow them, and any follow between you will be removed. You can unblock them later." confirmLabel="Block" onConfirm={async () => { await relationships.block(authorHandle); toast(`Blocked ${authorDisplay}`, { duration: 2500 }); }} /> ) : null} setReportOpen(false)} title="Report post" options={POST_REPORT_OPTIONS} onSubmit={async (category, reason) => { await posts.report(post.id, category as PostReportCategory, reason); toast("Thanks — your report was submitted", { duration: 3000 }); }} /> ); } function MenuItemButton({ item, mobile = false, }: { item: MenuItem; mobile?: boolean; }) { const destructive = item.variant === "destructive"; return ( ); }