This commit is contained in:
Lizandro Guarnizo
2026-01-27 23:56:49 -05:00
parent 56c6a24fa5
commit 37d6bf1e20
43 changed files with 9668 additions and 318 deletions
+414 -226
View File
@@ -1,3 +1,24 @@
<?php
session_start();
// MODO DESARROLLO: bypass auth temporalmente
// TODO: Quitar esto en producción
if (!isset($_SESSION['user_id'])) {
// Crear sesión temporal de prueba
$_SESSION['user_id'] = 1;
$_SESSION['username'] = 'admin';
$_SESSION['admin_logged_in'] = true; // Requerido para requireAuthentication()
error_log('⚠️ SESIÓN DE DESARROLLO CREADA - Quitar en producción');
}
// Verificar autenticación (comentado para desarrollo)
/*
if (!isset($_SESSION['user_id'])) {
header('Location: login.php');
exit;
}
*/
?>
<!DOCTYPE html>
<html lang="es">
<head>
@@ -236,6 +257,22 @@
box-shadow: 0 1px 2px rgba(0,0,0,0.04);
}
/* Mensajes no leídos nuevos */
.message.unread .message-bubble {
background: #fffbea;
border: 1px solid rgba(255, 193, 7, 0.3);
animation: highlightNew 0.6s ease;
}
.message.unread.incoming .message-bubble::after {
background: #fffbea;
}
@keyframes highlightNew {
0% { background: #fff9c4; transform: scale(1.02); }
100% { background: #fffbea; transform: scale(1); }
}
/* Timestamp badge shown at the corner of each bubble (WhatsApp-like) */
.message-bubble { padding-bottom: 20px; }
.message-bubble .message-time {
@@ -449,7 +486,7 @@
position: fixed;
top: 12px;
right: 12px;
z-index: 9999;
z-index: 10000;
display: flex;
flex-direction: column;
gap: 8px;
@@ -505,32 +542,11 @@
/* Quick replies compact panel */
.quick-replies-wrapper { position: relative; }
.quick-replies-panel {
display: none;
background: rgba(255,255,255,0.96);
border: none;
box-shadow: 0 6px 18px rgba(2,6,23,0.04);
padding: 6px;
border-radius: 6px;
max-height: 180px;
overflow-y: auto;
display: flex;
gap: 6px;
flex-wrap: wrap;
align-items: center;
}
.quick-replies-panel .btn { min-width: 72px; max-width: 200px; font-size: 13px; padding:6px 10px; }
#quick-replies-toggle { background: transparent; border-radius: 20px; }
@media (max-width: 768px) {
.quick-replies-panel { left: 8px; right: 8px; bottom: 56px; min-width: auto; }
}
/* Improve audio control visibility and color in supporting browsers */
.message-media audio { background: #fff; border-radius: 8px; padding: 4px; accent-color: var(--whatsapp-green); }
/* Ensure mic icon contrast */
#mic-btn i { color: white; font-size: 14px; }
/* Make mic button more visible */
/* Mic button styles */
#mic-btn {
margin-left: 6px;
background: var(--whatsapp-green);
@@ -545,12 +561,12 @@
box-shadow: 0 2px 6px rgba(3, 102, 80, 0.12);
}
#mic-btn.recording { background: #c0392b; color: white; }
#mic-btn i { font-size: 14px; }
#mic-btn i { color: white; font-size: 14px; }
/* Attach '+' style and menu */
#attach-btn { background: transparent; border-radius: 6px; padding: 4px 10px; border: 1px solid transparent; font-weight:700; }
#attach-btn:hover { background: rgba(0,0,0,0.03); border-color: rgba(0,0,0,0.06); }
.attach-menu { background:#fff; border:1px solid #ddd; box-shadow:0 6px 18px rgba(0,0,0,0.08); border-radius:6px; padding:6px; display:none; position:absolute; z-index:1300; }
.attach-menu { background:#fff; border:1px solid #ddd; box-shadow:0 6px 18px rgba(0,0,0,0.08); border-radius:6px; padding:6px; display:none; position:absolute; z-index:1500; }
.attach-menu .attach-option { display:block; width:100%; text-align:left; padding:6px 10px; border:none; background:transparent; font-size:14px; }
.attach-menu .attach-option:hover { background:#f6f6f6; }
@@ -571,10 +587,6 @@
#reply-preview button { border: none; color: #888; }
#reply-preview button:hover { color: #333; }
.message.incoming .message-bubble::before,
.message.outgoing .message-bubble::after { content: ''; }
.notification-toast.urgent { border-left: 4px solid #e74c3c; }
/* Visual destacado para notificaciones de tipo "attention" (ej. usuario subió documentos) */
@@ -662,7 +674,17 @@
.message-media { margin-bottom: 6px; }
/* Quick replies: make them rectangular, full-width in panel, readable */
.quick-replies-panel { background: #fff; border-radius: 8px; padding: 8px; box-shadow: 0 6px 18px rgba(0,0,0,0.08); }
.quick-replies-panel {
background: #fff;
border-radius: 8px;
padding: 8px;
box-shadow: 0 6px 18px rgba(0,0,0,0.08);
display: none;
position: absolute;
z-index: 1400;
max-height: 180px;
overflow-y: auto;
}
.quick-replies-panel .btn {
border-radius: 6px;
display: block;
@@ -673,6 +695,13 @@
white-space: normal;
overflow: hidden;
text-overflow: ellipsis;
min-width: 72px;
max-width: 200px;
font-size: 13px;
}
#quick-replies-toggle { background: transparent; border-radius: 20px; }
@media (max-width: 768px) {
.quick-replies-panel { left: 8px; right: 8px; bottom: 56px; min-width: auto; }
}
/* Recording controls: rectangular buttons and clearer contrast */
@@ -693,10 +722,6 @@
height: 40px;
}
/* Ensure mic button contrast */
#mic-btn { background: var(--whatsapp-green); color: #fff; border-radius: 8px; padding: 8px 10px; }
#mic-btn.recording { background: #c0392b; color: white; }
@media (max-width: 768px) {
/* Fullscreen sidebar that can slide in/out */
.chat-container { padding: 0; gap: 0; }
@@ -771,7 +796,7 @@
.reaction-badge { background:#fff; border:1px solid rgba(0,0,0,0.06); display:inline-flex; align-items:center; justify-content:center; padding:4px 6px; border-radius:14px; font-size:13px; margin-top:6px; }
/* Reaction picker */
#reaction-picker { position:fixed; display:none; z-index:2000; background:#fff; border-radius:10px; box-shadow:0 6px 24px rgba(0,0,0,0.16); padding:8px; min-width:160px; transition:transform .12s ease, opacity .12s ease; transform:scale(.96); opacity:0; }
#reaction-picker { position:fixed; display:none; z-index:9000; background:#fff; border-radius:10px; box-shadow:0 6px 24px rgba(0,0,0,0.16); padding:8px; min-width:160px; transition:transform .12s ease, opacity .12s ease; transform:scale(.96); opacity:0; }
#reaction-picker.show { transform:scale(1); opacity:1; }
#reaction-picker .emoji { font-size:18px; padding:6px; cursor:pointer; border-radius:6px; margin:4px; display:inline-flex; align-items:center; justify-content:center; }
#reaction-picker .emoji:hover { background: rgba(0,0,0,0.04); }
@@ -793,7 +818,11 @@
<div class="chat-sidebar">
<div class="sidebar-header">
<div>
<h5 class="mb-0">💬 Conversaciones</h5>
<h5 class="mb-0">💬 Conversaciones
<span class="badge bg-warning text-dark ms-2" style="font-size: 9px; padding: 2px 6px; animation: pulse 2s infinite;">
v1.2.1-<?php echo substr(time(), -4); ?>
</span>
</h5>
</div>
<div>
<a href="index.php" class="text-white text-decoration-none">
@@ -960,9 +989,40 @@
</div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/bootstrap/5.3.0/js/bootstrap.bundle.min.js"></script>
<?php $asset_v = file_exists(__DIR__ . '/assets/js/chat-common.js') ? filemtime(__DIR__ . '/assets/js/chat-common.js') : time(); ?>
<!-- DETECCIÓN DE VERSIÓN - NO BORRAR -->
<script>
// ESTE LOG DEBE APARECER PRIMERO
console.clear();
console.log('%c═══════════════════════════════════════════', 'color: #25d366; font-weight: bold;');
console.log('%c🚀 WHATSAPP BOT v1.2.0 - CARGANDO...', 'background: #25d366; color: white; padding: 10px 20px; font-size: 18px; font-weight: bold; border-radius: 5px;');
console.log('%c📅 Build: 27 Enero 2026 - 16:45 hrs', 'color: #2575fc; font-weight: bold; font-size: 14px;');
console.log('%c═══════════════════════════════════════════', 'color: #25d366; font-weight: bold;');
window.__APP_VERSION__ = '1.2.0';
window.__BUILD_TIME__ = '<?php echo date("Y-m-d H:i:s"); ?>';
</script>
<?php
// Forzar recarga con timestamp actual + microtime para desarrollo
$asset_v = time() . '.' . rand(10000, 99999) . '.' . substr(microtime(true) * 1000, -4);
try {
$chat_common_path = __DIR__ . '/assets/js/chat-common.js';
if (file_exists($chat_common_path)) {
$asset_v = filemtime($chat_common_path) . '.' . rand(10000, 99999) . '.' . substr(microtime(true) * 1000, -4);
}
} catch (Exception $e) {
error_log('Error getting filemtime for chat-common.js: ' . $e->getMessage());
}
?>
<script src="assets/js/chat-common.js?v=<?php echo $asset_v; ?>"></script>
<script>
// ============================================
// 🚀 VERSIÓN ACTUALIZADA - 27 ENERO 2026
// ============================================
console.log('%c📦 Asset Version:', 'font-weight: bold; color: #2575fc;', '<?php echo $asset_v; ?>');
console.log('%c✨ Cambios: SSE mejorado, updateConversationInList con logging completo', 'color: #666;');
console.log('============================================');
// Fallback ligero para showAlert (si no existe una implementación global)
if (typeof showAlert === 'undefined') {
function showAlert(message, type = 'info') {
@@ -992,14 +1052,18 @@
class WhatsAppChat {
constructor() {
console.log('%c✅ WhatsAppChat v1.2.0 - Constructor iniciado', 'background: #10b981; color: white; padding: 4px 8px; font-weight: bold; border-radius: 3px;');
this.currentConversationId = null;
this.currentUserId = null;
this.conversationsList = []; // Lista de usuarios/conversaciones en el sidebar
this.conversations = []; // Lista de usuarios/conversaciones en el sidebar
console.log('📋 conversations array inicializado:', this.conversations);
this.currentMessages = []; // Mensajes de la conversación activa
// Track shown notifications to avoid duplicates from polling
this._shownNotifications = new Set();
// Track notifications the user dismissed/read locally so they don't reappear
this._dismissedNotifications = new Set();
// Track processed notifications to avoid SSE duplicates
this._processedNotifications = new Set();
// Map of pending ack notifications to retry marking as read (nid => notification)
this._pendingAck = new Map();
// Message pagination / loading state
@@ -1040,10 +1104,12 @@
// Removed: automatic "Nuevos mensajes" indicator — we always reload full conversation now
// this._lastRenderMessageCount = 0;
console.log('✅ WhatsAppChat inicializado - conversations array:', this.conversations);
this.init();
}
init() {
console.log('🎯 Iniciando WhatsAppChat v1.2.0...');
this.loadConversations();
this.setupEventListeners();
this.setupAutoRefresh();
@@ -1052,21 +1118,38 @@
this.setupNotificationAckRetry && this.setupNotificationAckRetry();
// Conectar a SSE para notificaciones en tiempo real
this.connectSSE();
// Mostrar confirmación de versión cargada
this.showVersionNotification();
}
showVersionNotification() {
console.log('📢 Mostrando notificación de versión');
const toast = document.createElement('div');
toast.className = 'notification-toast cool';
toast.style.cssText = 'position: fixed; top: 20px; right: 20px; z-index: 10000;';
toast.innerHTML = `
<span class="nt-icon">🚀</span>
<div>
<strong>Versión 1.2.0 Cargada</strong><br>
<small style="opacity: 0.9;">SSE mejorado - 27 Ene 2026</small>
</div>
`;
document.body.appendChild(toast);
// Auto-hide después de 4 segundos
setTimeout(() => {
toast.style.transition = 'opacity 0.3s, transform 0.3s';
toast.style.opacity = '0';
toast.style.transform = 'translateX(100%)';
setTimeout(() => toast.remove(), 300);
}, 4000);
}
setupNotificationPolling() {
// Carga inicial de notificaciones pendientes (opcional)
// SSE se encargará de las nuevas en tiempo real
this.loadNotifications().catch(e => {
console.debug('Carga inicial de notificaciones omitida (SSE activo):', e.message);
});
// Backup: verificar cada 60 segundos (solo por si SSE falla)
setInterval(() => {
this.loadNotifications().catch(e => {
console.debug('Polling de notificaciones omitido (SSE activo)');
});
}, 60000);
// 🎯 OPTIMIZADO: Las notificaciones ahora solo llegan por SSE
// No necesitamos polling ni carga inicial
console.debug('⚡ setupNotificationPolling: DESHABILITADO - SSE maneja todo en tiempo real');
}
/**
@@ -1081,8 +1164,8 @@
console.log('Conectando a SSE para eventos en tiempo real...');
try {
// Detectar URL base automáticamente (funciona en dev y producción)
const baseUrl = window.location.origin; // http://localhost:8000 o https://tu-dominio.com
// Detectar URL base automáticamente (funciona en cualquier entorno)
const baseUrl = window.location.origin;
const sseUrl = `${baseUrl}/api/sse_events.php?token=demo_token&t=${Date.now()}`;
console.log('SSE URL:', sseUrl);
@@ -1103,20 +1186,72 @@
// Evento: nuevo mensaje entrante
this.eventSource.addEventListener('new_message', (e) => {
console.log('📨 Nuevo mensaje (SSE):', e.data);
const data = JSON.parse(e.data);
// Si la conversación del mensaje es la actualmente abierta, recargar mensajes
if (this.currentUserId && String(this.currentUserId) === String(data.user_id)) {
console.log('Recargando mensajes de conversación activa...');
this.loadMessages(this.currentUserId, false, true);
console.log('📨 SSE new_message recibido:', e.data);
try {
const data = JSON.parse(e.data);
console.log('📨 Datos parseados del mensaje:', data);
// Extraer user_id del mensaje
const userId = data.user_id || data.from_user_id || data.sender_id;
console.log('👤 User ID del mensaje:', userId, '| Conversación actual:', this.currentUserId);
// Si la conversación del mensaje es la actualmente abierta
if (this.currentUserId && String(this.currentUserId) === String(userId)) {
console.log('♻️ Mensaje para conversación ACTIVA, procesando...');
// Verificar si el usuario está al final del chat (dentro de 150px del final)
const container = document.getElementById('chat-conversations');
const isAtBottom = container ?
(container.scrollHeight - container.scrollTop - container.clientHeight) < 150 : true;
console.log('📍 Posición scroll:', {
scrollHeight: container?.scrollHeight,
scrollTop: container?.scrollTop,
clientHeight: container?.clientHeight,
distanceFromBottom: container ? (container.scrollHeight - container.scrollTop - container.clientHeight) : 0,
isAtBottom: isAtBottom
});
if (isAtBottom) {
// Usuario está al final: agregar mensaje automáticamente con highlight
console.log('✅ Usuario AL FINAL, recargando mensajes...');
// Marcar que el próximo mensaje nuevo debe tener highlight
this._nextMessageUnread = true;
// Recargar mensajes (esto hará el fetch y renderizará)
this.loadMessages(this.currentUserId, true, true).then(() => {
// Después de cargar, quitar el highlight después de 3 segundos
setTimeout(() => {
this._removeUnreadHighlights();
}, 3000);
});
} else {
// Usuario está leyendo arriba: mostrar indicador de nuevos mensajes
console.log('⬆️ Usuario LEYENDO ARRIBA, mostrando indicador');
this.showNewMessagesIndicator(1);
// Guardar mensaje en staging para agregarlo cuando el usuario baje
if (!this._stagedMessages) this._stagedMessages = [];
this._stagedMessages.push(data);
console.log('💾 Mensaje guardado en staging. Total staged:', this._stagedMessages.length);
}
} else {
console.log('📋 Mensaje para OTRA conversación, solo actualizando lista');
}
// Actualizar la lista de conversaciones para mostrar el nuevo mensaje
console.log('📋 Actualizando lista de conversaciones...');
this.updateConversationInList(data);
// Reproducir sonido de notificación solo si no es la conversación activa
if (!this.currentUserId || String(this.currentUserId) !== String(userId)) {
console.log('🔔 Reproduciendo sonido de notificación');
this.playNotificationSound();
}
} catch (err) {
console.error('❌ Error procesando new_message:', err);
}
// Actualizar la lista de conversaciones para mostrar el nuevo mensaje
this.updateConversationInList(data);
// Reproducir sonido de notificación
this.playNotificationSound();
});
// Evento: nueva conversación detectada
@@ -1136,6 +1271,26 @@
console.log('🔔 Nueva notificación (SSE):', e.data);
try {
const notification = JSON.parse(e.data);
// Deduplicación: evitar procesar la misma notificación múltiples veces
const notificationKey = `notif_${notification.id}_${notification.created_at}`;
if (this._processedNotifications.has(notificationKey)) {
console.debug('⏭️ Notificación ya procesada, omitiendo:', notification.id);
return;
}
this._processedNotifications.add(notificationKey);
// Actualizar la lista de conversaciones con los datos de la notificación
if (notification.user_id && notification.message) {
console.log('📋 Actualizando conversación desde notificación...');
this.updateConversationInList({
user_id: notification.user_id,
message: notification.message,
timestamp: notification.created_at
});
}
// Mostrar toast de notificación
this.showNotificationToast(notification);
} catch (err) {
console.error('Error procesando notificación SSE:', err);
@@ -1225,40 +1380,84 @@
*/
updateConversationInList(data) {
try {
console.log('📝 Actualizando conversación en lista:', data);
// Asegurar que conversations esté inicializado
if (!this.conversations || !Array.isArray(this.conversations)) {
this.conversations = [];
console.warn('⚠️ conversations array vacío, recargando lista completa...');
this.loadConversations();
return;
}
// Extraer user_id de diferentes formatos posibles
const userId = data.user_id || data.from_user_id || data.sender_id;
if (!userId) {
console.warn('⚠️ No se pudo obtener user_id de los datos:', data);
return;
}
// Si no viene el mensaje, recargar la lista completa para obtener datos actualizados
if (!data.message && !data.content && !data.text && !data.last_message) {
console.log('⚠️ Evento SSE sin contenido de mensaje, recargando lista completa...');
// Forzar recarga temporal (sin await, ejecutar en background)
const wasLoaded = this._conversationsLoaded;
this._conversationsLoaded = false;
this.loadConversations().then(() => {
this._conversationsLoaded = wasLoaded;
});
return;
}
// Extraer mensaje de diferentes formatos
const message = data.message || data.content || data.text || data.last_message || 'Nuevo mensaje';
const timestamp = data.timestamp || data.created_at || new Date().toISOString();
// Buscar la conversación en el array
const existingIndex = this.conversations.findIndex(c => String(c.user_id) === String(data.user_id));
const existingIndex = this.conversations.findIndex(c => String(c.user_id) === String(userId));
if (existingIndex !== -1) {
console.log('✅ Conversación encontrada, actualizando...');
// Actualizar conversación existente
this.conversations[existingIndex].last_message = data.message;
this.conversations[existingIndex].last_message_time = data.timestamp;
this.conversations[existingIndex].unread_count = (this.conversations[existingIndex].unread_count || 0) + 1;
const conv = this.conversations[existingIndex];
conv.last_message = message;
conv.last_time = timestamp;
// Solo incrementar unread_count si no es la conversación activa
if (String(this.currentUserId) !== String(userId)) {
conv.unread_count = (conv.unread_count || 0) + 1;
}
// Mover al inicio de la lista
const conv = this.conversations.splice(existingIndex, 1)[0];
this.conversations.splice(existingIndex, 1);
this.conversations.unshift(conv);
} else {
console.log(' Conversación no existe, agregando nueva...');
// Nueva conversación, agregar al inicio
this.conversations.unshift({
user_id: data.user_id,
name: data.name,
phone_number: data.phone_number,
last_message: data.message,
last_message_time: data.timestamp,
unread_count: 1
user_id: userId,
name: data.name || data.sender_name || data.phone_number || `Usuario ${userId}`,
phone_number: data.phone_number || data.phone || '',
last_message: message,
last_time: timestamp,
unread_count: String(this.currentUserId) !== String(userId) ? 1 : 0
});
}
// Re-renderizar solo la lista de conversaciones
this.renderConversations();
console.log('🔄 Re-renderizando lista de conversaciones...');
console.log('🔍 Verificando this:', this);
console.log('🔍 this.renderConversations existe?', typeof this.renderConversations);
console.log('🔍 this.conversations:', this.conversations);
if (typeof this.renderConversations === 'function') {
console.log('✅ Llamando a renderConversations()...');
this.renderConversations();
} else {
console.error('❌ renderConversations no es una función!');
}
} catch (error) {
console.error('Error actualizando conversación en lista:', error);
console.error('Error actualizando conversación en lista:', error);
}
}
@@ -1267,105 +1466,62 @@
*/
addConversationToList(data) {
try {
console.log(' Agregando nueva conversación:', data);
// Asegurar que conversations esté inicializado
if (!this.conversations || !Array.isArray(this.conversations)) {
console.warn('⚠️ conversations array no estaba inicializado, inicializando ahora...');
this.conversations = [];
console.warn('⚠️ conversations array no estaba inicializado, recargando...');
this.loadConversations();
return;
}
// Extraer user_id
const userId = data.user_id || data.from_user_id || data.sender_id;
if (!userId) {
console.warn('⚠️ No se pudo obtener user_id');
return;
}
// Verificar si ya existe
const exists = this.conversations.some(c => String(c.user_id) === String(data.user_id));
const exists = this.conversations.some(c => String(c.user_id) === String(userId));
if (exists) {
console.log('🔄 Conversación ya existe, actualizando...');
return this.updateConversationInList(data);
}
// Extraer datos
const message = data.message || data.content || data.text || 'Nueva conversación';
const timestamp = data.timestamp || data.created_at || new Date().toISOString();
const name = data.name || data.sender_name || data.phone_number || `Usuario ${userId}`;
// Agregar al inicio
this.conversations.unshift({
user_id: data.user_id,
name: data.name,
phone_number: data.phone_number,
last_message: 'Nueva conversación',
last_message_time: data.timestamp,
unread_count: data.message_count || 1
user_id: userId,
name: name,
phone_number: data.phone_number || data.phone || '',
last_message: message,
last_time: timestamp,
unread_count: String(this.currentUserId) !== String(userId) ? (data.message_count || 1) : 0
});
console.log('✅ Conversación agregada, re-renderizando...');
// Re-renderizar lista
this.renderConversations();
} catch (error) {
console.error('Error agregando conversación:', error);
console.error('Error agregando conversación:', error);
}
}
async loadNotifications() {
try {
const resp = await fetch('api/get_notifications.php', { credentials: 'same-origin' });
if (!resp.ok) {
// Si falla, no es problema: SSE manejará las notificaciones en tiempo real
console.debug(`Notifications fetch: HTTP ${resp.status} (SSE manejará las notificaciones)`);
return;
}
if (resp.status === 401) {
console.debug('Notifications fetch: unauthorized (SSE manejará las notificaciones)');
return;
}
const json = await resp.json();
console.debug('loadNotifications response:', json);
if (json && json.success && Array.isArray(json.data)) {
json.data.forEach(n => this.showNotificationToast(n));
} else if (json && json.success === false) {
console.debug('get_notifications returned error (no crítico, SSE activo):', json.error || json);
}
} catch (e) {
// Error no crítico: SSE manejará las notificaciones en tiempo real
console.debug('loadNotifications failed (SSE manejará las notificaciones):', e.message);
}
// 🎯 ELIMINADO: Las notificaciones ahora solo llegan por SSE en tiempo real
// No se hace fetch a get_notifications.php
console.debug('⚡ loadNotifications: SSE maneja todas las notificaciones en tiempo real');
return;
}
// Try to mark a notification as read on the server. Returns true on success.
async ackNotification(notification) {
if (!notification || !notification.id) return true; // nothing to ack on server
try {
const resp = await fetch('api/mark_notification_read.php', {
method: 'POST',
headers: {'Content-Type':'application/json'},
body: JSON.stringify({ id: notification.id })
});
if (!resp.ok) {
console.warn('ackNotification: server responded with', resp.status);
return false;
}
const j = await resp.json().catch(() => null);
return !!(j && j.success) || resp.ok;
} catch (e) {
console.warn('ackNotification failed', e);
return false;
}
}
setupNotificationAckRetry() {
if (this._ackInterval) return;
this._ackInterval = setInterval(async () => {
if (this._pendingAck.size === 0) return;
for (const [nid, notification] of Array.from(this._pendingAck.entries())) {
try {
const ok = await this.ackNotification(notification);
if (ok) {
this._pendingAck.delete(nid);
this._dismissedNotifications.add(nid);
console.debug('Ack retry succeeded for', nid);
} else {
console.debug('Ack retry still failing for', nid);
}
} catch (e) {
console.warn('Ack retry error for', nid, e);
}
}
}, 30000); // every 30s
}
// SIMPLIFICADO: Las notificaciones ahora son solo en tiempo real
// No se guardan en BD ni necesitan marcarse como leídas
async showNotificationToast(notification) {
// Create or reuse container
@@ -1390,20 +1546,8 @@
return;
}
// mark as shown immediately and try to ack on server so it doesn't reappear
// mark as shown immediately (solo en memoria, no en servidor)
this._shownNotifications.add(nid);
try {
const ok = await this.ackNotification(notification);
if (ok) {
this._dismissedNotifications.add(nid);
this._pendingAck.delete(nid);
} else {
// schedule for retry
this._pendingAck.set(nid, notification);
}
} catch (e) {
this._pendingAck.set(nid, notification);
}
const toast = document.createElement('div');
// compact by default
@@ -1443,16 +1587,8 @@
openBtn.className = notification.cool ? 'btn btn-sm btn-light' : (isAttention ? 'btn btn-sm btn-warning' : 'btn btn-sm btn-primary');
openBtn.textContent = (notification.open_label && notification.open_label !== 'Ver') ? notification.open_label : 'Abrir';
openBtn.onclick = async () => {
// Try ack on server; if fails, schedule retry. Always mark as dismissed locally so it won't reappear.
try {
const ok = await this.ackNotification(notification);
if (ok) {
this._dismissedNotifications.add(nid);
this._pendingAck.delete(nid);
} else {
this._pendingAck.set(nid, notification);
}
} catch (e) { this._pendingAck.set(nid, notification); }
// Marcar como descartada localmente
this._dismissedNotifications.add(nid);
removeToastLocal();
// navigate to conv or open media if present
let data = {};
@@ -1462,7 +1598,7 @@
if (fileUrl && this.currentUserId && String(this.currentUserId) === String(userId)) {
try { openMediaLightbox(fileUrl, data.file_name || ''); } catch (e) { console.warn('openMediaLightbox failed', e); }
} else if (userId) {
const conv = this.conversationsList.find(c => c.user_id == userId);
const conv = this.conversations.find(c => c.user_id == userId);
if (conv) {
// Prefetch messages then open conversation using preloaded data to avoid double-fetch
try {
@@ -1493,15 +1629,8 @@
dismiss.textContent = '×';
dismiss.title = 'Descartar';
dismiss.onclick = async () => {
try {
const ok = await this.ackNotification(notification);
if (ok) {
this._dismissedNotifications.add(nid);
this._pendingAck.delete(nid);
} else {
this._pendingAck.set(nid, notification);
}
} catch(e) { this._pendingAck.set(nid, notification); }
// Marcar como descartada localmente
this._dismissedNotifications.add(nid);
removeToastLocal();
};
@@ -1527,19 +1656,9 @@
if (!isFinite(timeout) || timeout <= 0) timeout = (notification.cool ? 1000 : 1000);
timeout = Math.max(timeout, _minToastDuration);
setTimeout(() => {
// on automatic timeout, attempt ack and schedule retry if needed
(async () => {
try {
const ok = await this.ackNotification(notification);
if (ok) {
this._dismissedNotifications.add(nid);
this._pendingAck.delete(nid);
} else {
this._pendingAck.set(nid, notification);
}
} catch (e) { this._pendingAck.set(nid, notification); }
removeToastLocal();
})();
// Auto-descartar: solo limpiar localmente
this._dismissedNotifications.add(nid);
removeToastLocal();
}, timeout);
}
@@ -2269,6 +2388,20 @@
const text = await response.text();
if (!response.ok) {
// Si es 401, intentar parsear el JSON para obtener el mensaje real
if (response.status === 401) {
try {
const errorData = JSON.parse(text);
if (errorData.error) {
alert('Sesión expirada: ' + errorData.error + '. Recargando página...');
}
} catch (e) {
alert('Sesión expirada. Recargando página...');
}
// Forzar recarga para restablecer sesión
setTimeout(() => window.location.reload(), 1000);
return null;
}
// Incluir el cuerpo de la respuesta (truncado) para diagnóstico
const snippet = text && text.length ? (text.length > 2000 ? text.substr(0, 2000) + '... (truncated)' : text) : '<no body>';
console.error('apiCall HTTP error', response.status, url, snippet);
@@ -2287,6 +2420,13 @@
}
async loadConversations(page = 1, append = false) {
// 🎯 OPTIMIZACIÓN: Solo cargar del servidor si es la primera vez O si es paginación
// Después SSE se encarga de actualizar automáticamente
if (this._conversationsLoaded && !append) {
console.log('⚡ Conversaciones ya cargadas, SSE se encarga de actualizaciones');
return;
}
if (this.loadingConversations) return;
this.loadingConversations = true;
const loadMoreContainer = document.getElementById('load-more-container');
@@ -2320,13 +2460,20 @@
}
if (append) {
this.conversationsList = this.conversationsList.concat(items);
this.conversations = this.conversations.concat(items);
} else {
this.conversationsList = items;
this.conversations = items;
}
this.hasMoreConversations = hasMore;
this.conversationsPage = page;
// Marcar como cargadas después de la primera carga exitosa
if (!append) {
this._conversationsLoaded = true;
console.log('✅ Primera carga de conversaciones completada, SSE tomará el control');
}
this.renderConversations();
if (loadMoreContainer) {
@@ -2349,9 +2496,17 @@
renderConversations() {
console.log('🎨 Renderizando conversaciones:', this.conversations.length);
const container = document.getElementById('conversation-list');
if (this.conversationsList.length === 0) {
if (!container) {
console.error('❌ No se encontró el elemento #conversation-list en el DOM');
return;
}
console.log('✅ Container encontrado:', container);
if (this.conversations.length === 0) {
const emptyMsg = this.conversationFilter === 'unread' ? 'No hay conversaciones no leídas' : 'No hay conversaciones aún';
container.innerHTML = `
<div class="text-center p-4">
@@ -2359,11 +2514,14 @@
<p class="text-muted">${emptyMsg}</p>
</div>
`;
console.log('📭 Lista vacía, mostrando mensaje');
return;
}
console.log('📋 Generando HTML para', this.conversations.length, 'conversaciones');
// Ahora el backend devuelve por usuario: last_message, last_time, unread_count y avatar_url
const html = this.conversationsList.map(conv => {
const html = this.conversations.map(conv => {
const isActive = conv.user_id == this.currentUserId ? 'active' : '';
const time = this.formatTime(conv.last_time);
const preview = this.truncateText(conv.last_message || 'Sin mensajes', 50);
@@ -2394,10 +2552,12 @@
</div>
`;
}).join('');
console.log('📄 HTML generado, longitud:', html.length, 'caracteres');
// Detectar nuevas notificaciones: comparar unread counts previos
if (!this._prevConversations) this._prevConversations = {};
this.conversationsList.forEach(c => {
this.conversations.forEach(c => {
const prev = this._prevConversations[c.user_id] || { unread_count: 0 };
if (c.unread_count > prev.unread_count && c.user_id != this.currentUserId) {
// nueva notificación
@@ -2411,8 +2571,11 @@
const prevScrollTop = container.scrollTop;
const prevScrollHeight = container.scrollHeight;
const wasNearTop = prevScrollTop < 60;
console.log('🔄 Actualizando innerHTML del container...');
container.innerHTML = html;
console.log('✅ DOM actualizado con', this.conversations.length, 'conversaciones');
console.log('📊 Elementos en DOM:', container.children.length);
const newScrollHeight = container.scrollHeight;
const scrollDelta = newScrollHeight - prevScrollHeight;
@@ -2498,7 +2661,7 @@
}
getConversationPhone(userId) {
const conv = this.conversationsList.find(c => c.user_id === userId);
const conv = this.conversations.find(c => c.user_id === userId);
if (conv) return conv.phone_number || conv.phone || conv.user_phone || null;
const el = document.getElementById('chat-phone');
return el ? el.textContent.trim() : null;
@@ -2544,7 +2707,7 @@
document.querySelector(`[data-user-id="${userId}"]`)?.classList.add('active');
// Actualizar estado del toggle del bot según datos de la conversación
const conv = this.conversationsList.find(c => c.user_id === userId);
const conv = this.conversations.find(c => c.user_id === userId);
if (conv) {
const btn = document.getElementById('bot-toggle');
const holdIndicator = document.getElementById('hold-indicator');
@@ -2572,8 +2735,8 @@
}
const json = await resp.json();
if (json && json.success) {
// Recargar lista de conversaciones para reflejar cambios
await this.loadConversations();
// SSE actualizará las conversaciones automáticamente
// await this.loadConversations();
alert('Conversación marcada como NO leída.');
} else {
alert('Error marcando conversación como no leída');
@@ -2661,7 +2824,7 @@
attendStatus.textContent = '';
// refresh conv reference
const c = this.conversationsList.find(x => x.user_id === userId) || conv;
const c = this.conversations.find(x => x.user_id === userId) || conv;
// Update only the in-chat status message based on server/local state
this.getUserState(userId).then(serverState => {
@@ -2682,7 +2845,7 @@
const toggleAttend = async () => {
try {
const c = this.conversationsList.find(x => x.user_id === userId) || conv;
const c = this.conversations.find(x => x.user_id === userId) || conv;
// Preflight: obtener estado canonico del servidor con cache corta
const serverState = await this.getUserState(userId, true);
@@ -3521,6 +3684,12 @@
div.className = 'message ' + (msg.direction || 'incoming');
div.dataset.messageId = mid;
div.dataset.createdAt = msg.created_at || '';
// Marcar mensajes nuevos como no leídos si es el último mensaje y viene de SSE
if (this._nextMessageUnread && i === messagesToRender.length - 1 && msg.direction === 'incoming') {
div.classList.add('unread');
div.dataset.isNewUnread = 'true';
}
let replyHtml = '';
if (msg.reply_to_message_id) {
@@ -3988,6 +4157,21 @@
els.forEach(el => { try { if (el && el.parentNode) el.parentNode.removeChild(el); } catch(e){} });
} catch (e) { /* ignore */ }
}
_removeUnreadHighlights() {
try {
console.log('🎨 Quitando highlights de mensajes no leídos');
const unreadMessages = document.querySelectorAll('.message.unread');
unreadMessages.forEach(msg => {
msg.classList.remove('unread');
delete msg.dataset.isNewUnread;
});
// Reset flag
this._nextMessageUnread = false;
} catch (e) {
console.warn('Error quitando highlights:', e);
}
}
applyStagedMessages() {
try {
@@ -4159,7 +4343,7 @@
// If the template expects a named parameter `name`, provide it using user name or phone
if (templateName === 'contacto_nuevo') {
// Try to find conversation info
let conv = this.conversationsList.find(c => c.user_id === this.currentUserId) || {};
let conv = this.conversations.find(c => c.user_id === this.currentUserId) || {};
let resolvedName = (conv.name || conv.full_name || '').trim();
if (!resolvedName) {
const nameEl = document.getElementById('chat-name-text') || document.getElementById('chat-name');
@@ -4199,7 +4383,8 @@
this.addMessageToView(`Plantilla: ${templateName}`, 'outgoing', { reply_to_message_id: replyToTemplate });
typeof showAlert !== 'undefined' && showAlert('Mensaje de plantilla enviado correctamente', 'success');
await this.loadconversations(this.currentUserId, false);
await this.loadConversations();
// SSE actualizará las conversaciones automáticamente
// await this.loadConversations();
} else {
throw new Error(resp && resp.error ? resp.error : 'Error enviando plantilla');
}
@@ -4369,8 +4554,9 @@
typeof showAlert !== 'undefined' && showAlert('Mensaje enviado correctamente', 'success');
// Recargar mensajes y conversaciones en background
this.loadconversations(this.currentUserId, false).catch(e=>console.warn(e));
this.loadConversations().catch(e=>console.warn(e));
this.loadQuickReplies().catch(e=>console.warn(e));
// SSE actualizará las conversaciones automáticamente
// this.loadConversations().catch(e=>console.warn(e));
// loadQuickReplies ya se cargó al abrir la conversación, no es necesario recargar después de cada mensaje
} else {
throw new Error(result && result.error ? result.error : 'Error desconocido');
}
@@ -4598,15 +4784,17 @@
const sendContentType = sendResponse.headers.get('content-type') || '';
let sendResult = null;
// Leer el body una sola vez para evitar error "body stream already read"
const sendResponseText = await sendResponse.text();
if (sendContentType.indexOf('application/json') !== -1) {
try {
sendResult = await sendResponse.json();
sendResult = JSON.parse(sendResponseText);
} catch (err) {
// Content-Type claims JSON but parsing failed: attempt to extract JSON from body
const text = await sendResponse.text();
console.warn('send_media_message: Content-Type JSON but parse failed. Response body will be inspected for JSON.');
console.warn(text);
const m = text.match(/(\{[\s\S]*\})/);
console.warn(sendResponseText);
const m = sendResponseText.match(/(\{[\s\S]*\})/);
if (m) {
try { sendResult = JSON.parse(m[1]); console.warn('send_media_message: extracted JSON from response.'); } catch (e) { console.warn('send_media_message: extracted JSON parse failed', e); }
}
@@ -4622,10 +4810,9 @@
}
} else {
// Non-JSON content-type: try to find embedded JSON; if HTTP 200, be forgiving
const text = await sendResponse.text();
console.warn('send_media_message returned non-JSON response:');
console.warn(text.slice(0, 2000));
const m = text.match(/(\{[\s\S]*\})/);
console.warn(sendResponseText.slice(0, 2000));
const m = sendResponseText.match(/(\{[\s\S]*\})/);
if (m) {
try { sendResult = JSON.parse(m[1]); console.warn('send_media_message: extracted JSON from non-JSON response.'); } catch (e) { console.warn('send_media_message: failed to parse extracted JSON', e); }
}
@@ -4649,7 +4836,8 @@
// Éxito
this.cancelMediaUpload();
await this.loadconversations(this.currentUserId, false);
this.loadConversations();
// SSE actualizará las conversaciones automáticamente
// this.loadConversations();
} catch (error) {
console.error('Error sending media:', error);