Merge pull request #100 from ArnasDon/feature/chat-actions

feat(inbox): reactions, reply-to, and copy on chat messages
This commit is contained in:
Arnas Donauskas 2026-05-19 16:22:02 +03:00 committed by GitHub
commit a1348ebcbd
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 1131 additions and 34 deletions

View File

@ -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: <internal UUID>, emoji: <single emoji or "" to remove> }
*
* 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 },
);
}
}

View File

@ -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()

View File

@ -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<string | null> {
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) {

View File

@ -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 `<MessageBubble>`. 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 (
<div
className="group/actions relative"
onContextMenu={handleContextMenu}
onBlur={() => setTouchOpen(false)}
>
{children}
<div
data-touch-open={touchOpen || pickerOpen ? "true" : undefined}
className={cn(
"absolute -top-3 z-10 flex h-7 items-center gap-0.5 rounded-full border border-slate-700 bg-slate-900/95 px-1 shadow-md backdrop-blur-sm transition-opacity",
"opacity-0 group-hover/actions:opacity-100 group-focus-within/actions:opacity-100",
"data-[touch-open=true]:opacity-100",
isAgent ? "right-3" : "left-3",
)}
>
<Popover open={pickerOpen} onOpenChange={setPickerOpen}>
<PopoverTrigger
className="flex h-5 w-5 items-center justify-center rounded-full text-slate-300 hover:bg-slate-700 hover:text-white"
aria-label="React"
>
<SmilePlus className="h-3.5 w-3.5" />
</PopoverTrigger>
<PopoverContent
className="flex w-auto flex-row gap-1 p-1.5"
sideOffset={6}
>
{QUICK_EMOJIS.map((e) => (
<button
key={e}
type="button"
onClick={() => handlePickEmoji(e)}
className="flex h-8 w-8 items-center justify-center rounded-full text-lg leading-none transition-transform hover:scale-125 hover:bg-slate-700"
aria-label={`React with ${e}`}
>
{e}
</button>
))}
</PopoverContent>
</Popover>
<button
type="button"
onClick={handleReply}
className="flex h-5 w-5 items-center justify-center rounded-full text-slate-300 hover:bg-slate-700 hover:text-white"
aria-label="Reply"
>
<CornerUpLeft className="h-3.5 w-3.5" />
</button>
<button
type="button"
onClick={handleCopy}
className="flex h-5 w-5 items-center justify-center rounded-full text-slate-300 hover:bg-slate-700 hover:text-white"
aria-label="Copy"
>
<Copy className="h-3.5 w-3.5" />
</button>
</div>
</div>
);
}

View File

@ -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,23 +237,40 @@ export function MessageBubble({ message }: MessageBubbleProps) {
>
<div
className={cn(
"relative max-w-[75%] rounded-2xl px-3 py-2",
isAgent
? "rounded-br-md bg-violet-600 text-white"
: "rounded-bl-md bg-slate-800 text-slate-100"
"flex max-w-[75%] flex-col",
isAgent ? "items-end" : "items-start",
)}
>
<div
className={cn(
"relative rounded-2xl px-3 py-2",
isAgent
? "rounded-br-md bg-violet-600 text-white"
: "rounded-bl-md bg-slate-800 text-slate-100",
)}
>
{reply && (
<ReplyQuote authorLabel={reply.authorLabel} preview={reply.preview} />
)}
<MessageContent message={message} />
<div
className={cn(
"mt-1 flex items-center gap-1",
isAgent ? "justify-end" : "justify-start"
isAgent ? "justify-end" : "justify-start",
)}
>
<span className="text-[10px] text-white/60">{time}</span>
{isAgent && <StatusIcon status={message.status} />}
</div>
</div>
{reactions && reactions.length > 0 && onToggleReaction && (
<MessageReactions
reactions={reactions}
currentUserId={currentUserId}
onToggle={onToggleReaction}
/>
)}
</div>
</div>
);
}

View File

@ -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<HTMLTextAreaElement>) => {
@ -66,6 +78,15 @@ export function MessageComposer({
return (
<div className="border-t border-slate-800 bg-slate-900 p-3">
{replyTo && (
<div className="mb-2">
<ReplyQuote
authorLabel={replyTo.authorLabel}
preview={replyTo.preview}
onDismiss={onClearReply}
/>
</div>
)}
{sessionExpired && (
<div className="mb-2 flex items-center justify-between rounded-lg bg-amber-500/10 px-3 py-2">
<p className="text-xs text-amber-400">

View File

@ -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<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>
);
}

View File

@ -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<HTMLDivElement>(null);
const [templateModalOpen, setTemplateModalOpen] = useState(false);
const [profiles, setProfiles] = useState<Profile[]>([]);
const [reactions, setReactions] = useState<MessageReaction[]>([]);
const [replyTo, setReplyTo] = useState<ReplyDraft | null>(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<MessageReaction>;
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<string, Message>();
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<string, MessageReaction[]>();
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({
</div>
{/* Messages */}
<div className="space-y-2">
{group.messages.map((msg) => (
<MessageBubble key={msg.id} message={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 (
<MessageActions
key={msg.id}
message={msg}
onReply={() => handleStartReply(msg)}
onReact={(emoji) => handleAddReaction(msg.id, emoji)}
>
<MessageBubble
message={msg}
reply={reply}
reactions={msgReactions}
currentUserId={user?.id}
onToggleReaction={(emoji) =>
handleToggleReaction(msg.id, emoji)
}
/>
</MessageActions>
);
})}
</div>
</div>
))}
@ -576,6 +826,8 @@ export function MessageThread({
sessionExpired={sessionInfo.expired}
onSend={handleSend}
onOpenTemplates={handleOpenTemplates}
replyTo={replyTo}
onClearReply={() => setReplyTo(null)}
/>
<TemplatePicker

View File

@ -0,0 +1,73 @@
"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]";
}
}

View File

@ -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

View File

@ -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<MetaSendResult> {
const { phoneNumberId, accessToken, to, text } = args
const { phoneNumberId, accessToken, to, text, contextMessageId } = args
const url = `${META_API_BASE}/${phoneNumberId}/messages`
const body: Record<string, unknown> = {
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<string, unknown> = {
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<MetaSendResult> {
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) {

View File

@ -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 {

View File

@ -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 $$;