Files
spsays-training/components/social/post-card-menu.tsx
T
2026-06-04 19:30:50 +05:30

324 lines
10 KiB
TypeScript

"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 {
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";
// 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<HTMLButtonElement>(null);
const popoverRef = useRef<HTMLDivElement>(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: <Trash2 className="size-4" />,
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: <Ban className="size-4" />,
onClick: () => {
setOpen(false);
setBlockOpen(true);
},
});
}
items.push({
key: "report",
label: "Report post",
icon: <Flag className="size-4" />,
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 = (
<button
ref={triggerRef}
type="button"
onClick={() => setOpen((v) => !v)}
aria-label="Post options"
aria-haspopup="menu"
aria-expanded={open}
className={cn(
"size-8 inline-flex items-center justify-center rounded-full shrink-0",
"text-muted-foreground hover:text-foreground hover:bg-muted",
"transition-colors outline-none focus-visible:ring-2 focus-visible:ring-ring",
)}
>
<MoreVertical className="size-4" />
</button>
);
// Menu surface: bottom drawer on mobile, floating popover on desktop.
const menu = isMobile ? (
<Drawer open={open} onOpenChange={(o) => setOpen(o)}>
<DrawerContent className="bg-card">
<DrawerHeader className="text-left border-b border-border pb-3">
<DrawerTitle>Post options</DrawerTitle>
</DrawerHeader>
<div className="p-3 space-y-1">
{items.map((item) => (
<MenuItemButton key={item.key} item={item} mobile />
))}
</div>
</DrawerContent>
</Drawer>
) : open && popPos ? (
createPortal(
<div
ref={popoverRef}
role="menu"
style={{
position: "fixed",
top: popPos.top,
left: popPos.left,
zIndex: 60,
}}
className={cn(
"w-60 rounded-lg border border-border bg-card shadow-lg py-1",
"animate-in fade-in-0 zoom-in-95",
)}
>
{items.map((item) => (
<MenuItemButton key={item.key} item={item} />
))}
</div>,
document.body,
)
) : null;
return (
<>
{trigger}
{menu}
{/* Block confirm — only meaningful when we know the author's handle. */}
{authorHandle ? (
<ConfirmModal
open={blockOpen}
onClose={() => 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}
<ReportDialog
open={reportOpen}
onClose={() => 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 (
<button
type="button"
role="menuitem"
onClick={item.onClick}
className={cn(
"w-full flex items-center gap-3 text-left transition-colors rounded-md",
mobile ? "px-3 py-3" : "px-3 py-2",
destructive
? "text-destructive hover:bg-destructive/10"
: "text-foreground hover:bg-muted",
)}
>
<span
className={cn(
"shrink-0",
destructive ? "text-destructive" : "text-muted-foreground",
)}
>
{item.icon}
</span>
<span className="text-sm font-medium truncate">{item.label}</span>
</button>
);
}