mirror of
https://github.com/Luchexx/wacrm.git
synced 2026-11-02 09:19:51 +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>
74 lines
2.1 KiB
TypeScript
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]";
|
|
}
|
|
}
|