mirror of
https://github.com/Luchexx/wacrm.git
synced 2026-11-02 17:29:45 +00:00
Merge pull request #101 from ArnasDon/feature/chat-action-fixes
fix(inbox): scope hover to bubble; fix stale-closure reactions
This commit is contained in:
commit
3e9276b00f
@ -73,13 +73,20 @@ export function MessageActions({
|
|||||||
setTouchOpen(false);
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
className="group/actions relative"
|
className={cn(
|
||||||
|
"flex w-full",
|
||||||
|
isAgent ? "justify-end" : "justify-start",
|
||||||
|
)}
|
||||||
onContextMenu={handleContextMenu}
|
onContextMenu={handleContextMenu}
|
||||||
onBlur={() => setTouchOpen(false)}
|
onBlur={() => setTouchOpen(false)}
|
||||||
>
|
>
|
||||||
{children}
|
<div className="group/actions relative max-w-[75%]">
|
||||||
|
{children}
|
||||||
<div
|
<div
|
||||||
data-touch-open={touchOpen || pickerOpen ? "true" : undefined}
|
data-touch-open={touchOpen || pickerOpen ? "true" : undefined}
|
||||||
className={cn(
|
className={cn(
|
||||||
@ -130,6 +137,7 @@ export function MessageActions({
|
|||||||
<Copy className="h-3.5 w-3.5" />
|
<Copy className="h-3.5 w-3.5" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -231,46 +231,44 @@ export function MessageBubble({
|
|||||||
const isAgent = message.sender_type === "agent" || message.sender_type === "bot";
|
const isAgent = message.sender_type === "agent" || message.sender_type === "bot";
|
||||||
const time = format(new Date(message.created_at), "HH:mm");
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn("flex w-full", isAgent ? "justify-end" : "justify-start")}
|
className={cn(
|
||||||
|
"flex flex-col",
|
||||||
|
isAgent ? "items-end" : "items-start",
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex max-w-[75%] flex-col",
|
"relative rounded-2xl px-3 py-2",
|
||||||
isAgent ? "items-end" : "items-start",
|
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
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative rounded-2xl px-3 py-2",
|
"mt-1 flex items-center gap-1",
|
||||||
isAgent
|
isAgent ? "justify-end" : "justify-start",
|
||||||
? "rounded-br-md bg-violet-600 text-white"
|
|
||||||
: "rounded-bl-md bg-slate-800 text-slate-100",
|
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{reply && (
|
<span className="text-[10px] text-white/60">{time}</span>
|
||||||
<ReplyQuote authorLabel={reply.authorLabel} preview={reply.preview} />
|
{isAgent && <StatusIcon status={message.status} />}
|
||||||
)}
|
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
{reactions && reactions.length > 0 && onToggleReaction && (
|
|
||||||
<MessageReactions
|
|
||||||
reactions={reactions}
|
|
||||||
currentUserId={currentUserId}
|
|
||||||
onToggle={onToggleReaction}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
{reactions && reactions.length > 0 && onToggleReaction && (
|
||||||
|
<MessageReactions
|
||||||
|
reactions={reactions}
|
||||||
|
currentUserId={currentUserId}
|
||||||
|
onToggle={onToggleReaction}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -258,9 +258,24 @@ export function MessageThread({
|
|||||||
},
|
},
|
||||||
(payload) => {
|
(payload) => {
|
||||||
const row = payload.new as MessageReaction;
|
const row = payload.new as MessageReaction;
|
||||||
setReactions((prev) =>
|
setReactions((prev) => {
|
||||||
prev.some((r) => r.id === row.id) ? prev : [...prev, row],
|
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(
|
.on(
|
||||||
@ -505,69 +520,50 @@ export function MessageThread({
|
|||||||
[authorLabelFor],
|
[authorLabelFor],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Toggle (called from a reaction pill click). If the user already reacted
|
// Single reaction-set primitive. emoji === "" removes; otherwise adds/swaps.
|
||||||
// with `emoji` → send "" to remove. Otherwise send `emoji` to add/swap.
|
// The "toggle" semantic (pill click) is computed at the call site where the
|
||||||
const handleToggleReaction = useCallback(
|
// current reactions for the bubble are already in scope — keeps this
|
||||||
async (messageId: string, emoji: string) => {
|
// function dependency-free w.r.t. the reaction list.
|
||||||
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
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
|
|
||||||
const postReaction = useCallback(
|
const postReaction = useCallback(
|
||||||
async (messageId: string, emoji: string) => {
|
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
|
const convId = conversation.id;
|
||||||
// pill flips immediately.
|
const userId = user.id;
|
||||||
const prev = reactions;
|
let snapshot: MessageReaction[] = [];
|
||||||
const own = prev.find(
|
|
||||||
(r) =>
|
|
||||||
r.message_id === messageId &&
|
|
||||||
r.actor_type === "agent" &&
|
|
||||||
r.actor_id === user.id,
|
|
||||||
);
|
|
||||||
|
|
||||||
let next: MessageReaction[];
|
// Functional updater — captures the freshest reactions list, never a
|
||||||
if (emoji === "") {
|
// stale closure. Snapshot stored for rollback on POST failure.
|
||||||
next = prev.filter((r) => r !== own);
|
setReactions((prev) => {
|
||||||
} else if (own) {
|
snapshot = prev;
|
||||||
next = prev.map((r) => (r === own ? { ...own, emoji } : r));
|
const own = prev.find(
|
||||||
} else {
|
(r) =>
|
||||||
next = [
|
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,
|
...prev,
|
||||||
{
|
{
|
||||||
id: `temp-${Date.now()}`,
|
id: `temp-${Date.now()}`,
|
||||||
message_id: messageId,
|
message_id: messageId,
|
||||||
conversation_id: conversation.id,
|
conversation_id: convId,
|
||||||
actor_type: "agent",
|
actor_type: "agent",
|
||||||
actor_id: user.id,
|
actor_id: userId,
|
||||||
emoji,
|
emoji,
|
||||||
created_at: new Date().toISOString(),
|
created_at: new Date().toISOString(),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
}
|
});
|
||||||
setReactions(next);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch("/api/whatsapp/react", {
|
const res = await fetch("/api/whatsapp/react", {
|
||||||
@ -582,10 +578,10 @@ export function MessageThread({
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
const reason = err instanceof Error ? err.message : "network error";
|
const reason = err instanceof Error ? err.message : "network error";
|
||||||
toast.error(`Reaction failed: ${reason}`);
|
toast.error(`Reaction failed: ${reason}`);
|
||||||
setReactions(prev);
|
setReactions(snapshot);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[reactions, conversation, user?.id],
|
[conversation, user?.id],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleAssignChange = useCallback(
|
const handleAssignChange = useCallback(
|
||||||
@ -794,21 +790,32 @@ export function MessageThread({
|
|||||||
}
|
}
|
||||||
: null;
|
: null;
|
||||||
const msgReactions = reactionsByMessageId.get(msg.id);
|
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 (
|
return (
|
||||||
<MessageActions
|
<MessageActions
|
||||||
key={msg.id}
|
key={msg.id}
|
||||||
message={msg}
|
message={msg}
|
||||||
onReply={() => handleStartReply(msg)}
|
onReply={() => handleStartReply(msg)}
|
||||||
onReact={(emoji) => handleAddReaction(msg.id, emoji)}
|
onReact={(emoji) => {
|
||||||
|
if (emoji) void postReaction(msg.id, emoji);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<MessageBubble
|
<MessageBubble
|
||||||
message={msg}
|
message={msg}
|
||||||
reply={reply}
|
reply={reply}
|
||||||
reactions={msgReactions}
|
reactions={msgReactions}
|
||||||
currentUserId={user?.id}
|
currentUserId={user?.id}
|
||||||
onToggleReaction={(emoji) =>
|
onToggleReaction={handlePillToggle}
|
||||||
handleToggleReaction(msg.id, emoji)
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
</MessageActions>
|
</MessageActions>
|
||||||
);
|
);
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user