All checks were successful
build-and-deploy / build-and-deploy (push) Successful in 1m8s
- Crear endpoint DELETE /api/messages/:instanceId/:chatId - Agregar boton de eliminar en ChatItem - Crear modal de confirmacion con advertencia - Elimina el chat y todos los mensajes relacionados (CASCADE) - Muestra cantidad de mensajes eliminados en notificacion
1120 lines
34 KiB
Vue
1120 lines
34 KiB
Vue
<template>
|
|
<div class="space-y-6">
|
|
<!-- Header -->
|
|
<div class="flex items-center justify-between">
|
|
<div>
|
|
<h1 class="text-2xl font-bold text-[var(--wa-text)]">Mensajes</h1>
|
|
<p class="text-[var(--wa-text-muted)]">Vista de conversaciones de todas las instancias</p>
|
|
</div>
|
|
<div class="flex items-center gap-2">
|
|
<!-- SSE Status -->
|
|
<span
|
|
class="flex items-center gap-1 text-xs px-2 py-1 rounded"
|
|
:class="isConnected ? 'bg-green-900/50 text-green-400' : 'bg-red-900/50 text-red-400'"
|
|
>
|
|
<span class="w-2 h-2 rounded-full" :class="isConnected ? 'bg-green-400' : 'bg-red-400'" />
|
|
{{ isConnected ? 'Realtime' : 'Offline' }}
|
|
</span>
|
|
<!-- Debug toggle -->
|
|
<button
|
|
@click="showDebugPanel = !showDebugPanel"
|
|
class="text-xs px-2 py-1 rounded bg-gray-700 hover:bg-gray-600 text-gray-300"
|
|
>
|
|
<UIcon name="i-lucide-bug" class="w-4 h-4" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Debug Panel -->
|
|
<div
|
|
v-if="showDebugPanel"
|
|
class="p-4 rounded bg-gray-900 border border-gray-700 text-xs font-mono"
|
|
>
|
|
<div class="flex items-center justify-between mb-2">
|
|
<span class="text-gray-400">Last SSE Event:</span>
|
|
<div class="flex items-center gap-2">
|
|
<span class="text-gray-500">{{ lastEvent?.timestamp?.toLocaleTimeString() || 'N/A' }}</span>
|
|
<button
|
|
v-if="lastEvent"
|
|
@click="copyToClipboard(JSON.stringify(lastEvent, null, 2))"
|
|
class="px-2 py-1 rounded bg-gray-700 hover:bg-gray-600 text-gray-300"
|
|
title="Copiar al portapapeles"
|
|
>
|
|
<UIcon name="i-lucide-copy" class="w-3 h-3" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<pre v-if="lastEvent" class="text-green-400 whitespace-pre-wrap overflow-x-auto max-h-40">{{ JSON.stringify(lastEvent, null, 2) }}</pre>
|
|
<p v-else class="text-gray-500">No events received yet</p>
|
|
</div>
|
|
|
|
<!-- Instance Selector -->
|
|
<div class="flex items-center gap-4">
|
|
<USelectMenu
|
|
v-model="selectedInstance"
|
|
:items="instanceOptions"
|
|
placeholder="Seleccionar instancia"
|
|
class="w-64"
|
|
/>
|
|
</div>
|
|
|
|
<!-- Chat Interface -->
|
|
<div class="grid grid-cols-12 gap-4 h-[calc(100vh-300px)]">
|
|
<!-- Chat List -->
|
|
<div class="col-span-4 instance-card overflow-hidden flex flex-col">
|
|
<div class="p-4 border-b border-[var(--wa-border)]">
|
|
<div class="flex items-center gap-2">
|
|
<UInput
|
|
v-model="searchQuery"
|
|
placeholder="Buscar conversacion..."
|
|
icon="i-lucide-search"
|
|
class="flex-1"
|
|
/>
|
|
<button
|
|
@click="showNewChatModal = true"
|
|
class="text-xs px-2 py-2 rounded bg-[var(--wa-green)] hover:bg-[var(--wa-green-dark)] text-white"
|
|
title="Nueva conversacion"
|
|
:disabled="!selectedInstance"
|
|
>
|
|
<UIcon name="i-lucide-plus" class="w-4 h-4" />
|
|
</button>
|
|
<button
|
|
@click="showChatsDebug = !showChatsDebug"
|
|
class="text-xs px-2 py-2 rounded bg-gray-700 hover:bg-gray-600 text-gray-300"
|
|
title="Debug chats array"
|
|
>
|
|
<UIcon name="i-lucide-bug" class="w-4 h-4" />
|
|
</button>
|
|
</div>
|
|
<!-- Chats Debug Panel -->
|
|
<div
|
|
v-if="showChatsDebug"
|
|
class="mt-2 p-2 rounded bg-gray-900 border border-gray-700 text-xs font-mono max-h-60 overflow-auto"
|
|
>
|
|
<div class="flex items-center justify-between mb-1">
|
|
<span class="text-gray-400">Chats ({{ chats.length }}):</span>
|
|
<button
|
|
@click="copyToClipboard(JSON.stringify(chats, null, 2))"
|
|
class="px-2 py-1 rounded bg-gray-700 hover:bg-gray-600 text-gray-300"
|
|
title="Copiar al portapapeles"
|
|
>
|
|
<UIcon name="i-lucide-copy" class="w-3 h-3" />
|
|
</button>
|
|
</div>
|
|
<pre class="text-green-400 whitespace-pre-wrap">{{ JSON.stringify(chats, null, 2) }}</pre>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="flex-1 overflow-y-auto">
|
|
<div v-if="chats.length === 0" class="p-8 text-center">
|
|
<UIcon name="i-lucide-message-square" class="w-12 h-12 text-[var(--wa-text-muted)] mx-auto mb-3" />
|
|
<p class="text-[var(--wa-text-muted)]">No hay conversaciones</p>
|
|
</div>
|
|
|
|
<div v-else>
|
|
<MessagesChatItem
|
|
v-for="chat in filteredChats"
|
|
:key="chat.id"
|
|
:chat="chat"
|
|
:active="selectedChat?.id === chat.id"
|
|
@click="selectedChat = chat"
|
|
@edit-alias="openAliasModal"
|
|
@delete-chat="openDeleteModal"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Message View -->
|
|
<div class="col-span-8 instance-card overflow-hidden flex flex-col">
|
|
<div v-if="!selectedChat" class="flex-1 flex items-center justify-center">
|
|
<div class="text-center">
|
|
<UIcon name="i-lucide-message-circle" class="w-16 h-16 text-[var(--wa-text-muted)] mx-auto mb-4" />
|
|
<p class="text-[var(--wa-text-muted)]">Selecciona una conversacion</p>
|
|
</div>
|
|
</div>
|
|
|
|
<template v-else>
|
|
<!-- Chat Header -->
|
|
<div class="p-4 border-b border-[var(--wa-border)]">
|
|
<div class="flex items-center gap-3">
|
|
<UAvatar :alt="selectedChat.name" size="md" />
|
|
<div class="flex-1">
|
|
<p class="font-medium text-[var(--wa-text)]">{{ selectedChat.name }}</p>
|
|
<p v-if="currentChatPresence" class="text-sm text-[var(--wa-green-light)]">
|
|
{{ currentChatPresence }}
|
|
</p>
|
|
<p v-else class="text-sm text-[var(--wa-text-muted)]">{{ selectedChat.jid }}</p>
|
|
</div>
|
|
<button
|
|
@click="showSelectedChatDebug = !showSelectedChatDebug"
|
|
class="text-xs px-2 py-2 rounded bg-gray-700 hover:bg-gray-600 text-gray-300"
|
|
title="Debug selected chat"
|
|
>
|
|
<UIcon name="i-lucide-bug" class="w-4 h-4" />
|
|
</button>
|
|
</div>
|
|
<!-- Selected Chat Debug Panel -->
|
|
<div
|
|
v-if="showSelectedChatDebug"
|
|
class="mt-2 p-2 rounded bg-gray-900 border border-gray-700 text-xs font-mono max-h-40 overflow-auto"
|
|
>
|
|
<div class="flex items-center justify-between mb-1">
|
|
<span class="text-gray-400">Selected Chat:</span>
|
|
<button
|
|
@click="copyToClipboard(JSON.stringify(selectedChat, null, 2))"
|
|
class="px-2 py-1 rounded bg-gray-700 hover:bg-gray-600 text-gray-300"
|
|
title="Copiar al portapapeles"
|
|
>
|
|
<UIcon name="i-lucide-copy" class="w-3 h-3" />
|
|
</button>
|
|
</div>
|
|
<pre class="text-green-400 whitespace-pre-wrap">{{ JSON.stringify(selectedChat, null, 2) }}</pre>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Messages -->
|
|
<div
|
|
ref="messagesContainer"
|
|
class="flex-1 overflow-y-auto p-4 space-y-2"
|
|
@scroll="handleScroll"
|
|
>
|
|
<!-- Load more indicator -->
|
|
<div
|
|
v-if="loadingMore"
|
|
class="flex justify-center py-2"
|
|
>
|
|
<UIcon name="i-lucide-loader-2" class="w-5 h-5 animate-spin text-[var(--wa-text-muted)]" />
|
|
</div>
|
|
<div
|
|
v-else-if="hasMoreMessages"
|
|
class="flex justify-center py-2"
|
|
>
|
|
<button
|
|
class="text-xs text-[var(--wa-text-muted)] hover:text-[var(--wa-text)]"
|
|
@click="loadMoreMessages"
|
|
>
|
|
Cargar mensajes anteriores
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Load from WhatsApp when local DB is exhausted -->
|
|
<div
|
|
v-else-if="!hasMoreMessages && !loadingWhatsAppHistory"
|
|
class="flex flex-col items-center py-3 gap-2"
|
|
>
|
|
<span class="text-xs text-[var(--wa-text-muted)]">
|
|
No hay mas mensajes en la base de datos
|
|
</span>
|
|
<button
|
|
class="text-xs px-3 py-1.5 rounded bg-[var(--wa-green)] text-white hover:opacity-90 transition-opacity"
|
|
@click="fetchWhatsAppHistory"
|
|
>
|
|
Cargar historial de WhatsApp
|
|
</button>
|
|
</div>
|
|
|
|
<div
|
|
v-else-if="loadingWhatsAppHistory"
|
|
class="flex flex-col items-center py-3 gap-2"
|
|
>
|
|
<UIcon name="i-lucide-loader-2" class="w-5 h-5 animate-spin text-[var(--wa-green)]" />
|
|
<span class="text-xs text-[var(--wa-text-muted)]">
|
|
Solicitando historial de WhatsApp...
|
|
</span>
|
|
</div>
|
|
|
|
<MessagesMessageBubble
|
|
v-for="message in reversedMessages"
|
|
:key="message.id"
|
|
:message="message"
|
|
:instance-id="selectedInstance?.value || ''"
|
|
:is-group="selectedChat?.isGroup || false"
|
|
@reply="handleReply"
|
|
@react="handleReact"
|
|
/>
|
|
</div>
|
|
|
|
<!-- Input - grows when media is selected -->
|
|
<div class="relative p-4 border-t border-[var(--wa-border)] flex-shrink-0">
|
|
<MessagesMessageInput
|
|
:replying-to="replyingTo"
|
|
@send="handleSendMessage"
|
|
@send-voice="handleSendVoice"
|
|
@send-contact="handleSendContact"
|
|
@send-poll="handleSendPoll"
|
|
@send-event="handleSendEvent"
|
|
@cancel-reply="replyingTo = null"
|
|
@typing="handleTyping"
|
|
@recording="handleRecordingPresence"
|
|
/>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Alias Modal -->
|
|
<MessagesChatAliasModal
|
|
v-model:open="showAliasModal"
|
|
:chat="chatToEditAlias"
|
|
:instance-id="selectedInstance?.value || ''"
|
|
@saved="handleAliasSaved"
|
|
/>
|
|
|
|
<!-- Delete Confirmation Modal -->
|
|
<UModal v-model:open="showDeleteModal">
|
|
<template #content>
|
|
<UCard>
|
|
<template #header>
|
|
<div class="flex items-center gap-3">
|
|
<div class="p-2 rounded-full bg-red-500/20">
|
|
<UIcon name="i-lucide-trash-2" class="w-5 h-5 text-red-500" />
|
|
</div>
|
|
<h3 class="text-lg font-semibold text-white">Eliminar chat</h3>
|
|
</div>
|
|
</template>
|
|
|
|
<div class="space-y-4">
|
|
<p class="text-[var(--wa-text-muted)]">
|
|
Estas a punto de eliminar el chat con
|
|
<strong class="text-white">{{ chatToDelete?.name || chatToDelete?.jid }}</strong>.
|
|
</p>
|
|
<div class="p-3 rounded-lg bg-red-500/10 border border-red-500/30">
|
|
<p class="text-sm text-red-400">
|
|
Esta accion eliminara todos los mensajes y datos relacionados.
|
|
Esta accion no se puede deshacer.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<template #footer>
|
|
<div class="flex justify-end gap-2">
|
|
<UButton
|
|
variant="ghost"
|
|
@click="showDeleteModal = false"
|
|
>
|
|
Cancelar
|
|
</UButton>
|
|
<UButton
|
|
color="error"
|
|
:loading="isDeleting"
|
|
@click="confirmDeleteChat"
|
|
>
|
|
Eliminar
|
|
</UButton>
|
|
</div>
|
|
</template>
|
|
</UCard>
|
|
</template>
|
|
</UModal>
|
|
|
|
<!-- New Chat Modal -->
|
|
<UModal v-model:open="showNewChatModal">
|
|
<template #content>
|
|
<div class="p-6 bg-[var(--wa-bg-dark)]">
|
|
<h3 class="text-lg font-semibold text-[var(--wa-text)] mb-4">
|
|
Nueva conversación
|
|
</h3>
|
|
|
|
<div class="space-y-4">
|
|
<div>
|
|
<label class="block text-sm text-[var(--wa-text-muted)] mb-2">
|
|
Número de teléfono
|
|
</label>
|
|
<UInput
|
|
v-model="newChatPhoneNumber"
|
|
placeholder="Ej: 50588887777"
|
|
icon="i-lucide-phone"
|
|
class="w-full"
|
|
@keyup.enter="startNewChat"
|
|
/>
|
|
<p class="text-xs text-[var(--wa-text-muted)] mt-1">
|
|
Ingresa el número con código de país, sin espacios ni guiones
|
|
</p>
|
|
</div>
|
|
|
|
<div v-if="newChatError" class="text-sm text-red-400">
|
|
{{ newChatError }}
|
|
</div>
|
|
|
|
<div class="flex justify-end gap-2 pt-2">
|
|
<UButton
|
|
color="neutral"
|
|
variant="ghost"
|
|
@click="closeNewChatModal"
|
|
>
|
|
Cancelar
|
|
</UButton>
|
|
<UButton
|
|
color="primary"
|
|
:loading="newChatLoading"
|
|
:disabled="!newChatPhoneNumber.trim()"
|
|
@click="startNewChat"
|
|
>
|
|
Iniciar conversación
|
|
</UButton>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
</UModal>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
definePageMeta({
|
|
layout: 'dashboard',
|
|
title: 'Mensajes',
|
|
icon: 'i-lucide-message-square'
|
|
})
|
|
|
|
const { instances, fetchInstances } = useInstances()
|
|
const { isConnected, lastEvent, on } = useRealtime()
|
|
|
|
const selectedInstance = ref<{ label: string; value: string } | null>(null)
|
|
|
|
// Presence composable
|
|
const instanceIdRef = computed(() => selectedInstance.value?.value || null)
|
|
const {
|
|
subscribeToPresence,
|
|
sendTyping,
|
|
sendRecording,
|
|
stopIndicator,
|
|
getPresenceText
|
|
} = usePresence(instanceIdRef)
|
|
const searchQuery = ref('')
|
|
const selectedChat = ref<any>(null)
|
|
const chats = ref<any[]>([])
|
|
const messages = ref<any[]>([])
|
|
const loadingChats = ref(false)
|
|
const loadingMessages = ref(false)
|
|
const loadingMore = ref(false)
|
|
const loadingWhatsAppHistory = ref(false)
|
|
const hasMoreMessages = ref(true)
|
|
const showDebugPanel = ref(false)
|
|
const showChatsDebug = ref(false)
|
|
const showSelectedChatDebug = ref(false)
|
|
|
|
// New chat modal state
|
|
const showNewChatModal = ref(false)
|
|
const newChatPhoneNumber = ref('')
|
|
const newChatLoading = ref(false)
|
|
const newChatError = ref('')
|
|
|
|
// Alias modal state
|
|
const showAliasModal = ref(false)
|
|
const chatToEditAlias = ref<any>(null)
|
|
|
|
// Delete confirmation modal state
|
|
const showDeleteModal = ref(false)
|
|
const chatToDelete = ref<any>(null)
|
|
const isDeleting = ref(false)
|
|
|
|
// Instance options for selector
|
|
const instanceOptions = computed(() =>
|
|
instances.value
|
|
.filter(i => i.status === 'connected')
|
|
.map(i => ({ label: i.name, value: i.id }))
|
|
)
|
|
|
|
// Auto-select first connected instance
|
|
watch(instanceOptions, (opts) => {
|
|
if (opts.length > 0 && !selectedInstance.value) {
|
|
selectedInstance.value = opts[0]
|
|
}
|
|
}, { immediate: true })
|
|
|
|
// Load chats when instance changes
|
|
watch(selectedInstance, async (instance) => {
|
|
if (!instance?.value) {
|
|
chats.value = []
|
|
return
|
|
}
|
|
|
|
loadingChats.value = true
|
|
try {
|
|
chats.value = await $fetch(`/api/messages/${instance.value}/chats`)
|
|
} catch (e) {
|
|
console.error('Error loading chats:', e)
|
|
chats.value = []
|
|
} finally {
|
|
loadingChats.value = false
|
|
}
|
|
})
|
|
|
|
// Load messages when chat changes
|
|
watch(selectedChat, async (chat) => {
|
|
if (!chat || !selectedInstance.value?.value) {
|
|
messages.value = []
|
|
return
|
|
}
|
|
|
|
loadingMessages.value = true
|
|
hasMoreMessages.value = true // Reset for new chat
|
|
try {
|
|
const result = await $fetch(`/api/messages/${selectedInstance.value.value}/${chat.id}?limit=50`)
|
|
messages.value = result as any[]
|
|
|
|
// If we got less than 50, there are no more messages
|
|
if ((result as any[]).length < 50) {
|
|
hasMoreMessages.value = false
|
|
}
|
|
|
|
// Subscribe to presence updates for this chat (if not a group)
|
|
if (!chat.isGroup && chat.jid) {
|
|
await subscribeToPresence(chat.jid)
|
|
}
|
|
} catch (e) {
|
|
console.error('Error loading messages:', e)
|
|
messages.value = []
|
|
} finally {
|
|
loadingMessages.value = false
|
|
}
|
|
})
|
|
|
|
// Load more messages (infinite scroll)
|
|
const loadMoreMessages = async () => {
|
|
if (!selectedInstance.value?.value || !selectedChat.value || loadingMore.value || !hasMoreMessages.value) return
|
|
|
|
const oldestMessage = messages.value[messages.value.length - 1] // Messages are in DESC order
|
|
if (!oldestMessage) return
|
|
|
|
loadingMore.value = true
|
|
const previousScrollHeight = messagesContainer.value?.scrollHeight || 0
|
|
|
|
try {
|
|
const instanceId = selectedInstance.value.value
|
|
const chatId = selectedChat.value.id
|
|
const before = oldestMessage.timestamp
|
|
|
|
const olderMessages = await $fetch(`/api/messages/${instanceId}/${chatId}?limit=50&before=${before}`)
|
|
|
|
if ((olderMessages as any[]).length < 50) {
|
|
hasMoreMessages.value = false
|
|
}
|
|
|
|
if ((olderMessages as any[]).length > 0) {
|
|
messages.value = [...messages.value, ...(olderMessages as any[])]
|
|
|
|
// Maintain scroll position after loading
|
|
nextTick(() => {
|
|
if (messagesContainer.value) {
|
|
const newScrollHeight = messagesContainer.value.scrollHeight
|
|
messagesContainer.value.scrollTop = newScrollHeight - previousScrollHeight
|
|
}
|
|
})
|
|
}
|
|
} catch (e) {
|
|
console.error('Error loading more messages:', e)
|
|
} finally {
|
|
loadingMore.value = false
|
|
}
|
|
}
|
|
|
|
// Handle scroll for infinite scroll
|
|
const handleScroll = () => {
|
|
if (!messagesContainer.value) return
|
|
|
|
// Load more when scrolled near the top (< 100px)
|
|
if (messagesContainer.value.scrollTop < 100 && hasMoreMessages.value && !loadingMore.value) {
|
|
loadMoreMessages()
|
|
}
|
|
}
|
|
|
|
// Fetch message history from WhatsApp (when local DB is exhausted)
|
|
const fetchWhatsAppHistory = async () => {
|
|
if (!selectedInstance.value?.value || !selectedChat.value) return
|
|
|
|
loadingWhatsAppHistory.value = true
|
|
const instanceId = selectedInstance.value.value
|
|
const chatId = selectedChat.value.id
|
|
|
|
try {
|
|
// Get the oldest message from local DB
|
|
const oldest = await $fetch<{
|
|
hasMessages: boolean
|
|
messageKey: { remoteJid: string; id: string; fromMe: boolean } | null
|
|
timestamp: number | null
|
|
}>(`/api/messages/${instanceId}/${chatId}/oldest`)
|
|
|
|
// Create a promise that resolves when history.synced event arrives
|
|
const historyPromise = new Promise<{ messagesCount: number; timeout?: boolean }>((resolve) => {
|
|
// Set up listener for history.synced event
|
|
const cleanup = on('history.synced', (data) => {
|
|
if (data.instanceId === instanceId) {
|
|
cleanup() // Remove listener
|
|
resolve({ messagesCount: data.messagesCount })
|
|
}
|
|
})
|
|
|
|
// Timeout de seguridad (15 segundos)
|
|
setTimeout(() => {
|
|
cleanup() // Remove listener
|
|
resolve({ messagesCount: 0, timeout: true })
|
|
}, 15000)
|
|
})
|
|
|
|
// Request history from WhatsApp
|
|
if (!oldest.hasMessages) {
|
|
// No messages in DB, request without reference
|
|
await $fetch('/api/debug/history/fetch', {
|
|
method: 'POST',
|
|
body: {
|
|
instanceId,
|
|
count: 100
|
|
}
|
|
})
|
|
} else {
|
|
// Request messages older than the oldest in DB
|
|
await $fetch('/api/debug/history/fetch', {
|
|
method: 'POST',
|
|
body: {
|
|
instanceId,
|
|
count: 100,
|
|
oldestMsgKey: oldest.messageKey,
|
|
oldestMsgTimestamp: oldest.timestamp
|
|
}
|
|
})
|
|
}
|
|
|
|
// Wait for history sync event or timeout
|
|
const result = await historyPromise
|
|
|
|
if (result.timeout) {
|
|
console.log('[History] Timeout waiting for history sync - WhatsApp may not have more messages')
|
|
} else if (result.messagesCount > 0) {
|
|
console.log(`[History] Received ${result.messagesCount} messages from history sync`)
|
|
await reloadMessages()
|
|
hasMoreMessages.value = true
|
|
} else {
|
|
console.log('[History] No messages received from history sync')
|
|
}
|
|
} catch (e) {
|
|
console.error('Error fetching WhatsApp history:', e)
|
|
} finally {
|
|
loadingWhatsAppHistory.value = false
|
|
}
|
|
}
|
|
|
|
// Computed presence text for current chat
|
|
const currentChatPresence = computed(() => {
|
|
if (!selectedChat.value?.jid) return null
|
|
return getPresenceText(selectedChat.value.jid)
|
|
})
|
|
|
|
const filteredChats = computed(() => {
|
|
if (!searchQuery.value) return chats.value
|
|
return chats.value.filter(chat =>
|
|
chat.name?.toLowerCase().includes(searchQuery.value.toLowerCase())
|
|
)
|
|
})
|
|
|
|
// Reverse messages for display (API returns DESC, we want ASC for chronological order)
|
|
const reversedMessages = computed(() => {
|
|
return [...messages.value].reverse()
|
|
})
|
|
|
|
const messagesContainer = ref<HTMLElement | null>(null)
|
|
|
|
// Scroll to bottom when new messages arrive
|
|
const scrollToBottom = () => {
|
|
nextTick(() => {
|
|
if (messagesContainer.value) {
|
|
messagesContainer.value.scrollTop = messagesContainer.value.scrollHeight
|
|
}
|
|
})
|
|
}
|
|
|
|
// Watch for message changes and scroll to bottom
|
|
watch(messages, () => {
|
|
scrollToBottom()
|
|
}, { deep: true })
|
|
|
|
// Copy to clipboard function
|
|
const toast = useToast()
|
|
const copyToClipboard = async (text: string) => {
|
|
try {
|
|
await navigator.clipboard.writeText(text)
|
|
toast.add({
|
|
title: 'Copiado al portapapeles',
|
|
icon: 'i-lucide-check',
|
|
color: 'success',
|
|
timeout: 2000
|
|
})
|
|
} catch (err) {
|
|
console.error('Failed to copy:', err)
|
|
toast.add({
|
|
title: 'Error al copiar',
|
|
icon: 'i-lucide-x',
|
|
color: 'error',
|
|
timeout: 2000
|
|
})
|
|
}
|
|
}
|
|
|
|
// Reply state (will be used for quote functionality)
|
|
const replyingTo = ref<any>(null)
|
|
|
|
const handleSendMessage = async (content: string, files: File[], caption: string, quotedId?: string, stickerModes?: boolean[]) => {
|
|
if (!selectedInstance.value?.value || !selectedChat.value) return
|
|
|
|
try {
|
|
const instanceId = selectedInstance.value.value
|
|
const chatId = selectedChat.value.id
|
|
const endpoint = `/api/messages/${instanceId}/${chatId}/send`
|
|
|
|
// If we have files, send as media (with optional sticker flags)
|
|
if (files.length > 0) {
|
|
const formData = new FormData()
|
|
|
|
// Add files
|
|
for (const file of files) {
|
|
formData.append('files', file)
|
|
}
|
|
|
|
// Add caption if provided
|
|
if (caption) {
|
|
formData.append('caption', caption)
|
|
}
|
|
|
|
// Add quoted message ID if replying
|
|
if (quotedId || replyingTo.value?.messageId) {
|
|
formData.append('quotedMessageId', quotedId || replyingTo.value.messageId)
|
|
}
|
|
|
|
// Add sticker modes if any images should be sent as stickers
|
|
if (stickerModes && stickerModes.length > 0) {
|
|
formData.append('asSticker', JSON.stringify(stickerModes))
|
|
}
|
|
|
|
await $fetch(endpoint, {
|
|
method: 'POST',
|
|
body: formData
|
|
})
|
|
} else if (content) {
|
|
// Send text message
|
|
await $fetch(endpoint, {
|
|
method: 'POST',
|
|
body: {
|
|
content,
|
|
quotedMessageId: quotedId || replyingTo.value?.messageId
|
|
}
|
|
})
|
|
}
|
|
|
|
// Clear reply state
|
|
replyingTo.value = null
|
|
|
|
// Reload messages
|
|
messages.value = await $fetch(`/api/messages/${instanceId}/${chatId}`)
|
|
} catch (e: any) {
|
|
console.error('Error sending message:', e)
|
|
|
|
// Extract error message from response
|
|
let errorMessage = 'Error al enviar el mensaje'
|
|
if (e?.data?.message) {
|
|
errorMessage = e.data.message
|
|
} else if (e?.message) {
|
|
errorMessage = e.message
|
|
}
|
|
|
|
toast.add({
|
|
title: 'Error de envío',
|
|
description: errorMessage,
|
|
color: 'error',
|
|
duration: 5000
|
|
})
|
|
}
|
|
}
|
|
|
|
// Handle voice message
|
|
const handleSendVoice = async (audioFile: File) => {
|
|
if (!selectedInstance.value?.value || !selectedChat.value) return
|
|
|
|
try {
|
|
const instanceId = selectedInstance.value.value
|
|
const chatId = selectedChat.value.id
|
|
|
|
const formData = new FormData()
|
|
formData.append('files', audioFile)
|
|
formData.append('isPtt', 'true') // Mark as push-to-talk (voice note)
|
|
|
|
await $fetch(`/api/messages/${instanceId}/${chatId}/send`, {
|
|
method: 'POST',
|
|
body: formData
|
|
})
|
|
|
|
// Reload messages
|
|
messages.value = await $fetch(`/api/messages/${instanceId}/${chatId}`)
|
|
} catch (e: any) {
|
|
console.error('Error sending voice message:', e)
|
|
|
|
let errorMessage = 'Error al enviar la nota de voz'
|
|
if (e?.data?.message) {
|
|
errorMessage = e.data.message
|
|
} else if (e?.message) {
|
|
errorMessage = e.message
|
|
}
|
|
|
|
toast.add({
|
|
title: 'Error de envío',
|
|
description: errorMessage,
|
|
color: 'error',
|
|
duration: 5000
|
|
})
|
|
}
|
|
}
|
|
|
|
// Handle reply action
|
|
const handleReply = (message: any) => {
|
|
replyingTo.value = message
|
|
// TODO: Focus input and show reply preview
|
|
console.log('Reply to:', message)
|
|
}
|
|
|
|
// Handle react action
|
|
const handleReact = async (message: any, emoji: string) => {
|
|
if (!selectedInstance.value?.value) return
|
|
|
|
try {
|
|
await $fetch(`/api/messages/${selectedInstance.value.value}/react`, {
|
|
method: 'POST',
|
|
body: {
|
|
messageId: message.messageId,
|
|
emoji
|
|
}
|
|
})
|
|
} catch (e) {
|
|
console.error('Error sending reaction:', e)
|
|
}
|
|
}
|
|
|
|
// Alias modal functions
|
|
const openAliasModal = (chat: any) => {
|
|
chatToEditAlias.value = {
|
|
...chat,
|
|
originalName: chat.originalName || chat.name
|
|
}
|
|
showAliasModal.value = true
|
|
}
|
|
|
|
const handleAliasSaved = (updatedChat: any) => {
|
|
// Update chat in list
|
|
const index = chats.value.findIndex(c => c.id === updatedChat.id)
|
|
if (index !== -1) {
|
|
chats.value[index] = {
|
|
...chats.value[index],
|
|
alias: updatedChat.alias,
|
|
name: updatedChat.name
|
|
}
|
|
}
|
|
|
|
// Update selected chat if it's the same
|
|
if (selectedChat.value?.id === updatedChat.id) {
|
|
selectedChat.value = {
|
|
...selectedChat.value,
|
|
alias: updatedChat.alias,
|
|
name: updatedChat.name
|
|
}
|
|
}
|
|
}
|
|
|
|
// Delete modal functions
|
|
const openDeleteModal = (chat: any) => {
|
|
chatToDelete.value = chat
|
|
showDeleteModal.value = true
|
|
}
|
|
|
|
const confirmDeleteChat = async () => {
|
|
if (!chatToDelete.value || !selectedInstance.value?.value) return
|
|
|
|
isDeleting.value = true
|
|
try {
|
|
const result = await $fetch(`/api/messages/${selectedInstance.value.value}/${chatToDelete.value.id}`, {
|
|
method: 'DELETE'
|
|
})
|
|
|
|
if (result.success) {
|
|
// Remove chat from list
|
|
chats.value = chats.value.filter(c => c.id !== chatToDelete.value.id)
|
|
|
|
// Clear selected chat if it was the deleted one
|
|
if (selectedChat.value?.id === chatToDelete.value.id) {
|
|
selectedChat.value = null
|
|
messages.value = []
|
|
}
|
|
|
|
toast.add({
|
|
title: 'Chat eliminado',
|
|
description: `Se eliminaron ${result.deleted.messagesDeleted} mensajes`,
|
|
icon: 'i-lucide-check',
|
|
color: 'success',
|
|
timeout: 3000
|
|
})
|
|
|
|
showDeleteModal.value = false
|
|
chatToDelete.value = null
|
|
}
|
|
} catch (error: any) {
|
|
console.error('Error deleting chat:', error)
|
|
toast.add({
|
|
title: 'Error al eliminar',
|
|
description: error?.data?.message || error?.message || 'No se pudo eliminar el chat',
|
|
icon: 'i-lucide-x',
|
|
color: 'error',
|
|
timeout: 5000
|
|
})
|
|
} finally {
|
|
isDeleting.value = false
|
|
}
|
|
}
|
|
|
|
// New chat modal functions
|
|
const closeNewChatModal = () => {
|
|
showNewChatModal.value = false
|
|
newChatPhoneNumber.value = ''
|
|
newChatError.value = ''
|
|
}
|
|
|
|
const startNewChat = async () => {
|
|
if (!selectedInstance.value?.value || !newChatPhoneNumber.value.trim()) return
|
|
|
|
newChatLoading.value = true
|
|
newChatError.value = ''
|
|
|
|
try {
|
|
const result = await $fetch(`/api/messages/${selectedInstance.value.value}/new-chat`, {
|
|
method: 'POST',
|
|
body: {
|
|
phoneNumber: newChatPhoneNumber.value.trim()
|
|
}
|
|
})
|
|
|
|
if (result.success && result.chat) {
|
|
// If it's a new chat, add it to the list
|
|
if (result.isNew) {
|
|
chats.value.unshift(result.chat)
|
|
}
|
|
|
|
// Select the chat
|
|
selectedChat.value = result.chat
|
|
|
|
// Close the modal
|
|
closeNewChatModal()
|
|
|
|
toast.add({
|
|
title: result.isNew ? 'Conversación creada' : 'Conversación encontrada',
|
|
icon: 'i-lucide-check',
|
|
color: 'success',
|
|
timeout: 2000
|
|
})
|
|
}
|
|
} catch (e: any) {
|
|
console.error('Error creating new chat:', e)
|
|
newChatError.value = e?.data?.message || e?.message || 'Error al crear la conversación'
|
|
} finally {
|
|
newChatLoading.value = false
|
|
}
|
|
}
|
|
|
|
// Handle typing event from input
|
|
const handleTyping = () => {
|
|
if (!selectedChat.value?.jid || selectedChat.value?.isGroup) return
|
|
sendTyping(selectedChat.value.jid)
|
|
}
|
|
|
|
// Handle recording presence
|
|
const handleRecordingPresence = (isRecording: boolean) => {
|
|
if (!selectedChat.value?.jid || selectedChat.value?.isGroup) return
|
|
|
|
if (isRecording) {
|
|
sendRecording(selectedChat.value.jid)
|
|
} else {
|
|
stopIndicator(selectedChat.value.jid)
|
|
}
|
|
}
|
|
|
|
// Handle contact send
|
|
interface ContactInfo {
|
|
displayName: string
|
|
phoneNumber: string
|
|
organization?: string
|
|
}
|
|
|
|
const handleSendContact = async (contacts: ContactInfo[], quotedId?: string) => {
|
|
if (!selectedInstance.value?.value || !selectedChat.value) return
|
|
|
|
try {
|
|
const instanceId = selectedInstance.value.value
|
|
const chatId = selectedChat.value.id
|
|
|
|
await $fetch(`/api/messages/${instanceId}/${chatId}/send`, {
|
|
method: 'POST',
|
|
body: {
|
|
type: 'contact',
|
|
contacts,
|
|
quotedMessageId: quotedId || replyingTo.value?.messageId
|
|
}
|
|
})
|
|
|
|
replyingTo.value = null
|
|
messages.value = await $fetch(`/api/messages/${instanceId}/${chatId}`)
|
|
} catch (e: any) {
|
|
console.error('Error sending contact:', e)
|
|
toast.add({
|
|
title: 'Error de envío',
|
|
description: e?.data?.message || e?.message || 'Error al enviar el contacto',
|
|
color: 'error',
|
|
duration: 5000
|
|
})
|
|
}
|
|
}
|
|
|
|
// Handle poll send
|
|
interface PollData {
|
|
name: string
|
|
options: string[]
|
|
selectableCount: number
|
|
}
|
|
|
|
const handleSendPoll = async (poll: PollData, quotedId?: string) => {
|
|
if (!selectedInstance.value?.value || !selectedChat.value) return
|
|
|
|
try {
|
|
const instanceId = selectedInstance.value.value
|
|
const chatId = selectedChat.value.id
|
|
|
|
await $fetch(`/api/messages/${instanceId}/${chatId}/send`, {
|
|
method: 'POST',
|
|
body: {
|
|
type: 'poll',
|
|
...poll,
|
|
quotedMessageId: quotedId || replyingTo.value?.messageId
|
|
}
|
|
})
|
|
|
|
replyingTo.value = null
|
|
messages.value = await $fetch(`/api/messages/${instanceId}/${chatId}`)
|
|
} catch (e: any) {
|
|
console.error('Error sending poll:', e)
|
|
toast.add({
|
|
title: 'Error de envío',
|
|
description: e?.data?.message || e?.message || 'Error al enviar la encuesta',
|
|
color: 'error',
|
|
duration: 5000
|
|
})
|
|
}
|
|
}
|
|
|
|
// Handle event send
|
|
interface EventData {
|
|
name: string
|
|
startDate: string
|
|
endDate?: string
|
|
description?: string
|
|
location?: {
|
|
name?: string
|
|
address?: string
|
|
latitude?: number
|
|
longitude?: number
|
|
}
|
|
}
|
|
|
|
const handleSendEvent = async (eventData: EventData, quotedId?: string) => {
|
|
if (!selectedInstance.value?.value || !selectedChat.value) return
|
|
|
|
try {
|
|
const instanceId = selectedInstance.value.value
|
|
const chatId = selectedChat.value.id
|
|
|
|
await $fetch(`/api/messages/${instanceId}/${chatId}/send`, {
|
|
method: 'POST',
|
|
body: {
|
|
type: 'event',
|
|
...eventData,
|
|
quotedMessageId: quotedId || replyingTo.value?.messageId
|
|
}
|
|
})
|
|
|
|
replyingTo.value = null
|
|
messages.value = await $fetch(`/api/messages/${instanceId}/${chatId}`)
|
|
} catch (e: any) {
|
|
console.error('Error sending event:', e)
|
|
toast.add({
|
|
title: 'Error de envío',
|
|
description: e?.data?.message || e?.message || 'Error al enviar el evento',
|
|
color: 'error',
|
|
duration: 5000
|
|
})
|
|
}
|
|
}
|
|
|
|
// Reload chats for current instance
|
|
const reloadChats = async () => {
|
|
if (!selectedInstance.value?.value) return
|
|
try {
|
|
chats.value = await $fetch(`/api/messages/${selectedInstance.value.value}/chats`)
|
|
} catch (e) {
|
|
console.error('Error reloading chats:', e)
|
|
}
|
|
}
|
|
|
|
// Reload messages for current chat
|
|
const reloadMessages = async () => {
|
|
if (!selectedInstance.value?.value || !selectedChat.value) return
|
|
try {
|
|
messages.value = await $fetch(`/api/messages/${selectedInstance.value.value}/${selectedChat.value.id}`)
|
|
} catch (e) {
|
|
console.error('Error reloading messages:', e)
|
|
}
|
|
}
|
|
|
|
// Load instances on mount
|
|
onMounted(() => {
|
|
fetchInstances()
|
|
|
|
// Listen for real-time message events
|
|
on('message.received', (data) => {
|
|
console.log('[Messages] New message received:', data)
|
|
|
|
// If it's for the current instance, reload chats
|
|
if (data.instanceId === selectedInstance.value?.value) {
|
|
reloadChats()
|
|
|
|
// If it's for the current chat, reload messages
|
|
if (selectedChat.value?.jid === data.message?.key?.remoteJid) {
|
|
reloadMessages()
|
|
}
|
|
}
|
|
})
|
|
|
|
on('message.sent', (data) => {
|
|
console.log('[Messages] Message sent:', data)
|
|
// Already handled by the send function
|
|
})
|
|
|
|
// Listen for reaction updates
|
|
on('message.reaction', (data) => {
|
|
console.log('[Messages] Reaction received:', data)
|
|
|
|
// If it's for the current instance and chat, reload messages
|
|
if (data.instanceId === selectedInstance.value?.value && selectedChat.value) {
|
|
reloadMessages()
|
|
}
|
|
})
|
|
|
|
// Listen for message status updates
|
|
on('message.status', (data) => {
|
|
console.log('[Messages] Message status update:', data)
|
|
|
|
// Update message status in local state
|
|
if (data.instanceId === selectedInstance.value?.value) {
|
|
const messageIndex = messages.value.findIndex(m => m.messageId === data.messageId)
|
|
if (messageIndex !== -1) {
|
|
messages.value[messageIndex].status = data.status
|
|
}
|
|
}
|
|
})
|
|
})
|
|
</script>
|