From d41652d7489d2356ae59f915ee954c8ac6f51bfb Mon Sep 17 00:00:00 2001 From: Arnas Donauskas Date: Tue, 19 May 2026 16:32:34 +0300 Subject: [PATCH] fix(inbox): scope hover to the bubble; fix stale-closure reaction flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/components/inbox/message-actions.tsx | 12 ++- src/components/inbox/message-bubble.tsx | 52 +++++----- src/components/inbox/message-thread.tsx | 125 ++++++++++++----------- 3 files changed, 101 insertions(+), 88 deletions(-) diff --git a/src/components/inbox/message-actions.tsx b/src/components/inbox/message-actions.tsx index c617dea..bd63f6f 100644 --- a/src/components/inbox/message-actions.tsx +++ b/src/components/inbox/message-actions.tsx @@ -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 (
setTouchOpen(false)} > - {children} +
+ {children}
+
); } diff --git a/src/components/inbox/message-bubble.tsx b/src/components/inbox/message-bubble.tsx index 029555f..e24e839 100644 --- a/src/components/inbox/message-bubble.tsx +++ b/src/components/inbox/message-bubble.tsx @@ -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 so its hover + // group matches the bubble's content area, not the full row. return (
+ {reply && ( + + )} +
- {reply && ( - - )} - -
- {time} - {isAgent && } -
+ {time} + {isAgent && }
- {reactions && reactions.length > 0 && onToggleReaction && ( - - )}
+ {reactions && reactions.length > 0 && onToggleReaction && ( + + )}
); } diff --git a/src/components/inbox/message-thread.tsx b/src/components/inbox/message-thread.tsx index bcf181e..88fec7e 100644 --- a/src/components/inbox/message-thread.tsx +++ b/src/components/inbox/message-thread.tsx @@ -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 ( handleStartReply(msg)} - onReact={(emoji) => handleAddReaction(msg.id, emoji)} + onReact={(emoji) => { + if (emoji) void postReaction(msg.id, emoji); + }} > - handleToggleReaction(msg.id, emoji) - } + onToggleReaction={handlePillToggle} /> );