wacrm/src/components/inbox/message-reactions.tsx
Arnas Donauskas 339b1fe94f
feat(inbox): reactions, reply-to, and copy on chat messages
Adds three WhatsApp-standard affordances to the inbox: per-message
emoji reactions, reply-with-quote, and copy-text. Bubbles stay pure
presenters — actions layer on by composition (MessageActions wraps
MessageBubble). Hover toolbar on desktop, long-press (onContextMenu)
on touch.

Data model (migration 009):
- messages.reply_to_message_id (self-FK, ON DELETE SET NULL) so replies
  survive a deleted parent
- message_reactions table — one row per (message, actor) with a unique
  constraint, conversation_id denormalised so Supabase Realtime can
  filter without a join
- RLS scoped via conversation ownership; service-role bypass for the
  webhook

API:
- /api/whatsapp/send accepts reply_to_message_id, resolves the parent's
  Meta message_id and forwards it as context.message_id
- /api/whatsapp/react (new) sends Meta reaction payloads and mirrors
  the upsert/delete into message_reactions
- Webhook stops storing inbound reactions as fake text messages and
  instead upserts/deletes message_reactions; inbound replies (context.id)
  resolve to internal UUID and populate reply_to_message_id

UI:
- MessageActions hover toolbar with inline 6-emoji Popover (static set;
  no emoji-picker library)
- ReplyQuote shared between the in-bubble quote and the composer chip
- MessageReactions pill row with click-to-toggle
- Thread subscribes to message_reactions filtered by conversation_id;
  optimistic upsert with rollback on failure

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

76 lines
2.1 KiB
TypeScript

"use client";
import { useMemo } from "react";
import { cn } from "@/lib/utils";
import type { MessageReaction } from "@/types";
interface MessageReactionsProps {
reactions: MessageReaction[];
currentUserId: string | undefined;
/** Toggle the agent's reaction. If the agent already has this emoji →
* caller should send empty to remove; otherwise swap/add. */
onToggle: (emoji: string) => void;
}
interface ReactionGroup {
emoji: string;
count: number;
byCurrentUser: boolean;
}
function groupReactions(
reactions: MessageReaction[],
currentUserId: string | undefined,
): ReactionGroup[] {
const map = new Map<string, ReactionGroup>();
for (const r of reactions) {
const existing = map.get(r.emoji);
const isMine =
r.actor_type === "agent" &&
!!currentUserId &&
r.actor_id === currentUserId;
if (existing) {
existing.count += 1;
existing.byCurrentUser = existing.byCurrentUser || isMine;
} else {
map.set(r.emoji, { emoji: r.emoji, count: 1, byCurrentUser: isMine });
}
}
return [...map.values()];
}
export function MessageReactions({
reactions,
currentUserId,
onToggle,
}: MessageReactionsProps) {
const groups = useMemo(
() => groupReactions(reactions, currentUserId),
[reactions, currentUserId],
);
if (groups.length === 0) return null;
return (
<div className="mt-1 flex flex-wrap gap-1">
{groups.map((g) => (
<button
key={g.emoji}
type="button"
onClick={() => onToggle(g.emoji)}
aria-pressed={g.byCurrentUser}
className={cn(
"inline-flex items-center gap-1 rounded-full border px-1.5 py-0.5 text-[11px] leading-none transition-colors",
g.byCurrentUser
? "border-violet-500/60 bg-violet-500/15 text-violet-200 hover:bg-violet-500/25"
: "border-slate-700 bg-slate-800/80 text-slate-200 hover:bg-slate-700",
)}
>
<span className="text-sm leading-none">{g.emoji}</span>
{g.count > 1 && <span>{g.count}</span>}
</button>
))}
</div>
);
}