364 lines
12 KiB
TypeScript
364 lines
12 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 { 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<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.
|
|
// 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.
|
|
async function copyCaption() {
|
|
setOpen(false);
|
|
try {
|
|
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);
|
|
await navigator.clipboard.writeText(lines.join("\n"));
|
|
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: <Copy className="size-4" />,
|
|
onClick: copyCaption,
|
|
});
|
|
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={() => {
|
|
// Measure synchronously in the click (an event handler — safe) so
|
|
// the popover renders at the right spot on first paint.
|
|
if (!open && !isMobile) recompute();
|
|
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 select-none">
|
|
<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 select-none",
|
|
"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>
|
|
);
|
|
}
|