"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 { useEffect, useRef, useState } from "react"; import { createPortal } from "react-dom"; import { Ban, 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 type { Post } from "@/lib/social"; import { useAuth } from "@/components/auth/auth-provider"; import { useMediaQuery } from "@/components/selection-toolbar/shared"; 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 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, ); 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. // Recompute every time we open + on resize so a viewport change between // renders doesn't strand the popover at a stale coordinate. useEffect(() => { if (!open || isMobile) { setPopPos(null); return; } function recompute() { 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; } const top = rect.bottom + 4; setPopPos({ top, left }); } recompute(); window.addEventListener("resize", recompute); window.addEventListener("scroll", recompute, true); return () => { window.removeEventListener("resize", recompute); window.removeEventListener("scroll", recompute, true); }; }, [open, isMobile]); // 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]); // 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[] = []; if (isOwner) { if (onDelete) { items.push({ key: "delete", label: "Delete post", icon: , onClick: () => { setOpen(false); onDelete(post.id); }, variant: "destructive", }); } } else { if (isAuthed) { items.push({ key: "block", label: `Block ${authorDisplay}`, icon: , onClick: () => { setOpen(false); toast(`Blocking ${authorDisplay} is coming soon`, { duration: 2500, }); }, }); } items.push({ key: "report", label: "Report post", icon: , onClick: () => { setOpen(false); toast("Post reporting is coming soon", { duration: 2500 }); }, }); } // 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 = ( ); // Mobile: bottom drawer if (isMobile) { return ( <> {trigger} setOpen(o)}> Post options
{items.map((item) => ( ))}
); } // Desktop: floating popover anchored to the trigger. return ( <> {trigger} {open && popPos ? createPortal(
{items.map((item) => ( ))}
, document.body, ) : null} ); } function MenuItemButton({ item, mobile = false, }: { item: MenuItem; mobile?: boolean; }) { const destructive = item.variant === "destructive"; return ( ); }