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>
This commit is contained in:
Arnas Donauskas 2026-05-19 16:32:34 +03:00
parent a1348ebcbd
commit d41652d748
No known key found for this signature in database
3 changed files with 101 additions and 88 deletions

View File

@ -73,13 +73,20 @@ export function MessageActions({
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="group/actions relative"
className={cn(
"flex w-full",
isAgent ? "justify-end" : "justify-start",
)}
onContextMenu={handleContextMenu}
onBlur={() => setTouchOpen(false)}
>
{children}
<div className="group/actions relative max-w-[75%]">
{children}
<div
data-touch-open={touchOpen || pickerOpen ? "true" : undefined}
className={cn(
@ -130,6 +137,7 @@ export function MessageActions({
<Copy className="h-3.5 w-3.5" />
</button>
</div>
</div>
</div>
);
}

View File

@ -231,46 +231,44 @@ export function MessageBubble({
const isAgent = message.sender_type === "agent" || message.sender_type === "bot";
const time = format(new Date(message.created_at), "HH:mm");
// Row alignment + width cap are owned by <MessageActions> so its hover
// group matches the bubble's content area, not the full row.
return (
<div
className={cn("flex w-full", isAgent ? "justify-end" : "justify-start")}
className={cn(
"flex flex-col",
isAgent ? "items-end" : "items-start",
)}
>
<div
className={cn(
"flex max-w-[75%] flex-col",
isAgent ? "items-end" : "items-start",
"relative rounded-2xl px-3 py-2",
isAgent
? "rounded-br-md bg-violet-600 text-white"
: "rounded-bl-md bg-slate-800 text-slate-100",
)}
>
{reply && (
<ReplyQuote authorLabel={reply.authorLabel} preview={reply.preview} />
)}
<MessageContent message={message} />
<div
className={cn(
"relative rounded-2xl px-3 py-2",
isAgent
? "rounded-br-md bg-violet-600 text-white"
: "rounded-bl-md bg-slate-800 text-slate-100",
"mt-1 flex items-center gap-1",
isAgent ? "justify-end" : "justify-start",
)}
>
{reply && (
<ReplyQuote authorLabel={reply.authorLabel} preview={reply.preview} />
)}
<MessageContent message={message} />
<div
className={cn(
"mt-1 flex items-center gap-1",
isAgent ? "justify-end" : "justify-start",
)}
>
<span className="text-[10px] text-white/60">{time}</span>
{isAgent && <StatusIcon status={message.status} />}
</div>
<span className="text-[10px] text-white/60">{time}</span>
{isAgent && <StatusIcon status={message.status} />}
</div>
{reactions && reactions.length > 0 && onToggleReaction && (
<MessageReactions
reactions={reactions}
currentUserId={currentUserId}
onToggle={onToggleReaction}
/>
)}
</div>
{reactions && reactions.length > 0 && onToggleReaction && (
<MessageReactions
reactions={reactions}
currentUserId={currentUserId}
onToggle={onToggleReaction}
/>
)}
</div>
);
}

View File

@ -258,9 +258,24 @@ export function MessageThread({
},
(payload) => {
const row = payload.new as MessageReaction;
setReactions((prev) =>
prev.some((r) => r.id === row.id) ? prev : [...prev, row],
);
setReactions((prev) => {
if (prev.some((r) => r.id === row.id)) return prev;
// Swap any matching optimistic temp row for the real one so
// the pill doesn't double up after a successful POST.
const tempIdx = prev.findIndex(
(r) =>
r.id.startsWith("temp-") &&
r.message_id === row.message_id &&
r.actor_type === row.actor_type &&
r.actor_id === row.actor_id,
);
if (tempIdx >= 0) {
const copy = prev.slice();
copy[tempIdx] = row;
return copy;
}
return [...prev, row];
});
},
)
.on(
@ -505,69 +520,50 @@ export function MessageThread({
[authorLabelFor],
);
// Toggle (called from a reaction pill click). If the user already reacted
// with `emoji` → send "" to remove. Otherwise send `emoji` to add/swap.
const handleToggleReaction = useCallback(
async (messageId: string, emoji: string) => {
if (!user?.id) return;
const current = reactions.find(
(r) =>
r.message_id === messageId &&
r.actor_type === "agent" &&
r.actor_id === user.id,
);
const nextEmoji = current?.emoji === emoji ? "" : emoji;
await postReaction(messageId, nextEmoji);
},
// postReaction is defined below; React only needs the closure refs.
// eslint-disable-next-line react-hooks/exhaustive-deps
[reactions, user?.id],
);
// Add or swap to a specific emoji (called from the emoji picker bar).
const handleAddReaction = useCallback(
async (messageId: string, emoji: string) => {
if (!emoji) return;
await postReaction(messageId, emoji);
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[],
);
// Single reaction-set primitive. emoji === "" removes; otherwise adds/swaps.
// The "toggle" semantic (pill click) is computed at the call site where the
// current reactions for the bubble are already in scope — keeps this
// function dependency-free w.r.t. the reaction list.
const postReaction = useCallback(
async (messageId: string, emoji: string) => {
if (!user?.id || !conversation) return;
if (!user?.id || !conversation) {
console.warn("[reactions] missing user or conversation");
return;
}
if (messageId.startsWith("temp-")) {
toast.error("Wait for the message to finish sending");
return;
}
// Snapshot for rollback. Optimistically update local state so the
// pill flips immediately.
const prev = reactions;
const own = prev.find(
(r) =>
r.message_id === messageId &&
r.actor_type === "agent" &&
r.actor_id === user.id,
);
const convId = conversation.id;
const userId = user.id;
let snapshot: MessageReaction[] = [];
let next: MessageReaction[];
if (emoji === "") {
next = prev.filter((r) => r !== own);
} else if (own) {
next = prev.map((r) => (r === own ? { ...own, emoji } : r));
} else {
next = [
// Functional updater — captures the freshest reactions list, never a
// stale closure. Snapshot stored for rollback on POST failure.
setReactions((prev) => {
snapshot = prev;
const own = prev.find(
(r) =>
r.message_id === messageId &&
r.actor_type === "agent" &&
r.actor_id === userId,
);
if (emoji === "") return own ? prev.filter((r) => r !== own) : prev;
if (own) return prev.map((r) => (r === own ? { ...own, emoji } : r));
return [
...prev,
{
id: `temp-${Date.now()}`,
message_id: messageId,
conversation_id: conversation.id,
conversation_id: convId,
actor_type: "agent",
actor_id: user.id,
actor_id: userId,
emoji,
created_at: new Date().toISOString(),
},
];
}
setReactions(next);
});
try {
const res = await fetch("/api/whatsapp/react", {
@ -582,10 +578,10 @@ export function MessageThread({
} catch (err) {
const reason = err instanceof Error ? err.message : "network error";
toast.error(`Reaction failed: ${reason}`);
setReactions(prev);
setReactions(snapshot);
}
},
[reactions, conversation, user?.id],
[conversation, user?.id],
);
const handleAssignChange = useCallback(
@ -794,21 +790,32 @@ export function MessageThread({
}
: null;
const msgReactions = reactionsByMessageId.get(msg.id);
// Toggle is computed at the call site — `msgReactions`
// and `user?.id` are already in scope, no extra hook.
const handlePillToggle = (emoji: string) => {
const own = msgReactions?.find(
(r) =>
r.actor_type === "agent" &&
r.actor_id === user?.id,
);
const next = own?.emoji === emoji ? "" : emoji;
void postReaction(msg.id, next);
};
return (
<MessageActions
key={msg.id}
message={msg}
onReply={() => handleStartReply(msg)}
onReact={(emoji) => handleAddReaction(msg.id, emoji)}
onReact={(emoji) => {
if (emoji) void postReaction(msg.id, emoji);
}}
>
<MessageBubble
message={msg}
reply={reply}
reactions={msgReactions}
currentUserId={user?.id}
onToggleReaction={(emoji) =>
handleToggleReaction(msg.id, emoji)
}
onToggleReaction={handlePillToggle}
/>
</MessageActions>
);