wacrm/src/components/inbox/reply-quote.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

74 lines
2.1 KiB
TypeScript

"use client";
import { X } from "lucide-react";
import { cn } from "@/lib/utils";
import type { Message } from "@/types";
interface ReplyQuoteProps {
/** Sender label of the quoted message: "You" for our own messages,
* contact name for customer-sent messages. Caller resolves this — the
* quote component doesn't see the parent Message. */
authorLabel: string;
/** Compact text preview. Falls back to a placeholder for media types. */
preview: string;
/** Present → renders the composer-chip variant with an X button. Absent →
* renders the embedded-in-bubble variant. */
onDismiss?: () => void;
}
export function ReplyQuote({
authorLabel,
preview,
onDismiss,
}: ReplyQuoteProps) {
const isChip = !!onDismiss;
return (
<div
className={cn(
"flex items-start gap-2 border-l-2 border-violet-400 px-2 py-1",
isChip
? "rounded-md bg-slate-800/80"
: "mb-1.5 rounded-md bg-black/20",
)}
>
<div className="min-w-0 flex-1">
<div className="truncate text-[11px] font-medium text-violet-300">
{authorLabel}
</div>
<div className="truncate text-xs text-slate-200/80">{preview}</div>
</div>
{onDismiss && (
<button
type="button"
onClick={onDismiss}
aria-label="Cancel reply"
className="flex h-6 w-6 shrink-0 items-center justify-center rounded text-slate-400 hover:bg-slate-700 hover:text-white"
>
<X className="h-3.5 w-3.5" />
</button>
)}
</div>
);
}
/** Build the one-line preview text shown inside a reply quote. */
export function buildReplyPreview(message: Message): string {
if (message.content_text) return message.content_text;
switch (message.content_type) {
case "image":
return "[Image]";
case "video":
return "[Video]";
case "audio":
return "[Audio]";
case "document":
return "[Document]";
case "location":
return "[Location]";
case "template":
return "[Template]";
default:
return "[Message]";
}
}