mirror of
https://github.com/Luchexx/wacrm.git
synced 2026-11-02 17:29:45 +00:00
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>
76 lines
2.1 KiB
TypeScript
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>
|
|
);
|
|
}
|