Files
spsays-training/components/chat/message-menu.tsx
T

192 lines
5.0 KiB
TypeScript

"use client";
/**
* The message context menu: reply, copy, star, forward, delete, report.
*
* Opened by long-press on touch and right-click on desktop, both arbitrated by
* MessageGestures. Positioned at the pointer and clamped to the viewport, so a
* bubble near an edge doesn't push the menu off-screen.
*/
import { useEffect, useLayoutEffect, useRef, useState } from "react";
import {
Copy,
Flag,
Forward,
Reply,
Star,
StarOff,
Trash2,
} from "lucide-react";
import { ReactionRow } from "@/components/chat/reaction-row";
import { cn } from "@/lib/utils";
export type MenuPoint = { x: number; y: number };
export type MessageMenuActions = {
onReply: () => void;
onCopy: () => void;
onStar: () => void;
onForward: () => void;
onDeleteForMe: () => void;
/** Absent when the caller isn't the sender — only they may erase for all. */
onDeleteForEveryone?: () => void;
onReport?: () => void;
};
const MARGIN = 8;
export function MessageMenu({
point,
starred,
myReaction,
onReact,
actions,
onClose,
}: {
point: MenuPoint;
starred: boolean;
/** The viewer's current emoji on this message, if any. */
myReaction?: string;
/** Picking the same emoji again is a toggle — the caller decides. */
onReact: (emoji: string) => void;
actions: MessageMenuActions;
onClose: () => void;
}) {
const ref = useRef<HTMLDivElement>(null);
const [pos, setPos] = useState<MenuPoint>(point);
// Clamp after measuring, before paint, so the menu never flashes off-screen.
useLayoutEffect(() => {
const el = ref.current;
if (!el) return;
const { width, height } = el.getBoundingClientRect();
setPos({
x: Math.min(Math.max(MARGIN, point.x), window.innerWidth - width - MARGIN),
y: Math.min(Math.max(MARGIN, point.y), window.innerHeight - height - MARGIN),
});
}, [point.x, point.y]);
useEffect(() => {
const onKey = (e: KeyboardEvent) => e.key === "Escape" && onClose();
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [onClose]);
const run = (fn?: () => void) => () => {
fn?.();
onClose();
};
return (
<>
{/* Catches the next click or scroll anywhere and dismisses. */}
<div
className="fixed inset-0 z-40"
onClick={onClose}
onContextMenu={(e) => {
e.preventDefault();
onClose();
}}
/>
<div
ref={ref}
role="menu"
style={{ left: pos.x, top: pos.y }}
// min-w-56 rather than something tighter: "Delete for everyone" is the
// longest label and, with its icon and padding, was wrapping or sitting
// flush against the edge at a narrower width.
className="fixed z-50 min-w-56 select-none overflow-hidden rounded-xl border border-border bg-background pb-1 shadow-lg"
>
{/* Reactions lead: they are the most common thing to want from a
long press, and putting them in this menu keeps one gesture
responsible for one surface. */}
<ReactionRow
current={myReaction}
onPick={(emoji) => {
onReact(emoji);
onClose();
}}
/>
<Item icon={<Reply className="size-4" />} onClick={run(actions.onReply)}>
Reply
</Item>
<Item icon={<Copy className="size-4" />} onClick={run(actions.onCopy)}>
Copy
</Item>
<Item
icon={
starred ? <StarOff className="size-4" /> : <Star className="size-4" />
}
onClick={run(actions.onStar)}
>
{starred ? "Remove star" : "Star"}
</Item>
<Item
icon={<Forward className="size-4" />}
onClick={run(actions.onForward)}
>
Forward
</Item>
<div className="my-1 h-px bg-border" />
<Item
icon={<Trash2 className="size-4" />}
onClick={run(actions.onDeleteForMe)}
>
Delete for me
</Item>
{actions.onDeleteForEveryone && (
<Item
danger
icon={<Trash2 className="size-4" />}
onClick={run(actions.onDeleteForEveryone)}
>
Delete for everyone
</Item>
)}
{actions.onReport && (
<Item
danger
icon={<Flag className="size-4" />}
onClick={run(actions.onReport)}
>
Report
</Item>
)}
</div>
</>
);
}
function Item({
icon,
onClick,
danger = false,
children,
}: {
icon: React.ReactNode;
onClick: () => void;
danger?: boolean;
children: React.ReactNode;
}) {
return (
<button
type="button"
role="menuitem"
onClick={onClick}
className={cn(
"flex w-full items-center gap-2.5 px-3 py-2 text-left text-sm transition-colors",
danger
? "text-destructive hover:bg-destructive/10"
: "text-foreground hover:bg-muted",
)}
>
{icon}
{children}
</button>
);
}