From 339b1fe94f479bae9998223ff4c17f997821f785 Mon Sep 17 00:00:00 2001 From: Arnas Donauskas Date: Tue, 19 May 2026 16:17:26 +0300 Subject: [PATCH] feat(inbox): reactions, reply-to, and copy on chat messages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/app/api/whatsapp/react/route.ts | 178 ++++++++++++++ src/app/api/whatsapp/send/route.ts | 35 +++ src/app/api/whatsapp/webhook/route.ts | 120 ++++++++- src/components/inbox/message-actions.tsx | 135 ++++++++++ src/components/inbox/message-bubble.tsx | 52 +++- src/components/inbox/message-composer.tsx | 27 +- src/components/inbox/message-reactions.tsx | 75 ++++++ src/components/inbox/message-thread.tsx | 260 +++++++++++++++++++- src/components/inbox/reply-quote.tsx | 73 ++++++ src/lib/rate-limit.ts | 4 + src/lib/whatsapp/meta-api.ts | 79 +++++- src/types/index.ts | 13 + supabase/migrations/009_message_actions.sql | 114 +++++++++ 13 files changed, 1131 insertions(+), 34 deletions(-) create mode 100644 src/app/api/whatsapp/react/route.ts create mode 100644 src/components/inbox/message-actions.tsx create mode 100644 src/components/inbox/message-reactions.tsx create mode 100644 src/components/inbox/reply-quote.tsx create mode 100644 supabase/migrations/009_message_actions.sql diff --git a/src/app/api/whatsapp/react/route.ts b/src/app/api/whatsapp/react/route.ts new file mode 100644 index 0000000..e8e1dc5 --- /dev/null +++ b/src/app/api/whatsapp/react/route.ts @@ -0,0 +1,178 @@ +import { NextResponse } from 'next/server'; +import { createClient } from '@/lib/supabase/server'; +import { sendReactionMessage } from '@/lib/whatsapp/meta-api'; +import { decrypt } from '@/lib/whatsapp/encryption'; +import { sanitizePhoneForMeta } from '@/lib/whatsapp/phone-utils'; +import { + checkRateLimit, + rateLimitResponse, + RATE_LIMITS, +} from '@/lib/rate-limit'; + +/** + * POST /api/whatsapp/react + * + * Body: { message_id: , emoji: } + * + * Sends the reaction to Meta and mirrors it into `message_reactions` + * (delete on empty emoji). Customer-side reactions are handled by the + * webhook — this route only writes `actor_type = 'agent'` rows. + */ +export async function POST(request: Request) { + try { + const supabase = await createClient(); + + const { + data: { user }, + error: authError, + } = await supabase.auth.getUser(); + + if (authError || !user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const limit = checkRateLimit(`react:${user.id}`, RATE_LIMITS.react); + if (!limit.success) { + return rateLimitResponse(limit); + } + + const body = await request.json(); + const { message_id, emoji } = body as { + message_id?: string; + emoji?: string; + }; + + if (!message_id || typeof emoji !== 'string') { + return NextResponse.json( + { error: 'message_id and emoji are required' }, + { status: 400 }, + ); + } + + // Resolve target message + its conversation; verify ownership. + const { data: targetMessage, error: msgError } = await supabase + .from('messages') + .select('id, message_id, conversation_id') + .eq('id', message_id) + .maybeSingle(); + + if (msgError || !targetMessage) { + return NextResponse.json({ error: 'Message not found' }, { status: 404 }); + } + + if (!targetMessage.message_id) { + // No Meta ID yet — usually a sending/failed agent message. We can't + // tell Meta to react to a message it never received. + return NextResponse.json( + { error: 'Cannot react to a message that has not been sent to WhatsApp' }, + { status: 400 }, + ); + } + + const { data: conversation, error: convError } = await supabase + .from('conversations') + .select('id, user_id, contact:contacts(phone)') + .eq('id', targetMessage.conversation_id) + .eq('user_id', user.id) + .maybeSingle(); + + if (convError || !conversation) { + return NextResponse.json( + { error: 'Conversation not found' }, + { status: 404 }, + ); + } + + const contact = Array.isArray(conversation.contact) + ? conversation.contact[0] + : conversation.contact; + if (!contact?.phone) { + return NextResponse.json( + { error: 'Contact phone number not found' }, + { status: 400 }, + ); + } + + // WhatsApp config + access token + const { data: config, error: configError } = await supabase + .from('whatsapp_config') + .select('phone_number_id, access_token') + .eq('user_id', user.id) + .single(); + + if (configError || !config) { + return NextResponse.json( + { error: 'WhatsApp not configured.' }, + { status: 400 }, + ); + } + + const accessToken = decrypt(config.access_token); + const sanitizedPhone = sanitizePhoneForMeta(contact.phone); + + try { + await sendReactionMessage({ + phoneNumberId: config.phone_number_id, + accessToken, + to: sanitizedPhone, + targetMessageId: targetMessage.message_id, + emoji, + }); + } catch (err) { + const message = + err instanceof Error ? err.message : 'Unknown Meta API error'; + console.error('[whatsapp/react] Meta send failed:', message); + return NextResponse.json( + { error: `Meta API error: ${message}` }, + { status: 502 }, + ); + } + + // Mirror into DB. Empty emoji = removal. + if (emoji === '') { + const { error: delError } = await supabase + .from('message_reactions') + .delete() + .eq('message_id', targetMessage.id) + .eq('actor_type', 'agent') + .eq('actor_id', user.id); + + if (delError) { + console.error('[whatsapp/react] DB delete failed:', delError.message); + return NextResponse.json( + { error: 'Reaction sent to Meta but DB delete failed' }, + { status: 500 }, + ); + } + } else { + // Upsert. The unique constraint (message_id, actor_type, actor_id) + // lets us swap emoji in a single statement. + const { error: upsertError } = await supabase.from('message_reactions').upsert( + { + message_id: targetMessage.id, + conversation_id: targetMessage.conversation_id, + actor_type: 'agent', + actor_id: user.id, + emoji, + }, + { onConflict: 'message_id,actor_type,actor_id' }, + ); + + if (upsertError) { + console.error('[whatsapp/react] DB upsert failed:', upsertError.message); + return NextResponse.json( + { error: 'Reaction sent to Meta but DB upsert failed' }, + { status: 500 }, + ); + } + } + + return NextResponse.json({ success: true }); + } catch (error) { + console.error('Error in WhatsApp react POST:', error); + return NextResponse.json( + { error: 'Failed to react to message' }, + { status: 500 }, + ); + } +} diff --git a/src/app/api/whatsapp/send/route.ts b/src/app/api/whatsapp/send/route.ts index fb791da..ad2613d 100644 --- a/src/app/api/whatsapp/send/route.ts +++ b/src/app/api/whatsapp/send/route.ts @@ -45,6 +45,7 @@ export async function POST(request: Request) { media_url, template_name, template_params, + reply_to_message_id, } = body if (!conversation_id || !message_type) { @@ -136,6 +137,37 @@ export async function POST(request: Request) { }) } + // Resolve the reply target (if any) to its Meta message_id, which is + // what `context.message_id` on the outgoing Meta payload needs. The + // parent must belong to this same conversation — otherwise a caller + // could quote messages they can't see by guessing UUIDs. + let contextMessageId: string | undefined + if (reply_to_message_id) { + const { data: parent, error: parentError } = await supabase + .from('messages') + .select('message_id, conversation_id') + .eq('id', reply_to_message_id) + .eq('conversation_id', conversation_id) + .maybeSingle() + + if (parentError || !parent) { + return NextResponse.json( + { error: 'reply_to_message_id not found in this conversation' }, + { status: 400 } + ) + } + if (!parent.message_id) { + // Parent never reached Meta (still in 'sending' or 'failed') — we + // can't quote it on WhatsApp. Send without context rather than + // dropping the message entirely. + console.warn( + '[whatsapp/send] reply target has no Meta message_id; sending without context' + ) + } else { + contextMessageId = parent.message_id + } + } + // Send via Meta API — retry with phone-number variants if Meta rejects // with "recipient not in allowed list" (common in sandbox / when a // number was registered with/without a trunk 0). If an alternate @@ -152,6 +184,7 @@ export async function POST(request: Request) { to: phone, templateName: template_name, params: template_params || [], + contextMessageId, }) return result.messageId } @@ -160,6 +193,7 @@ export async function POST(request: Request) { accessToken, to: phone, text: content_text, + contextMessageId, }) return result.messageId } @@ -225,6 +259,7 @@ export async function POST(request: Request) { template_name: template_name || null, message_id: waMessageId, status: 'sent', + reply_to_message_id: reply_to_message_id || null, }) .select() .single() diff --git a/src/app/api/whatsapp/webhook/route.ts b/src/app/api/whatsapp/webhook/route.ts index 844d61c..f9e80c3 100644 --- a/src/app/api/whatsapp/webhook/route.ts +++ b/src/app/api/whatsapp/webhook/route.ts @@ -32,6 +32,8 @@ interface WhatsAppMessage { sticker?: { id: string; mime_type: string } location?: { latitude: number; longitude: number; name?: string; address?: string } reaction?: { message_id: string; emoji: string } + /** Present when the customer swipe-replies to one of our messages. */ + context?: { id: string } } interface WhatsAppWebhookEntry { @@ -351,6 +353,87 @@ async function flagBroadcastReplyIfAny(userId: string, contactId: string) { } } +/** + * Resolve a Meta-side message_id into the matching internal UUID, scoped + * to one conversation. Returns null when we never received the parent + * (e.g. a swipe-reply to a message older than this CRM install). + */ +async function lookupInternalIdByMetaId( + metaId: string, + conversationId: string +): Promise { + const { data, error } = await supabaseAdmin() + .from('messages') + .select('id') + .eq('message_id', metaId) + .eq('conversation_id', conversationId) + .maybeSingle() + if (error) { + console.error('[webhook] lookupInternalIdByMetaId failed:', error.message) + return null + } + return data?.id ?? null +} + +/** + * Persist an inbound reaction. WhatsApp reactions are not new messages — + * they're per-(target, actor) state. We upsert / delete on + * `message_reactions`, never write a row into `messages`. + * + * Best-effort: a missing parent (we never received it) is logged and + * skipped so the webhook still acks 200 to Meta. + */ +async function handleReaction( + message: WhatsAppMessage, + conversationId: string, + contactId: string +) { + const reaction = message.reaction + if (!reaction?.message_id) return + + const targetInternalId = await lookupInternalIdByMetaId( + reaction.message_id, + conversationId + ) + if (!targetInternalId) { + console.warn( + '[webhook] reaction target message not found; skipping', + reaction.message_id + ) + return + } + + // Empty emoji = removal (per Meta's Cloud API spec). + if (!reaction.emoji) { + const { error: delError } = await supabaseAdmin() + .from('message_reactions') + .delete() + .eq('message_id', targetInternalId) + .eq('actor_type', 'customer') + .eq('actor_id', contactId) + if (delError) { + console.error('[webhook] reaction delete failed:', delError.message) + } + return + } + + const { error: upsertError } = await supabaseAdmin() + .from('message_reactions') + .upsert( + { + message_id: targetInternalId, + conversation_id: conversationId, + actor_type: 'customer', + actor_id: contactId, + emoji: reaction.emoji, + }, + { onConflict: 'message_id,actor_type,actor_id' } + ) + if (upsertError) { + console.error('[webhook] reaction upsert failed:', upsertError.message) + } +} + async function processMessage( message: WhatsAppMessage, contact: { profile: { name: string }; wa_id: string }, @@ -360,12 +443,6 @@ async function processMessage( const senderPhone = normalizePhone(message.from) const contactName = contact.profile.name - // Parse message content based on type - const { contentText, mediaUrl, mediaType } = await parseMessageContent( - message, - accessToken - ) - // Find or create contact const contactOutcome = await findOrCreateContact( userId, @@ -382,6 +459,36 @@ async function processMessage( ) if (!conversation) return + // Reactions short-circuit here — they aren't messages. We never insert + // into `messages`, never bump unread_count, never update last_message_text. + // Done before parseMessageContent so the media-URL fetch is skipped. + if (message.type === 'reaction') { + await handleReaction(message, conversation.id, contactRecord.id) + return + } + + // Parse message content based on type + const { contentText, mediaUrl, mediaType } = await parseMessageContent( + message, + accessToken + ) + + // Resolve swipe-reply context if present. A missing parent is fine — + // we just store NULL and the UI renders the message without a quote. + let replyToInternalId: string | null = null + if (message.context?.id) { + replyToInternalId = await lookupInternalIdByMetaId( + message.context.id, + conversation.id + ) + if (!replyToInternalId) { + console.warn( + '[webhook] reply context parent not found:', + message.context.id + ) + } + } + // Insert message — field names MUST match the messages table schema // (see supabase/migrations/001_initial_schema.sql): // conversation_id, sender_type, content_type, content_text, @@ -424,6 +531,7 @@ async function processMessage( message_id: message.id, status: 'delivered', created_at: new Date(parseInt(message.timestamp) * 1000).toISOString(), + reply_to_message_id: replyToInternalId, }) if (msgError) { diff --git a/src/components/inbox/message-actions.tsx b/src/components/inbox/message-actions.tsx new file mode 100644 index 0000000..c617dea --- /dev/null +++ b/src/components/inbox/message-actions.tsx @@ -0,0 +1,135 @@ +"use client"; + +import { useState, type ReactNode } from "react"; +import { CornerUpLeft, Copy, SmilePlus } from "lucide-react"; +import { toast } from "sonner"; +import { cn } from "@/lib/utils"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import type { Message } from "@/types"; + +// WhatsApp's own quick-reaction bar starts with these six. Picking the same +// set keeps the affordance familiar without pulling in a 300KB emoji library. +const QUICK_EMOJIS = ["👍", "❤️", "😂", "😮", "😢", "🙏"]; + +interface MessageActionsProps { + message: Message; + onReply: () => void; + onReact: (emoji: string) => void; + children: ReactNode; +} + +/** + * Hover/long-press toolbar wrapper around a ``. The bubble + * itself stays a pure presenter — this component owns the action surface so + * the bubble's render path is unaffected when the toolbar isn't visible. + */ +export function MessageActions({ + message, + onReply, + onReact, + children, +}: MessageActionsProps) { + // Touch devices have no hover. Long-press fires `contextmenu`; we capture + // it, suppress the native menu, and pin the toolbar open until the user + // interacts elsewhere. + const [touchOpen, setTouchOpen] = useState(false); + const [pickerOpen, setPickerOpen] = useState(false); + + const isAgent = + message.sender_type === "agent" || message.sender_type === "bot"; + + const handleContextMenu = (e: React.MouseEvent) => { + e.preventDefault(); + setTouchOpen(true); + }; + + const handleCopy = async () => { + const text = message.content_text ?? ""; + if (!text) { + toast.error("Nothing to copy"); + return; + } + try { + await navigator.clipboard.writeText(text); + toast.success("Copied"); + } catch { + toast.error("Copy failed"); + } + setTouchOpen(false); + }; + + const handlePickEmoji = (emoji: string) => { + onReact(emoji); + setPickerOpen(false); + setTouchOpen(false); + }; + + const handleReply = () => { + onReply(); + setTouchOpen(false); + }; + + return ( +
setTouchOpen(false)} + > + {children} +
+ + + + + + {QUICK_EMOJIS.map((e) => ( + + ))} + + + + +
+
+ ); +} diff --git a/src/components/inbox/message-bubble.tsx b/src/components/inbox/message-bubble.tsx index 5e6e5c6..029555f 100644 --- a/src/components/inbox/message-bubble.tsx +++ b/src/components/inbox/message-bubble.tsx @@ -2,7 +2,7 @@ import { useState, useEffect, useCallback } from "react"; import { cn } from "@/lib/utils"; -import type { Message } from "@/types"; +import type { Message, MessageReaction } from "@/types"; import { Clock, Check, @@ -14,9 +14,16 @@ import { ImageOff, } from "lucide-react"; import { format } from "date-fns"; +import { ReplyQuote } from "./reply-quote"; +import { MessageReactions } from "./message-reactions"; interface MessageBubbleProps { message: Message; + /** Pre-computed quote info for messages that reply to another. */ + reply?: { authorLabel: string; preview: string } | null; + reactions?: MessageReaction[]; + currentUserId?: string; + onToggleReaction?: (emoji: string) => void; } function StatusIcon({ status }: { status: Message["status"] }) { @@ -214,7 +221,13 @@ function MessageContent({ message }: { message: Message }) { } } -export function MessageBubble({ message }: MessageBubbleProps) { +export function MessageBubble({ + message, + reply, + reactions, + currentUserId, + onToggleReaction, +}: MessageBubbleProps) { const isAgent = message.sender_type === "agent" || message.sender_type === "bot"; const time = format(new Date(message.created_at), "HH:mm"); @@ -224,22 +237,39 @@ export function MessageBubble({ message }: MessageBubbleProps) { >
-
- {time} - {isAgent && } + {reply && ( + + )} + +
+ {time} + {isAgent && } +
+ {reactions && reactions.length > 0 && onToggleReaction && ( + + )}
); diff --git a/src/components/inbox/message-composer.tsx b/src/components/inbox/message-composer.tsx index 3eeb07b..21974de 100644 --- a/src/components/inbox/message-composer.tsx +++ b/src/components/inbox/message-composer.tsx @@ -4,12 +4,22 @@ import { useState, useRef, useCallback, KeyboardEvent } from "react"; import { Send, LayoutTemplate } from "lucide-react"; import { Button } from "@/components/ui/button"; import { cn } from "@/lib/utils"; +import { ReplyQuote } from "./reply-quote"; + +interface ReplyDraft { + /** Internal UUID of the message being replied to — sent back through onSend. */ + id: string; + authorLabel: string; + preview: string; +} interface MessageComposerProps { conversationId: string; sessionExpired: boolean; - onSend: (text: string) => void; + onSend: (text: string, replyToId?: string) => void; onOpenTemplates: () => void; + replyTo?: ReplyDraft | null; + onClearReply?: () => void; } export function MessageComposer({ @@ -17,6 +27,8 @@ export function MessageComposer({ sessionExpired, onSend, onOpenTemplates, + replyTo, + onClearReply, }: MessageComposerProps) { const [text, setText] = useState(""); const [sending, setSending] = useState(false); @@ -36,7 +48,7 @@ export function MessageComposer({ setSending(true); try { - onSend(trimmed); + onSend(trimmed, replyTo?.id); setText(""); if (textareaRef.current) { textareaRef.current.style.height = "auto"; @@ -44,7 +56,7 @@ export function MessageComposer({ } finally { setSending(false); } - }, [text, sending, sessionExpired, onSend]); + }, [text, sending, sessionExpired, onSend, replyTo?.id]); const handleKeyDown = useCallback( (e: KeyboardEvent) => { @@ -66,6 +78,15 @@ export function MessageComposer({ return (
+ {replyTo && ( +
+ +
+ )} {sessionExpired && (

diff --git a/src/components/inbox/message-reactions.tsx b/src/components/inbox/message-reactions.tsx new file mode 100644 index 0000000..215c4da --- /dev/null +++ b/src/components/inbox/message-reactions.tsx @@ -0,0 +1,75 @@ +"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(); + 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 ( +

+ {groups.map((g) => ( + + ))} +
+ ); +} diff --git a/src/components/inbox/message-thread.tsx b/src/components/inbox/message-thread.tsx index b80e8a1..bcf181e 100644 --- a/src/components/inbox/message-thread.tsx +++ b/src/components/inbox/message-thread.tsx @@ -7,6 +7,7 @@ import { cn } from "@/lib/utils"; import type { Conversation, Message, + MessageReaction, Contact, ConversationStatus, MessageTemplate, @@ -31,10 +32,18 @@ import { } from "@/components/ui/dropdown-menu"; import { ScrollArea } from "@/components/ui/scroll-area"; import { MessageBubble } from "./message-bubble"; +import { MessageActions } from "./message-actions"; import { MessageComposer } from "./message-composer"; import { TemplatePicker } from "./template-picker"; +import { buildReplyPreview } from "./reply-quote"; import { toast } from "sonner"; +interface ReplyDraft { + id: string; + authorLabel: string; + preview: string; +} + function renderTemplateBody(body: string, params: string[]): string { return body.replace(/\{\{(\d+)\}\}/g, (_, raw) => { const idx = Number(raw) - 1; @@ -109,6 +118,8 @@ export function MessageThread({ const scrollRef = useRef(null); const [templateModalOpen, setTemplateModalOpen] = useState(false); const [profiles, setProfiles] = useState([]); + const [reactions, setReactions] = useState([]); + const [replyTo, setReplyTo] = useState(null); // Profiles are bounded by RLS to rows the current user is allowed to // see — today that's just the current user, but the dropdown keeps the @@ -210,6 +221,89 @@ export function MessageThread({ }; }, [conversationId]); + // Reactions: fetch + realtime per conversation. Subscribing here (not at + // the page level) keeps the channel scoped to the visible conversation, + // matching the message fetch effect above and avoiding cross-conversation + // chatter on a busy inbox. + useEffect(() => { + if (!conversationId) { + setReactions([]); + return; + } + const supabase = createClient(); + let cancelled = false; + + (async () => { + const { data, error } = await supabase + .from("message_reactions") + .select("*") + .eq("conversation_id", conversationId); + if (cancelled) return; + if (error) { + console.error("Failed to fetch reactions:", error); + return; + } + setReactions((data as MessageReaction[]) ?? []); + })(); + + const channel = supabase + .channel(`reactions:${conversationId}`) + .on( + "postgres_changes", + { + event: "INSERT", + schema: "public", + table: "message_reactions", + filter: `conversation_id=eq.${conversationId}`, + }, + (payload) => { + const row = payload.new as MessageReaction; + setReactions((prev) => + prev.some((r) => r.id === row.id) ? prev : [...prev, row], + ); + }, + ) + .on( + "postgres_changes", + { + event: "UPDATE", + schema: "public", + table: "message_reactions", + filter: `conversation_id=eq.${conversationId}`, + }, + (payload) => { + const row = payload.new as MessageReaction; + setReactions((prev) => prev.map((r) => (r.id === row.id ? row : r))); + }, + ) + .on( + "postgres_changes", + { + event: "DELETE", + schema: "public", + table: "message_reactions", + filter: `conversation_id=eq.${conversationId}`, + }, + (payload) => { + const old = payload.old as Partial; + if (!old?.id) return; + setReactions((prev) => prev.filter((r) => r.id !== old.id)); + }, + ) + .subscribe(); + + return () => { + cancelled = true; + supabase.removeChannel(channel); + }; + }, [conversationId]); + + // Clear any in-progress reply draft when the active conversation changes — + // a quote pulled from conversation A shouldn't bleed into conversation B. + useEffect(() => { + setReplyTo(null); + }, [conversationId]); + // Reset the server-side unread_count to 0 whenever an unread count // surfaces on the active conversation — covers both (a) opening a // conversation that had unread messages and (b) new messages arriving @@ -240,7 +334,7 @@ export function MessageThread({ }, [messages]); const handleSend = useCallback( - async (text: string) => { + async (text: string, replyToId?: string) => { if (!conversation) return; const tempId = `temp-${Date.now()}`; @@ -254,8 +348,10 @@ export function MessageThread({ content_text: text, status: "sending", created_at: new Date().toISOString(), + reply_to_message_id: replyToId, }; onNewMessage(optimisticMsg); + setReplyTo(null); try { const res = await fetch("/api/whatsapp/send", { @@ -265,6 +361,7 @@ export function MessageThread({ conversation_id: conversation.id, message_type: "text", content_text: text, + reply_to_message_id: replyToId, }), }); @@ -365,6 +462,132 @@ export function MessageThread({ [conversation, onNewMessage, onUpdateMessage], ); + // Build a quick id → Message map so reply quotes can be rendered without + // an extra fetch — the thread already holds the full conversation. + const messagesById = useMemo(() => { + const map = new Map(); + for (const m of messages) map.set(m.id, m); + return map; + }, [messages]); + + // Bucket reactions by their target message_id for O(1) per-bubble lookup. + const reactionsByMessageId = useMemo(() => { + const map = new Map(); + for (const r of reactions) { + const bucket = map.get(r.message_id); + if (bucket) bucket.push(r); + else map.set(r.message_id, [r]); + } + return map; + }, [reactions]); + + const contactDisplayName = contact?.name || contact?.phone || "Customer"; + + // Author label for a quoted message: "You" when we sent the parent, + // contact name when the customer sent it. + const authorLabelFor = useCallback( + (m: Message): string => { + const isAgentMsg = + m.sender_type === "agent" || m.sender_type === "bot"; + return isAgentMsg ? "You" : contactDisplayName; + }, + [contactDisplayName], + ); + + const handleStartReply = useCallback( + (msg: Message) => { + setReplyTo({ + id: msg.id, + authorLabel: authorLabelFor(msg), + preview: buildReplyPreview(msg), + }); + }, + [authorLabelFor], + ); + + // Toggle (called from a reaction pill click). If the user already reacted + // with `emoji` → send "" to remove. Otherwise send `emoji` to add/swap. + const handleToggleReaction = useCallback( + async (messageId: string, emoji: string) => { + 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( + async (messageId: string, emoji: string) => { + if (!user?.id || !conversation) return; + + // Snapshot for rollback. Optimistically update local state so the + // pill flips immediately. + const prev = reactions; + const own = prev.find( + (r) => + r.message_id === messageId && + r.actor_type === "agent" && + r.actor_id === user.id, + ); + + let next: MessageReaction[]; + if (emoji === "") { + next = prev.filter((r) => r !== own); + } else if (own) { + next = prev.map((r) => (r === own ? { ...own, emoji } : r)); + } else { + next = [ + ...prev, + { + id: `temp-${Date.now()}`, + message_id: messageId, + conversation_id: conversation.id, + actor_type: "agent", + actor_id: user.id, + emoji, + created_at: new Date().toISOString(), + }, + ]; + } + setReactions(next); + + try { + const res = await fetch("/api/whatsapp/react", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ message_id: messageId, emoji }), + }); + if (!res.ok) { + const payload = await res.json().catch(() => ({})); + throw new Error(payload?.error || `HTTP ${res.status}`); + } + } catch (err) { + const reason = err instanceof Error ? err.message : "network error"; + toast.error(`Reaction failed: ${reason}`); + setReactions(prev); + } + }, + [reactions, conversation, user?.id], + ); + const handleAssignChange = useCallback( async (agentId: string | null) => { if (!conversation) return; @@ -560,9 +783,36 @@ export function MessageThread({
{/* Messages */}
- {group.messages.map((msg) => ( - - ))} + {group.messages.map((msg) => { + const parent = msg.reply_to_message_id + ? messagesById.get(msg.reply_to_message_id) + : null; + const reply = parent + ? { + authorLabel: authorLabelFor(parent), + preview: buildReplyPreview(parent), + } + : null; + const msgReactions = reactionsByMessageId.get(msg.id); + return ( + handleStartReply(msg)} + onReact={(emoji) => handleAddReaction(msg.id, emoji)} + > + + handleToggleReaction(msg.id, emoji) + } + /> + + ); + })}
))} @@ -576,6 +826,8 @@ export function MessageThread({ sessionExpired={sessionInfo.expired} onSend={handleSend} onOpenTemplates={handleOpenTemplates} + replyTo={replyTo} + onClearReply={() => setReplyTo(null)} /> void; +} + +export function ReplyQuote({ + authorLabel, + preview, + onDismiss, +}: ReplyQuoteProps) { + const isChip = !!onDismiss; + return ( +
+
+
+ {authorLabel} +
+
{preview}
+
+ {onDismiss && ( + + )} +
+ ); +} + +/** 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]"; + } +} diff --git a/src/lib/rate-limit.ts b/src/lib/rate-limit.ts index 71d3667..a6f905a 100644 --- a/src/lib/rate-limit.ts +++ b/src/lib/rate-limit.ts @@ -121,6 +121,10 @@ export const RATE_LIMITS = { * broadcast is one call; this caps the rate at which a single user * can launch campaigns, not the messages inside one. */ broadcast: { limit: 5, windowMs: 60_000 }, + /** Reaction add/swap/remove. More permissive than send — users + * fidget with reactions and a single "swap" is actually two calls + * (remove + add) under the hood. */ + react: { limit: 120, windowMs: 60_000 }, } as const; /** Test-only helper. Clears the in-memory state so unit tests don't diff --git a/src/lib/whatsapp/meta-api.ts b/src/lib/whatsapp/meta-api.ts index 89c1da9..71343f8 100644 --- a/src/lib/whatsapp/meta-api.ts +++ b/src/lib/whatsapp/meta-api.ts @@ -74,6 +74,9 @@ export interface SendTextMessageArgs { accessToken: string to: string text: string + /** Meta's message_id of the message being replied to. Adds a `context` field + * so WhatsApp renders the new message as a reply with a quote preview. */ + contextMessageId?: string } /** @@ -83,21 +86,25 @@ export interface SendTextMessageArgs { export async function sendTextMessage( args: SendTextMessageArgs ): Promise { - const { phoneNumberId, accessToken, to, text } = args + const { phoneNumberId, accessToken, to, text, contextMessageId } = args const url = `${META_API_BASE}/${phoneNumberId}/messages` + const body: Record = { + messaging_product: 'whatsapp', + recipient_type: 'individual', + to, + type: 'text', + text: { body: text }, + } + if (contextMessageId) { + body.context = { message_id: contextMessageId } + } const response = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${accessToken}`, }, - body: JSON.stringify({ - messaging_product: 'whatsapp', - recipient_type: 'individual', - to, - type: 'text', - text: { body: text }, - }), + body: JSON.stringify(body), }) if (!response.ok) { await throwMetaError(response, `Meta API error: ${response.status}`) @@ -113,6 +120,8 @@ export interface SendTemplateMessageArgs { templateName: string language?: string params?: string[] + /** Meta's message_id of the message being replied to. */ + contextMessageId?: string } /** @@ -129,6 +138,7 @@ export async function sendTemplateMessage( templateName, language = 'en_US', params, + contextMessageId, } = args const url = `${META_API_BASE}/${phoneNumberId}/messages` @@ -146,6 +156,55 @@ export async function sendTemplateMessage( ] } + const body: Record = { + messaging_product: 'whatsapp', + recipient_type: 'individual', + to, + type: 'template', + template, + } + if (contextMessageId) { + body.context = { message_id: contextMessageId } + } + + const response = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${accessToken}`, + }, + body: JSON.stringify(body), + }) + if (!response.ok) { + await throwMetaError(response, `Meta API error: ${response.status}`) + } + const data = await response.json() + return { messageId: data.messages[0].id } +} + +// ============================================================ +// Reactions +// ============================================================ + +export interface SendReactionMessageArgs { + phoneNumberId: string + accessToken: string + to: string + /** Meta's message_id of the message being reacted to. */ + targetMessageId: string + /** Single emoji, or empty string to remove an existing reaction. */ + emoji: string +} + +/** + * Send a reaction (or removal) to a previously-exchanged message. + * Empty `emoji` removes the reaction per Meta's spec. + */ +export async function sendReactionMessage( + args: SendReactionMessageArgs +): Promise { + const { phoneNumberId, accessToken, to, targetMessageId, emoji } = args + const url = `${META_API_BASE}/${phoneNumberId}/messages` const response = await fetch(url, { method: 'POST', headers: { @@ -156,8 +215,8 @@ export async function sendTemplateMessage( messaging_product: 'whatsapp', recipient_type: 'individual', to, - type: 'template', - template, + type: 'reaction', + reaction: { message_id: targetMessageId, emoji }, }), }) if (!response.ok) { diff --git a/src/types/index.ts b/src/types/index.ts index 30a688d..8d00e97 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -90,6 +90,19 @@ export interface Message { message_id?: string; status: MessageStatus; created_at: string; + reply_to_message_id?: string; +} + +export type ReactionActor = 'customer' | 'agent'; + +export interface MessageReaction { + id: string; + message_id: string; + conversation_id: string; + actor_type: ReactionActor; + actor_id?: string; + emoji: string; + created_at: string; } export interface WhatsAppConfig { diff --git a/supabase/migrations/009_message_actions.sql b/supabase/migrations/009_message_actions.sql new file mode 100644 index 0000000..323c59b --- /dev/null +++ b/supabase/migrations/009_message_actions.sql @@ -0,0 +1,114 @@ +-- ============================================================ +-- Chat actions: reply linkage + reactions +-- +-- Adds two things the chat UI now needs: +-- +-- 1. `messages.reply_to_message_id` — a self-FK so a message can +-- point at the message it replies to. We use the internal UUID +-- (not Meta's message_id text), because Meta IDs aren't unique +-- across phone numbers and can't be FK-constrained. The webhook +-- resolves `context.id` from Meta into our internal UUID before +-- writing. ON DELETE SET NULL — a deleted parent must not nuke +-- its replies (which today never happens, but the constraint +-- should match intent). +-- +-- 2. `message_reactions` table — one row per (message, actor). +-- Reactions arrive concurrently from agents (UI) and customers +-- (webhook). A row-level uniqueness constraint enforces "one +-- reaction per actor per message" without read-modify-write +-- games on a JSONB column. +-- +-- `conversation_id` is denormalised purely so Supabase Realtime +-- can filter on it with a plain `eq`. Realtime can't join. +-- +-- Idempotent — safe to run multiple times. +-- ============================================================ + +-- ============================================================ +-- 1. Reply linkage on messages +-- ============================================================ +ALTER TABLE messages + ADD COLUMN IF NOT EXISTS reply_to_message_id UUID + REFERENCES messages(id) ON DELETE SET NULL; + +-- Partial index — most messages aren't replies, so skip nulls. +CREATE INDEX IF NOT EXISTS idx_messages_reply_to + ON messages(reply_to_message_id) + WHERE reply_to_message_id IS NOT NULL; + +-- ============================================================ +-- 2. message_reactions +-- ============================================================ +CREATE TABLE IF NOT EXISTS message_reactions ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + message_id UUID NOT NULL REFERENCES messages(id) ON DELETE CASCADE, + conversation_id UUID NOT NULL REFERENCES conversations(id) ON DELETE CASCADE, + actor_type TEXT NOT NULL CHECK (actor_type IN ('customer', 'agent')), + actor_id UUID, + emoji TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE (message_id, actor_type, actor_id) +); + +CREATE INDEX IF NOT EXISTS idx_message_reactions_conversation + ON message_reactions(conversation_id); + +CREATE INDEX IF NOT EXISTS idx_message_reactions_message + ON message_reactions(message_id); + +ALTER TABLE message_reactions ENABLE ROW LEVEL SECURITY; + +DROP POLICY IF EXISTS "Users see reactions on their conversations" ON message_reactions; +CREATE POLICY "Users see reactions on their conversations" ON message_reactions FOR SELECT + USING (EXISTS ( + SELECT 1 FROM conversations c + WHERE c.id = message_reactions.conversation_id + AND c.user_id = auth.uid() + )); + +DROP POLICY IF EXISTS "Users insert reactions on their conversations" ON message_reactions; +CREATE POLICY "Users insert reactions on their conversations" ON message_reactions FOR INSERT + WITH CHECK (EXISTS ( + SELECT 1 FROM conversations c + WHERE c.id = message_reactions.conversation_id + AND c.user_id = auth.uid() + )); + +-- Agents may remove their own reactions. Customer reactions are managed +-- by the webhook (service-role bypass), not the UI. +DROP POLICY IF EXISTS "Users delete their own agent reactions" ON message_reactions; +CREATE POLICY "Users delete their own agent reactions" ON message_reactions FOR DELETE + USING ( + actor_type = 'agent' + AND actor_id = auth.uid() + AND EXISTS ( + SELECT 1 FROM conversations c + WHERE c.id = message_reactions.conversation_id + AND c.user_id = auth.uid() + ) + ); + +-- Agents may swap their own reaction emoji (UPDATE path is also used by +-- the upsert in /api/whatsapp/react). +DROP POLICY IF EXISTS "Users update their own agent reactions" ON message_reactions; +CREATE POLICY "Users update their own agent reactions" ON message_reactions FOR UPDATE + USING ( + actor_type = 'agent' + AND actor_id = auth.uid() + AND EXISTS ( + SELECT 1 FROM conversations c + WHERE c.id = message_reactions.conversation_id + AND c.user_id = auth.uid() + ) + ); + +-- Realtime — let the thread subscribe filtered by conversation_id. +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_publication_tables + WHERE pubname = 'supabase_realtime' AND tablename = 'message_reactions' + ) THEN + ALTER PUBLICATION supabase_realtime ADD TABLE message_reactions; + END IF; +END $$;