wacrm/src/components/inbox/message-actions.tsx
Arnas Donauskas d41652d748
fix(inbox): scope hover to the bubble; fix stale-closure reaction flow
Two follow-ups to the chat-actions PR:

1. Action toolbar was revealing across the whole message row, not just
   over the bubble. The `group/actions` div in MessageActions wrapped
   the bubble's full-width `flex w-full justify-end/start` row, so
   anywhere in that row triggered group-hover. Moved row alignment +
   max-w-[75%] cap onto MessageActions and reduced MessageBubble to a
   content-sized stack. Hover area now matches the bubble shape.

2. Agent reactions weren't appearing locally. `handleAddReaction` was
   wrapped in `useCallback` with a `[]` dep array, so it captured the
   first-render `postReaction` — whose own closure held a stale
   `reactions = []`, stale `conversation`, and stale `user`. Every
   optimistic update replaced the reactions list with `[onlyMyTemp]`
   and the API POST may have hit a null conversation. Dropped both
   wrappers; postReaction is now called directly from JSX and uses a
   functional `setReactions((prev) => ...)` so each call sees fresh
   state. Toggle semantics for pill-click are computed at the call site
   where `msgReactions` and `user?.id` are already in scope. Realtime
   INSERT now swaps a matching optimistic temp row instead of stacking
   a duplicate.

Also: reject `temp-*` message IDs with a friendly toast so users can't
react to a bubble that hasn't reached Meta yet.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 16:32:34 +03:00

144 lines
4.5 KiB
TypeScript

"use client";
import { useState, type ReactNode } from "react";
import { CornerUpLeft, Copy, SmilePlus } from "lucide-react";
import { toast } from "sonner";
import { cn } from "@/lib/utils";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import type { Message } from "@/types";
// WhatsApp's own quick-reaction bar starts with these six. Picking the same
// set keeps the affordance familiar without pulling in a 300KB emoji library.
const QUICK_EMOJIS = ["👍", "❤️", "😂", "😮", "😢", "🙏"];
interface MessageActionsProps {
message: Message;
onReply: () => void;
onReact: (emoji: string) => void;
children: ReactNode;
}
/**
* Hover/long-press toolbar wrapper around a `<MessageBubble>`. The bubble
* itself stays a pure presenter — this component owns the action surface so
* the bubble's render path is unaffected when the toolbar isn't visible.
*/
export function MessageActions({
message,
onReply,
onReact,
children,
}: MessageActionsProps) {
// Touch devices have no hover. Long-press fires `contextmenu`; we capture
// it, suppress the native menu, and pin the toolbar open until the user
// interacts elsewhere.
const [touchOpen, setTouchOpen] = useState(false);
const [pickerOpen, setPickerOpen] = useState(false);
const isAgent =
message.sender_type === "agent" || message.sender_type === "bot";
const handleContextMenu = (e: React.MouseEvent) => {
e.preventDefault();
setTouchOpen(true);
};
const handleCopy = async () => {
const text = message.content_text ?? "";
if (!text) {
toast.error("Nothing to copy");
return;
}
try {
await navigator.clipboard.writeText(text);
toast.success("Copied");
} catch {
toast.error("Copy failed");
}
setTouchOpen(false);
};
const handlePickEmoji = (emoji: string) => {
onReact(emoji);
setPickerOpen(false);
setTouchOpen(false);
};
const handleReply = () => {
onReply();
setTouchOpen(false);
};
// Row alignment lives here (not in MessageBubble) so the `group/actions`
// hover region matches the bubble's content width — hovering empty space
// in the row no longer reveals the toolbar.
return (
<div
className={cn(
"flex w-full",
isAgent ? "justify-end" : "justify-start",
)}
onContextMenu={handleContextMenu}
onBlur={() => setTouchOpen(false)}
>
<div className="group/actions relative max-w-[75%]">
{children}
<div
data-touch-open={touchOpen || pickerOpen ? "true" : undefined}
className={cn(
"absolute -top-3 z-10 flex h-7 items-center gap-0.5 rounded-full border border-slate-700 bg-slate-900/95 px-1 shadow-md backdrop-blur-sm transition-opacity",
"opacity-0 group-hover/actions:opacity-100 group-focus-within/actions:opacity-100",
"data-[touch-open=true]:opacity-100",
isAgent ? "right-3" : "left-3",
)}
>
<Popover open={pickerOpen} onOpenChange={setPickerOpen}>
<PopoverTrigger
className="flex h-5 w-5 items-center justify-center rounded-full text-slate-300 hover:bg-slate-700 hover:text-white"
aria-label="React"
>
<SmilePlus className="h-3.5 w-3.5" />
</PopoverTrigger>
<PopoverContent
className="flex w-auto flex-row gap-1 p-1.5"
sideOffset={6}
>
{QUICK_EMOJIS.map((e) => (
<button
key={e}
type="button"
onClick={() => handlePickEmoji(e)}
className="flex h-8 w-8 items-center justify-center rounded-full text-lg leading-none transition-transform hover:scale-125 hover:bg-slate-700"
aria-label={`React with ${e}`}
>
{e}
</button>
))}
</PopoverContent>
</Popover>
<button
type="button"
onClick={handleReply}
className="flex h-5 w-5 items-center justify-center rounded-full text-slate-300 hover:bg-slate-700 hover:text-white"
aria-label="Reply"
>
<CornerUpLeft className="h-3.5 w-3.5" />
</button>
<button
type="button"
onClick={handleCopy}
className="flex h-5 w-5 items-center justify-center rounded-full text-slate-300 hover:bg-slate-700 hover:text-white"
aria-label="Copy"
>
<Copy className="h-3.5 w-3.5" />
</button>
</div>
</div>
</div>
);
}