mirror of
https://github.com/Luchexx/wacrm.git
synced 2026-11-02 09:19:51 +00:00
Merge pull request #100 from ArnasDon/feature/chat-actions
feat(inbox): reactions, reply-to, and copy on chat messages
This commit is contained in:
commit
a1348ebcbd
178
src/app/api/whatsapp/react/route.ts
Normal file
178
src/app/api/whatsapp/react/route.ts
Normal 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 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -45,6 +45,7 @@ export async function POST(request: Request) {
|
|||||||
media_url,
|
media_url,
|
||||||
template_name,
|
template_name,
|
||||||
template_params,
|
template_params,
|
||||||
|
reply_to_message_id,
|
||||||
} = body
|
} = body
|
||||||
|
|
||||||
if (!conversation_id || !message_type) {
|
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
|
// Send via Meta API — retry with phone-number variants if Meta rejects
|
||||||
// with "recipient not in allowed list" (common in sandbox / when a
|
// with "recipient not in allowed list" (common in sandbox / when a
|
||||||
// number was registered with/without a trunk 0). If an alternate
|
// number was registered with/without a trunk 0). If an alternate
|
||||||
@ -152,6 +184,7 @@ export async function POST(request: Request) {
|
|||||||
to: phone,
|
to: phone,
|
||||||
templateName: template_name,
|
templateName: template_name,
|
||||||
params: template_params || [],
|
params: template_params || [],
|
||||||
|
contextMessageId,
|
||||||
})
|
})
|
||||||
return result.messageId
|
return result.messageId
|
||||||
}
|
}
|
||||||
@ -160,6 +193,7 @@ export async function POST(request: Request) {
|
|||||||
accessToken,
|
accessToken,
|
||||||
to: phone,
|
to: phone,
|
||||||
text: content_text,
|
text: content_text,
|
||||||
|
contextMessageId,
|
||||||
})
|
})
|
||||||
return result.messageId
|
return result.messageId
|
||||||
}
|
}
|
||||||
@ -225,6 +259,7 @@ export async function POST(request: Request) {
|
|||||||
template_name: template_name || null,
|
template_name: template_name || null,
|
||||||
message_id: waMessageId,
|
message_id: waMessageId,
|
||||||
status: 'sent',
|
status: 'sent',
|
||||||
|
reply_to_message_id: reply_to_message_id || null,
|
||||||
})
|
})
|
||||||
.select()
|
.select()
|
||||||
.single()
|
.single()
|
||||||
|
|||||||
@ -32,6 +32,8 @@ interface WhatsAppMessage {
|
|||||||
sticker?: { id: string; mime_type: string }
|
sticker?: { id: string; mime_type: string }
|
||||||
location?: { latitude: number; longitude: number; name?: string; address?: string }
|
location?: { latitude: number; longitude: number; name?: string; address?: string }
|
||||||
reaction?: { message_id: string; emoji: string }
|
reaction?: { message_id: string; emoji: string }
|
||||||
|
/** Present when the customer swipe-replies to one of our messages. */
|
||||||
|
context?: { id: string }
|
||||||
}
|
}
|
||||||
|
|
||||||
interface WhatsAppWebhookEntry {
|
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(
|
async function processMessage(
|
||||||
message: WhatsAppMessage,
|
message: WhatsAppMessage,
|
||||||
contact: { profile: { name: string }; wa_id: string },
|
contact: { profile: { name: string }; wa_id: string },
|
||||||
@ -360,12 +443,6 @@ async function processMessage(
|
|||||||
const senderPhone = normalizePhone(message.from)
|
const senderPhone = normalizePhone(message.from)
|
||||||
const contactName = contact.profile.name
|
const contactName = contact.profile.name
|
||||||
|
|
||||||
// Parse message content based on type
|
|
||||||
const { contentText, mediaUrl, mediaType } = await parseMessageContent(
|
|
||||||
message,
|
|
||||||
accessToken
|
|
||||||
)
|
|
||||||
|
|
||||||
// Find or create contact
|
// Find or create contact
|
||||||
const contactOutcome = await findOrCreateContact(
|
const contactOutcome = await findOrCreateContact(
|
||||||
userId,
|
userId,
|
||||||
@ -382,6 +459,36 @@ async function processMessage(
|
|||||||
)
|
)
|
||||||
if (!conversation) return
|
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
|
// Insert message — field names MUST match the messages table schema
|
||||||
// (see supabase/migrations/001_initial_schema.sql):
|
// (see supabase/migrations/001_initial_schema.sql):
|
||||||
// conversation_id, sender_type, content_type, content_text,
|
// conversation_id, sender_type, content_type, content_text,
|
||||||
@ -424,6 +531,7 @@ async function processMessage(
|
|||||||
message_id: message.id,
|
message_id: message.id,
|
||||||
status: 'delivered',
|
status: 'delivered',
|
||||||
created_at: new Date(parseInt(message.timestamp) * 1000).toISOString(),
|
created_at: new Date(parseInt(message.timestamp) * 1000).toISOString(),
|
||||||
|
reply_to_message_id: replyToInternalId,
|
||||||
})
|
})
|
||||||
|
|
||||||
if (msgError) {
|
if (msgError) {
|
||||||
|
|||||||
135
src/components/inbox/message-actions.tsx
Normal file
135
src/components/inbox/message-actions.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { useState, useEffect, useCallback } from "react";
|
import { useState, useEffect, useCallback } from "react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import type { Message } from "@/types";
|
import type { Message, MessageReaction } from "@/types";
|
||||||
import {
|
import {
|
||||||
Clock,
|
Clock,
|
||||||
Check,
|
Check,
|
||||||
@ -14,9 +14,16 @@ import {
|
|||||||
ImageOff,
|
ImageOff,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { format } from "date-fns";
|
import { format } from "date-fns";
|
||||||
|
import { ReplyQuote } from "./reply-quote";
|
||||||
|
import { MessageReactions } from "./message-reactions";
|
||||||
|
|
||||||
interface MessageBubbleProps {
|
interface MessageBubbleProps {
|
||||||
message: Message;
|
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"] }) {
|
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 isAgent = message.sender_type === "agent" || message.sender_type === "bot";
|
||||||
const time = format(new Date(message.created_at), "HH:mm");
|
const time = format(new Date(message.created_at), "HH:mm");
|
||||||
|
|
||||||
@ -224,23 +237,40 @@ export function MessageBubble({ message }: MessageBubbleProps) {
|
|||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative max-w-[75%] rounded-2xl px-3 py-2",
|
"flex max-w-[75%] flex-col",
|
||||||
isAgent
|
isAgent ? "items-end" : "items-start",
|
||||||
? "rounded-br-md bg-violet-600 text-white"
|
|
||||||
: "rounded-bl-md bg-slate-800 text-slate-100"
|
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
<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} />
|
<MessageContent message={message} />
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"mt-1 flex items-center gap-1",
|
"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>
|
<span className="text-[10px] text-white/60">{time}</span>
|
||||||
{isAgent && <StatusIcon status={message.status} />}
|
{isAgent && <StatusIcon status={message.status} />}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{reactions && reactions.length > 0 && onToggleReaction && (
|
||||||
|
<MessageReactions
|
||||||
|
reactions={reactions}
|
||||||
|
currentUserId={currentUserId}
|
||||||
|
onToggle={onToggleReaction}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,12 +4,22 @@ import { useState, useRef, useCallback, KeyboardEvent } from "react";
|
|||||||
import { Send, LayoutTemplate } from "lucide-react";
|
import { Send, LayoutTemplate } from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { cn } from "@/lib/utils";
|
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 {
|
interface MessageComposerProps {
|
||||||
conversationId: string;
|
conversationId: string;
|
||||||
sessionExpired: boolean;
|
sessionExpired: boolean;
|
||||||
onSend: (text: string) => void;
|
onSend: (text: string, replyToId?: string) => void;
|
||||||
onOpenTemplates: () => void;
|
onOpenTemplates: () => void;
|
||||||
|
replyTo?: ReplyDraft | null;
|
||||||
|
onClearReply?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MessageComposer({
|
export function MessageComposer({
|
||||||
@ -17,6 +27,8 @@ export function MessageComposer({
|
|||||||
sessionExpired,
|
sessionExpired,
|
||||||
onSend,
|
onSend,
|
||||||
onOpenTemplates,
|
onOpenTemplates,
|
||||||
|
replyTo,
|
||||||
|
onClearReply,
|
||||||
}: MessageComposerProps) {
|
}: MessageComposerProps) {
|
||||||
const [text, setText] = useState("");
|
const [text, setText] = useState("");
|
||||||
const [sending, setSending] = useState(false);
|
const [sending, setSending] = useState(false);
|
||||||
@ -36,7 +48,7 @@ export function MessageComposer({
|
|||||||
|
|
||||||
setSending(true);
|
setSending(true);
|
||||||
try {
|
try {
|
||||||
onSend(trimmed);
|
onSend(trimmed, replyTo?.id);
|
||||||
setText("");
|
setText("");
|
||||||
if (textareaRef.current) {
|
if (textareaRef.current) {
|
||||||
textareaRef.current.style.height = "auto";
|
textareaRef.current.style.height = "auto";
|
||||||
@ -44,7 +56,7 @@ export function MessageComposer({
|
|||||||
} finally {
|
} finally {
|
||||||
setSending(false);
|
setSending(false);
|
||||||
}
|
}
|
||||||
}, [text, sending, sessionExpired, onSend]);
|
}, [text, sending, sessionExpired, onSend, replyTo?.id]);
|
||||||
|
|
||||||
const handleKeyDown = useCallback(
|
const handleKeyDown = useCallback(
|
||||||
(e: KeyboardEvent<HTMLTextAreaElement>) => {
|
(e: KeyboardEvent<HTMLTextAreaElement>) => {
|
||||||
@ -66,6 +78,15 @@ export function MessageComposer({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="border-t border-slate-800 bg-slate-900 p-3">
|
<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 && (
|
{sessionExpired && (
|
||||||
<div className="mb-2 flex items-center justify-between rounded-lg bg-amber-500/10 px-3 py-2">
|
<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">
|
<p className="text-xs text-amber-400">
|
||||||
|
|||||||
75
src/components/inbox/message-reactions.tsx
Normal file
75
src/components/inbox/message-reactions.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -7,6 +7,7 @@ import { cn } from "@/lib/utils";
|
|||||||
import type {
|
import type {
|
||||||
Conversation,
|
Conversation,
|
||||||
Message,
|
Message,
|
||||||
|
MessageReaction,
|
||||||
Contact,
|
Contact,
|
||||||
ConversationStatus,
|
ConversationStatus,
|
||||||
MessageTemplate,
|
MessageTemplate,
|
||||||
@ -31,10 +32,18 @@ import {
|
|||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
import { MessageBubble } from "./message-bubble";
|
import { MessageBubble } from "./message-bubble";
|
||||||
|
import { MessageActions } from "./message-actions";
|
||||||
import { MessageComposer } from "./message-composer";
|
import { MessageComposer } from "./message-composer";
|
||||||
import { TemplatePicker } from "./template-picker";
|
import { TemplatePicker } from "./template-picker";
|
||||||
|
import { buildReplyPreview } from "./reply-quote";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
interface ReplyDraft {
|
||||||
|
id: string;
|
||||||
|
authorLabel: string;
|
||||||
|
preview: string;
|
||||||
|
}
|
||||||
|
|
||||||
function renderTemplateBody(body: string, params: string[]): string {
|
function renderTemplateBody(body: string, params: string[]): string {
|
||||||
return body.replace(/\{\{(\d+)\}\}/g, (_, raw) => {
|
return body.replace(/\{\{(\d+)\}\}/g, (_, raw) => {
|
||||||
const idx = Number(raw) - 1;
|
const idx = Number(raw) - 1;
|
||||||
@ -109,6 +118,8 @@ export function MessageThread({
|
|||||||
const scrollRef = useRef<HTMLDivElement>(null);
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
const [templateModalOpen, setTemplateModalOpen] = useState(false);
|
const [templateModalOpen, setTemplateModalOpen] = useState(false);
|
||||||
const [profiles, setProfiles] = useState<Profile[]>([]);
|
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
|
// 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
|
// see — today that's just the current user, but the dropdown keeps the
|
||||||
@ -210,6 +221,89 @@ export function MessageThread({
|
|||||||
};
|
};
|
||||||
}, [conversationId]);
|
}, [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
|
// Reset the server-side unread_count to 0 whenever an unread count
|
||||||
// surfaces on the active conversation — covers both (a) opening a
|
// surfaces on the active conversation — covers both (a) opening a
|
||||||
// conversation that had unread messages and (b) new messages arriving
|
// conversation that had unread messages and (b) new messages arriving
|
||||||
@ -240,7 +334,7 @@ export function MessageThread({
|
|||||||
}, [messages]);
|
}, [messages]);
|
||||||
|
|
||||||
const handleSend = useCallback(
|
const handleSend = useCallback(
|
||||||
async (text: string) => {
|
async (text: string, replyToId?: string) => {
|
||||||
if (!conversation) return;
|
if (!conversation) return;
|
||||||
|
|
||||||
const tempId = `temp-${Date.now()}`;
|
const tempId = `temp-${Date.now()}`;
|
||||||
@ -254,8 +348,10 @@ export function MessageThread({
|
|||||||
content_text: text,
|
content_text: text,
|
||||||
status: "sending",
|
status: "sending",
|
||||||
created_at: new Date().toISOString(),
|
created_at: new Date().toISOString(),
|
||||||
|
reply_to_message_id: replyToId,
|
||||||
};
|
};
|
||||||
onNewMessage(optimisticMsg);
|
onNewMessage(optimisticMsg);
|
||||||
|
setReplyTo(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch("/api/whatsapp/send", {
|
const res = await fetch("/api/whatsapp/send", {
|
||||||
@ -265,6 +361,7 @@ export function MessageThread({
|
|||||||
conversation_id: conversation.id,
|
conversation_id: conversation.id,
|
||||||
message_type: "text",
|
message_type: "text",
|
||||||
content_text: text,
|
content_text: text,
|
||||||
|
reply_to_message_id: replyToId,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -365,6 +462,132 @@ export function MessageThread({
|
|||||||
[conversation, onNewMessage, onUpdateMessage],
|
[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(
|
const handleAssignChange = useCallback(
|
||||||
async (agentId: string | null) => {
|
async (agentId: string | null) => {
|
||||||
if (!conversation) return;
|
if (!conversation) return;
|
||||||
@ -560,9 +783,36 @@ export function MessageThread({
|
|||||||
</div>
|
</div>
|
||||||
{/* Messages */}
|
{/* Messages */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{group.messages.map((msg) => (
|
{group.messages.map((msg) => {
|
||||||
<MessageBubble key={msg.id} message={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>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@ -576,6 +826,8 @@ export function MessageThread({
|
|||||||
sessionExpired={sessionInfo.expired}
|
sessionExpired={sessionInfo.expired}
|
||||||
onSend={handleSend}
|
onSend={handleSend}
|
||||||
onOpenTemplates={handleOpenTemplates}
|
onOpenTemplates={handleOpenTemplates}
|
||||||
|
replyTo={replyTo}
|
||||||
|
onClearReply={() => setReplyTo(null)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<TemplatePicker
|
<TemplatePicker
|
||||||
|
|||||||
73
src/components/inbox/reply-quote.tsx
Normal file
73
src/components/inbox/reply-quote.tsx
Normal 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]";
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -121,6 +121,10 @@ export const RATE_LIMITS = {
|
|||||||
* broadcast is one call; this caps the rate at which a single user
|
* broadcast is one call; this caps the rate at which a single user
|
||||||
* can launch campaigns, not the messages inside one. */
|
* can launch campaigns, not the messages inside one. */
|
||||||
broadcast: { limit: 5, windowMs: 60_000 },
|
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;
|
} as const;
|
||||||
|
|
||||||
/** Test-only helper. Clears the in-memory state so unit tests don't
|
/** Test-only helper. Clears the in-memory state so unit tests don't
|
||||||
|
|||||||
@ -74,6 +74,9 @@ export interface SendTextMessageArgs {
|
|||||||
accessToken: string
|
accessToken: string
|
||||||
to: string
|
to: string
|
||||||
text: 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(
|
export async function sendTextMessage(
|
||||||
args: SendTextMessageArgs
|
args: SendTextMessageArgs
|
||||||
): Promise<MetaSendResult> {
|
): Promise<MetaSendResult> {
|
||||||
const { phoneNumberId, accessToken, to, text } = args
|
const { phoneNumberId, accessToken, to, text, contextMessageId } = args
|
||||||
const url = `${META_API_BASE}/${phoneNumberId}/messages`
|
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, {
|
const response = await fetch(url, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
Authorization: `Bearer ${accessToken}`,
|
Authorization: `Bearer ${accessToken}`,
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify(body),
|
||||||
messaging_product: 'whatsapp',
|
|
||||||
recipient_type: 'individual',
|
|
||||||
to,
|
|
||||||
type: 'text',
|
|
||||||
text: { body: text },
|
|
||||||
}),
|
|
||||||
})
|
})
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
await throwMetaError(response, `Meta API error: ${response.status}`)
|
await throwMetaError(response, `Meta API error: ${response.status}`)
|
||||||
@ -113,6 +120,8 @@ export interface SendTemplateMessageArgs {
|
|||||||
templateName: string
|
templateName: string
|
||||||
language?: string
|
language?: string
|
||||||
params?: string[]
|
params?: string[]
|
||||||
|
/** Meta's message_id of the message being replied to. */
|
||||||
|
contextMessageId?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -129,6 +138,7 @@ export async function sendTemplateMessage(
|
|||||||
templateName,
|
templateName,
|
||||||
language = 'en_US',
|
language = 'en_US',
|
||||||
params,
|
params,
|
||||||
|
contextMessageId,
|
||||||
} = args
|
} = args
|
||||||
const url = `${META_API_BASE}/${phoneNumberId}/messages`
|
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, {
|
const response = await fetch(url, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
@ -156,8 +215,8 @@ export async function sendTemplateMessage(
|
|||||||
messaging_product: 'whatsapp',
|
messaging_product: 'whatsapp',
|
||||||
recipient_type: 'individual',
|
recipient_type: 'individual',
|
||||||
to,
|
to,
|
||||||
type: 'template',
|
type: 'reaction',
|
||||||
template,
|
reaction: { message_id: targetMessageId, emoji },
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
|
|||||||
@ -90,6 +90,19 @@ export interface Message {
|
|||||||
message_id?: string;
|
message_id?: string;
|
||||||
status: MessageStatus;
|
status: MessageStatus;
|
||||||
created_at: string;
|
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 {
|
export interface WhatsAppConfig {
|
||||||
|
|||||||
114
supabase/migrations/009_message_actions.sql
Normal file
114
supabase/migrations/009_message_actions.sql
Normal 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 $$;
|
||||||
Loading…
Reference in New Issue
Block a user