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

433 lines
14 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, Eye, EyeOff, 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,
onHiddenChange,
}: {
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;
/** Owner-only: called after a successful hide/unhide so the parent can
* update its local copy of the post (e.g. show a "Hidden" badge). */
onHiddenChange?: (id: string, hidden: boolean) => 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);
// Optimistic local mirror of post.is_hidden so the menu label flips
// instantly on toggle without waiting for a parent refetch.
const [hidden, setHidden] = useState(!!post.is_hidden);
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.
// 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<string> {
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");
}
});
}
async function toggleHidden() {
setOpen(false);
const next = !hidden;
setHidden(next); // optimistic
try {
if (next) await posts.hide(post.id);
else await posts.unhide(post.id);
toast(next ? "Hidden from your timeline" : "Restored to your timeline", {
duration: 2500,
});
onHiddenChange?.(post.id, next);
} catch {
setHidden(!next); // revert
toast.error("Could not update post visibility");
}
}
// 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) {
// Hide / unhide from timelines + public collections (independent of the
// delete-with-modal surface, so it's always available to the owner).
items.push({
key: "hide",
label: hidden ? "Unhide from timeline" : "Hide from timeline",
icon: hidden ? (
<Eye className="size-4" />
) : (
<EyeOff className="size-4" />
),
onClick: () => {
void toggleHidden();
},
});
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>
);
}