Files
whatsapp/conversations.php
T
2026-01-28 03:09:52 -05:00

5255 lines
282 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>💬 Conversaciones - WhatsApp Bot</title>
<link href="https://cdnjs.cloudflare.com/ajax/libs/bootstrap/5.3.0/css/bootstrap.min.css" rel="stylesheet">
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" rel="stylesheet">
<style>
:root {
--whatsapp-green: #25d366;
--whatsapp-green-dark: #128c7e;
--whatsapp-light: #dcf8c6;
--whatsapp-dark: #075e54;
--message-bg: #f0f0f0;
--sidebar-bg: #f8f9fa;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Arial, sans-serif;
background: linear-gradient(135deg, #f0f4f7 0%, #e5ddd5 100%);
margin: 0;
min-height: 100vh;
overflow: hidden;
color: #222;
font-size: 14px;
}
.chat-container {
display: flex;
height: 100vh;
background: transparent;
gap: 12px;
padding: 12px;
}
.chat-sidebar {
width: 360px;
background: var(--sidebar-bg);
border-radius: 8px;
border: 1px solid #e9eef2;
display: flex;
flex-direction: column;
box-shadow: 0 4px 14px rgba(16,24,40,0.04);
overflow: hidden;
}
.sidebar-header {
padding: 14px 16px;
background: linear-gradient(180deg, var(--whatsapp-dark) 0%, #054f45 100%);
color: white;
display: flex;
align-items: center;
justify-content: space-between;
}
.sidebar-search {
padding: 10px 12px;
background: white;
border-bottom: 1px solid #eef2f5;
}
.conversation-list {
flex: 1;
overflow-y: auto;
padding: 6px;
}
.conversation-item {
padding: 12px 14px;
border-bottom: 1px solid #f1f5f8;
cursor: pointer;
transition: background-color 0.12s, transform 0.08s;
display: flex;
align-items: center;
}
.conversation-item:hover {
background: #f6fbff;
transform: translateY(-1px);
}
.conversation-item.active {
background: #e9fbf1;
border-right: 3px solid var(--whatsapp-green);
}
.conversation-avatar {
width: 46px;
height: 46px;
border-radius: 50%;
background: var(--whatsapp-green);
display: flex;
align-items: center;
justify-content: center;
color: white;
font-weight: 700;
margin-right: 12px;
flex-shrink: 0;
}
.conversation-info {
flex: 1;
min-width: 0;
}
.conversation-name {
font-weight: 700;
font-size: 15px;
color: #262626;
margin-bottom: 4px;
}
.conversation-preview {
color: #6b7280;
font-size: 13px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.conversation-time {
color: #9aa4af;
font-size: 12px;
text-align: right;
flex-shrink: 0;
margin-left: 10px;
}
.chat-main {
flex: 1;
display: flex;
flex-direction: column;
background: transparent;
}
.chat-header {
padding: 12px 18px;
background: linear-gradient(180deg, var(--whatsapp-dark) 0%, #0a5347 100%);
color: white;
border-radius: 8px;
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
display: flex;
align-items: center;
gap: 12px;
box-shadow: 0 2px 6px rgba(15,23,42,0.06);
}
.chat-header-avatar {
width: 44px;
height: 44px;
border-radius: 50%;
background: var(--whatsapp-green);
display: flex;
align-items: center;
justify-content: center;
color: white;
font-weight: 700;
margin-right: 12px;
}
.chat-header-info h6 { margin: 0; font-size: 15px; }
.chat-header-info small { color: rgba(255,255,255,0.85); }
.chat-conversations {
flex: 1;
overflow-y: auto;
padding: 12px 16px;
background: #f8f9fb;
border: 1px solid #eef2f5;
border-radius: 0 0 8px 8px;
}
.message {
width: 100%;
margin-bottom: 8px;
display: flex;
align-items: flex-end;
}
.message.outgoing {
justify-content: flex-end;
}
.message.incoming {
justify-content: flex-start;
}
.message-bubble {
padding: 8px 12px;
border-radius: 18px;
position: relative;
word-wrap: break-word;
box-shadow: 0 3px 10px rgba(2,6,23,0.06);
max-width: 72%;
line-height: 1.35;
transition: transform 0.08s ease, box-shadow 0.12s ease;
}
.message.outgoing .message-bubble {
background: var(--whatsapp-light);
color: #222;
border-bottom-right-radius: 6px;
}
.message.incoming .message-bubble {
background: white;
color: #222;
border-bottom-left-radius: 6px;
}
/* Cola tipo WhatsApp */
.message.outgoing .message-bubble::after {
content: '';
position: absolute;
right: -6px;
bottom: 6px;
width: 12px;
height: 12px;
background: var(--whatsapp-light);
transform: rotate(45deg);
z-index: 0;
box-shadow: 0 1px 2px rgba(0,0,0,0.04);
}
.message.incoming .message-bubble::after {
content: '';
position: absolute;
left: -6px;
bottom: 6px;
width: 12px;
height: 12px;
background: white;
transform: rotate(45deg);
z-index: 0;
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 {
position: absolute;
right: 8px;
bottom: 6px;
font-size: 11px;
line-height: 1;
color: #666;
opacity: 1; /* always visible */
transition: opacity 0.12s ease-in-out, transform 0.12s ease;
white-space: nowrap;
font-weight: 500;
background: transparent;
padding: 0 4px;
}
/* Different color for outgoing (light text over green bubble) */
.message.outgoing .message-bubble .message-time { color: rgba(255,255,255,0.92); }
.message.incoming .message-bubble .message-time { color: rgba(32,37,41,0.7); }
/* reduce prominence on very small screens */
@media (max-width:420px) {
.message-bubble .message-time { font-size:10px; right:6px; bottom:4px; }
}
/* Preserve user whitespace and line breaks like WhatsApp */
.message-content {
white-space: pre-wrap; /* preserve newlines and wrap long lines */
white-space: -moz-pre-wrap;
white-space: -pre-wrap;
white-space: -o-pre-wrap;
word-break: break-word;
}
/* WhatsApp-like inline formatting and interactive messages */
.wa-interactive { margin-top: 8px; border: 1px solid rgba(0,0,0,0.06); padding: 8px; border-radius: 8px; background: #fff; }
.wa-interactive .wa-title { font-weight:700; margin-bottom:6px; }
.wa-interactive .wa-buttons { display:flex; gap:6px; flex-wrap:wrap; }
.wa-interactive .wa-buttons .wa-interactive-btn { white-space:nowrap; }
.wa-interactive .wa-list { margin-top:6px; }
.wa-interactive .wa-list-item { padding:8px; border-radius:6px; cursor:pointer; border:1px solid transparent; }
.wa-interactive .wa-list-item:hover { background:#f6f8fb; border-color:rgba(0,0,0,0.04); }
.wa-code { background:#0b0b0b; color:#fff; padding:8px; border-radius:6px; font-family: monospace; white-space: pre-wrap; }
.wa-inline-code { background:#f4f4f4; padding:2px 6px; border-radius:4px; font-family: monospace; }
.message-bubble:hover {
transform: translateY(-2px);
box-shadow: 0 8px 20px rgba(2,6,23,0.06);
}
.message-bubble:hover .message-time,
.message-bubble:focus-within .message-time {
opacity: 1;
}
.message-status {
display: inline-block;
margin-left: 5px;
font-size: 12px;
}
.status-sent { color: #999; }
.status-delivered { color: #999; }
.status-read { color: var(--whatsapp-green); }
.chat-input {
padding: 15px 20px;
background: #f0f0f0;
border-top: 1px solid #ddd;
display: flex;
align-items: center;
gap: 10px;
}
.chat-input input[type="text"] {
flex: 1;
padding: 10px 15px;
border: 1px solid #ddd;
border-radius: 25px;
outline: none;
font-size: 14px;
}
.chat-input input:focus {
border-color: var(--whatsapp-green);
}
.chat-input .btn {
border-radius: 50%;
width: 45px;
height: 45px;
display: flex;
align-items: center;
justify-content: center;
background: var(--whatsapp-green);
border: none;
color: white;
}
.chat-input .btn:hover {
background: var(--whatsapp-green-dark);
}
#attach-btn {
background: #075E54;
}
#attach-btn:hover {
background: #128C7E;
}
/* Estilos para mensajes multimedia */
.message-media {
max-width: 300px;
border-radius: 8px;
overflow: hidden;
margin-bottom: 5px;
}
.message-media img,
.message-media video {
width: 100%;
display: block;
cursor: pointer;
}
.message-media audio {
width: 100%;
}
.message-document {
background: rgba(0,0,0,0.05);
padding: 10px;
border-radius: 8px;
display: flex;
align-items: center;
gap: 10px;
cursor: pointer;
}
.message-document:hover {
background: rgba(0,0,0,0.1);
}
.message-document i {
font-size: 24px;
color: var(--whatsapp-green);
}
.document-info {
flex: 1;
}
.document-name {
font-weight: 500;
font-size: 14px;
}
.document-size {
font-size: 12px;
color: #666;
}
#media-preview {
animation: slideUp 0.3s ease;
}
@keyframes slideUp {
from {
transform: translateY(20px);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
.upload-progress {
position: absolute;
top: 0;
left: 0;
right: 0;
height: 3px;
background: var(--whatsapp-green);
transform-origin: left;
transition: transform 0.3s;
}
.no-conversation {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
color: #999;
text-align: center;
}
.no-conversation i {
font-size: 64px;
margin-bottom: 20px;
opacity: 0.3;
}
.loading {
text-align: center;
padding: 20px;
color: #666;
}
/* Notification toasts (compact by default) */
#notification-toasts {
position: fixed;
top: 12px;
right: 12px;
z-index: 10000;
display: flex;
flex-direction: column;
gap: 8px;
align-items: flex-end;
pointer-events: none;
}
.notification-toast {
pointer-events: auto;
background: #fff;
color: #222;
padding: 8px 10px;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0,0,0,0.12);
display: flex;
align-items: center;
gap: 10px;
min-width: 160px;
max-width: 260px;
font-size: 13px;
}
.notification-toast.small {
padding: 6px 8px;
font-size: 12px;
min-width: 140px;
max-width: 220px;
}
.notification-toast.cool {
background: linear-gradient(90deg,#6a11cb,#2575fc);
color: white;
box-shadow: 0 6px 16px rgba(37,117,252,0.28);
transform-origin: right;
animation: pop 320ms ease;
}
@keyframes pop { from { transform: translateY(-6px) scale(0.98); opacity:0 } to { transform: translateY(0) scale(1); opacity:1 } }
.notification-toast .nt-icon { font-size: 16px; margin-right: 6px; opacity: 0.95; }
.notification-toast .nt-actions { margin-left: auto; display:flex; gap:6px; }
.notification-toast .btn { font-size: 12px; padding: 4px 8px; }
/* UI improvements for chat area */
.quick-replies .btn {
border-radius: 20px;
padding: 6px 10px;
font-size: 12px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* Quick replies compact panel */
.quick-replies-wrapper { position: relative; }
/* Improve audio control visibility and color in supporting browsers */
.message-media audio { background: #fff; border-radius: 8px; padding: 4px; accent-color: var(--whatsapp-green); }
/* Mic button styles */
#mic-btn {
margin-left: 6px;
background: var(--whatsapp-green);
border: none;
color: white;
width: 40px;
height: 40px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 50%;
box-shadow: 0 2px 6px rgba(3, 102, 80, 0.12);
}
#mic-btn.recording { background: #c0392b; color: white; }
#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: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; }
/* Show mic in place of send when input is empty */
#send-btn { display: inline-block; }
#mic-btn { display: none; }
.chat-conversations {
background-repeat: repeat;
background-color: #e9efe9;
padding-bottom: 20px; /* space for reply preview and input */
}
.message-bubble { transition: transform 0.12s ease, box-shadow 0.12s ease; }
.message-bubble:hover { transform: translateY(-1px); box-shadow: 0 4px 10px rgba(0,0,0,0.06); }
#reply-preview { box-shadow: 0 1px 2px rgba(0,0,0,0.04); }
#reply-preview button { border: none; color: #888; }
#reply-preview button:hover { color: #333; }
.notification-toast.urgent { border-left: 4px solid #e74c3c; }
/* Visual destacado para notificaciones de tipo "attention" (ej. usuario subió documentos) */
.notification-toast.attention {
background: linear-gradient(90deg, #fff7e6, #fff3e0);
border: 1px solid rgba(255,165,0,0.18);
color: #333;
box-shadow: 0 8px 24px rgba(255,165,0,0.06);
min-width: 240px;
}
.notification-toast.attention .nt-icon {
font-size: 18px;
margin-right: 8px;
transform: translateY(-1px);
color: #ff9900;
}
.notification-toast.attention .nt-actions .btn { min-width: 70px; }
.message-template {
background: #fffacd;
border: 1px solid #f0e68c;
padding: 10px;
border-radius: 8px;
margin-bottom: 10px;
font-size: 13px;
}
.message-template.system {
background: linear-gradient(90deg,#fff4e6,#fffaf0);
border: 1px solid rgba(255,165,0,0.18);
color: #5c3a00;
font-weight: 600;
display: flex;
gap: 8px;
align-items: center;
animation: systemPop 320ms ease;
}
.message-template.system .mt-icon { font-size:16px; margin-right:6px; }
@keyframes systemPop { from { transform: translateY(6px); opacity:0 } to { transform: translateY(0); opacity:1 } }
/* Highlight animation for newly inserted system messages */
.message-template.system.system-highlight {
box-shadow: 0 10px 30px rgba(255,165,0,0.12);
border-color: rgba(255,165,0,0.28);
transform-origin: center left;
animation: pulseHighlight 1s ease both;
}
/* Date separator between message days */
.date-sep {
text-align: center;
font-size: 12px;
color: #666;
padding: 6px 0;
margin: 12px 0;
position: relative;
}
.date-sep::before, .date-sep::after { content: ''; display: inline-block; vertical-align: middle; width: 20%; height: 1px; background: rgba(0,0,0,0.06); margin: 0 8px; }
.date-sep { background: transparent; font-weight:600; }
@keyframes pulseHighlight {
0% { transform: translateY(6px) scale(.995); opacity: 0.95; }
50% { transform: translateY(0) scale(1.01); opacity: 1; }
100% { transform: translateY(0) scale(1); opacity: 1; }
}
.unread-count {
background: var(--whatsapp-green);
color: white;
border-radius: 50%;
font-size: 12px;
min-width: 20px;
height: 20px;
display: flex;
align-items: center;
justify-content: center;
margin-left: auto;
}
/* Media thumbnails and players */
.message-media img, .message-media .message-media-image { max-width: 320px; border-radius: 8px; display: block; height: auto; }
@media (max-width: 768px) {
.message-media img, .message-media .message-media-image { max-width: 60vw; }
}
.message-media video, .message-media audio { max-width: 100%; border-radius: 8px; }
.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);
display: none;
position: absolute;
z-index: 1400;
max-height: 180px;
overflow-y: auto;
}
.quick-replies-panel .btn {
border-radius: 6px;
display: block;
text-align: left;
width: 100%;
padding: 8px 10px;
margin-bottom: 6px;
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 */
#recording-indicator .btn {
border-radius: 6px;
padding: 6px 10px;
min-width: 84px;
}
/* Audio control visibility: stronger border and accent color */
.message-media audio {
background: #fff;
border: 1px solid rgba(0,0,0,0.06);
padding: 6px;
border-radius: 8px;
accent-color: var(--whatsapp-green);
box-shadow: 0 1px 3px rgba(0,0,0,0.04);
height: 40px;
}
@media (max-width: 768px) {
/* Fullscreen sidebar that can slide in/out */
.chat-container { padding: 0; gap: 0; }
.chat-sidebar {
width: 100%;
position: fixed;
top: 0;
left: 0;
z-index: 1100;
height: 100vh;
transform: translateX(0);
transition: transform 240ms ease;
box-shadow: none;
}
/* Hidden by default on mobile. .open will show it */
.chat-sidebar.mobile-hidden { transform: translateX(-110%); }
.chat-main {
width: 100%;
margin-left: 0;
display: flex;
flex-direction: column;
height: 100vh;
}
/* Header adjustment */
.chat-header { padding: 10px 12px; gap: 8px; }
.chat-header-avatar { width: 38px; height: 38px; }
/* Fix input to bottom to emulate mobile chat apps */
.chat-input {
position: fixed;
left: 0;
right: 0;
bottom: 0;
padding: 10px calc(12px + env(safe-area-inset-left, 0px)) calc(12px + env(safe-area-inset-bottom, 0px));
background: #fff;
z-index: 1400;
border-top: 1px solid #e9eef2;
box-shadow: 0 -6px 20px rgba(0,0,0,0.06);
}
/* improve touch targets inside input bar */
.chat-input input[type="text"] { font-size: 16px; padding: 12px 14px; }
.chat-input .btn { width:48px; height:48px; }
/* Give chat area bottom padding so messages are not hidden under input */
.chat-conversations { padding-bottom: 120px; }
/* Compact avatars and message bubble width */
.conversation-avatar { width: 40px; height: 40px; }
.message-bubble { max-width: 85%; font-size: 15px; }
/* Quick replies panel adapt */
.quick-replies-panel { left: 8px; right: 8px; bottom: 70px; max-height: 160px; }
/* Show a small back button in header to open sidebar */
#sidebar-toggle { display: inline-flex; }
}
.reply-preview {
background: #f4f6f8;
border-left: 3px solid #d0d7de;
padding: 6px 8px;
margin-bottom: 6px;
font-size: 12px;
color: #555;
border-radius: 4px;
}
.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: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); }
.conversation-avatar img, .conversation-avatar-img { max-width: 44px; max-height:44px; border-radius:50%; }
.conversation-avatar-wrapper { width:48px; display:flex; align-items:center; justify-content:center }
.conversation-item.unread { background: rgba(255, 248, 220, 0.9); }
.unread-badge { margin-left: 8px; font-size: 12px; }
</style>
<link rel="manifest" href="/manifest.json">
<meta name="theme-color" content="#10b981">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-capable" content="yes">
</head>
<body>
<div class="chat-container">
<!-- Sidebar con lista de conversaciones -->
<div class="chat-sidebar">
<div class="sidebar-header">
<div>
<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">
<i class="fas fa-arrow-left"></i>
</a>
</div>
</div>
<div class="sidebar-search">
<div class="input-group">
<span class="input-group-text bg-transparent border-0">
<i class="fas fa-search text-muted"></i>
</span>
<input type="text" class="form-control border-0" placeholder="Buscar conversaciones..." id="search-input">
</div>
</div>
<!-- Filtro de conversaciones: Todos / No leídos -->
<div style="padding:8px 12px; display:flex; gap:8px; align-items:center;">
<select id="conversation-filter" class="form-select form-select-sm" style="width:auto;">
<option value="all">Todos</option>
<option value="unread">No leídos</option>
</select>
<small class="text-muted">Mostrar sólo conversaciones con mensajes no leídos</small>
</div>
<div class="conversation-list" id="conversation-list">
<div class="loading">
<i class="fas fa-spinner fa-spin"></i>
<div>Cargando conversaciones...</div>
</div>
</div>
<!--div id="load-more-container" style="padding:10px; text-align:center; display:none;">
<button id="load-more-btn" class="btn btn-sm btn-outline-primary">Cargar más</button>
</div-->
</div>
<!-- Área principal del chat -->
<div class="chat-main">
<div id="no-conversation" class="no-conversation">
<i class="fab fa-whatsapp"></i>
<h4>WhatsApp Bot Manager</h4>
<p>Selecciona una conversación para comenzar a chatear</p>
</div>
<div id="chat-area" style="display: none; height: 100%; flex-direction: column;">
<div class="chat-header">
<button id="sidebar-toggle" class="btn btn-sm btn-outline-light d-md-none me-2" style="border-radius:8px; padding:6px 8px;" title="Volver a conversaciones"><i class="fas fa-bars"></i></button>
<div class="chat-header-avatar" id="chat-avatar">
<i class="fas fa-user"></i>
</div>
<div class="chat-header-info">
<h6 id="chat-name"><span id="chat-name-text">Usuario</span> <button class="btn btn-sm btn-link" id="edit-user-btn" title="Editar usuario"><i class="fas fa-user-edit"></i></button>
<span id="hold-indicator" style="display:none; margin-left:8px; color:#b85; font-weight:600">EN ESPERA</span>
</h6>
<small id="chat-phone">+1234567890</small>
<div style="font-size:12px; margin-top:4px;">
<button id="attend-toggle-btn" class="btn btn-sm btn-outline-light" style="display:none; margin-right:6px;">Atender</button>
<small id="attend-status" class="text-white me-3" style="font-size:0.9rem;"></small>
<button id="release-hold-btn" class="btn btn-sm btn-outline-primary" style="display:none">Liberar espera</button>
</div>
</div>
<div style="margin-left: auto; display:flex; gap:8px; align-items:center;">
<div id="bot-toggle-container" style="display:flex;align-items:center;gap:6px;margin-right:8px;">
<button class="btn btn-sm btn-outline-secondary" id="bot-toggle">Bot: On</button>
</div>
<!-- PWA install button for conversations view -->
<button id="pwa-install-btn" class="btn btn-sm btn-outline-primary d-none" style="display:none;">Instalar</button>
<button class="btn btn-sm btn-outline-secondary" id="mark-unread-btn" title="Marcar conversación como no leída" style="display:none;">Marcar no leído</button>
<button class="btn btn-sm btn-outline-danger" id="delete-conversation-btn" title="Eliminar conversación" style="display:none;"><i class="fas fa-trash"></i></button>
</div>
</div>
<div class="chat-conversations" id="chat-conversations">
<!-- Los mensajes se cargan aquí -->
</div>
<!-- Preview de archivo multimedia -->
<div id="media-preview" style="display: none; padding: 10px; background: #f0f0f0; border-top: 1px solid #ddd;">
<div style="display: flex; align-items: center; justify-content: space-between;">
<div style="display: flex; align-items: center; gap: 10px;">
<img id="preview-thumbnail" style="max-width: 60px; max-height: 60px; border-radius: 5px; display: none;">
<div>
<div id="preview-filename" style="font-weight: bold; font-size: 14px;"></div>
<div id="preview-filesize" style="font-size: 12px; color: #666;"></div>
</div>
</div>
<button class="btn btn-sm btn-danger" onclick="cancelMediaUpload()">
<i class="fas fa-times"></i>
</button>
</div>
<input type="text" id="media-caption" class="form-control mt-2" placeholder="Agregar un comentario (opcional)" maxlength="1024">
</div>
<div id="reply-preview" class="reply-preview" style="display:none; align-items:center; justify-content:space-between;">
<div style="flex:1; white-space:nowrap; overflow:hidden; text-overflow:ellipsis;" id="reply-preview-text">En respuesta a: (selecciona un mensaje)</div>
<button class="btn btn-sm btn-link" id="cancel-reply-btn" title="Cancelar respuesta">✕</button>
</div>
<div class="chat-input">
<input type="file" id="file-input" accept="image/*,video/*,audio/*,.pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx" style="display: none;">
<button class="btn" id="attach-btn" title="Adjuntar">
📎
</button>
<div id="attach-menu" class="attach-menu" style="display:none;">
<button class="attach-option" data-action="file">Enviar archivo</button>
<button class="attach-option" data-action="template">Enviar plantilla</button>
</div>
<div style="display:flex; gap:8px; align-items:center;">
<!-- <select id="message-type" class="form-select form-select-sm" style="width:auto; margin-right:8px;">
<option value="text">Texto</option>
<option value="template">Plantilla</option>
</select> -->
<!-- <div id="templateSelectContainer" style="display:none; margin-right:8px;">
<select id="templateSelect" class="form-select form-select-sm">
<option value="">Seleccionar plantilla...</option>
</select>
</div> -->
<div class="quick-replies-wrapper" style="position:relative;">
<button id="quick-replies-toggle" class="btn btn-sm btn-outline-secondary" title="Respuestas rápidas" aria-expanded="false">⚡️</button>
<div id="quick-replies-panel" class="quick-replies-panel" style="display:none; position:absolute; bottom:48px; left:0; z-index:1200; min-width:220px; max-width:420px;">
<div style="padding:6px;">
<input id="quick-replies-search" class="form-control form-control-sm" placeholder="Buscar respuestas o plantillas..." autocomplete="off" />
</div>
<div class="qr-sections" style="max-height:260px; overflow:auto; padding:6px;">
<div class="qr-section">
<div style="display:flex; justify-content:space-between; align-items:center;">
<strong>Respuestas rápidas</strong>
<small id="qr-count" class="text-muted">0</small>
</div>
<div class="qr-list" style="display:flex; gap:6px; flex-wrap:wrap; margin-top:6px;"></div>
</div>
<hr style="margin:8px 0; border-color:#eee;">
<div class="tpl-section">
<div style="display:flex; justify-content:space-between; align-items:center;">
<strong>Plantillas</strong>
<small id="tpl-count" class="text-muted">0</small>
</div>
<div class="tpl-list" style="display:flex; gap:6px; flex-wrap:wrap; margin-top:6px;"></div>
</div>
</div>
<div class="text-muted" id="qr-empty" style="padding:8px; display:none;">No hay respuestas rápidas ni plantillas que coincidan.</div>
</div>
</div>
<button class="btn" id="mic-btn" title="Grabar audio" style="margin-left:6px; border:1px solid #ddd;">
<i class="fas fa-microphone"></i>
</button>
</div>
<div id="recording-indicator" style="display:none; padding:8px; background:#fff3cd; border-radius:6px; margin-top:8px;">
<i class="fas fa-microphone text-danger"></i> Grabando... <span id="recording-time">0:00</span>
<button class="btn btn-sm btn-danger" id="stop-recording" style="margin-left:8px;">Detener</button>
<button class="btn btn-sm btn-secondary" id="cancel-recording" style="margin-left:6px;">Cancelar</button>
</div>
<input type="text" placeholder="Escribe un mensaje..." id="message-input" maxlength="4096">
<button class="btn" id="send-btn">
<i class="fas fa-paper-plane"></i>
</button>
<button class="btn btn-danger" id="delete-file-btn" title="Eliminar" style="display:none; margin-left:6px;">
<i class="fas fa-trash"></i>
</button>
</div>
</div>
</div>
</div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/bootstrap/5.3.0/js/bootstrap.bundle.min.js"></script>
<!-- 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 - Desarrollado por U-Site.app', 'background: #25d366; color: white; padding: 10px 20px; font-size: 18px; font-weight: bold; border-radius: 5px;');
console.log('%c═══════════════════════════════════════════', 'color: #25d366; font-weight: bold;');
window.__APP_VERSION__ = '1.2.0';
</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') {
try {
let container = document.getElementById('notification-toasts');
if (!container) {
container = document.createElement('div');
container.id = 'notification-toasts';
document.body.appendChild(container);
}
const toast = document.createElement('div');
toast.className = 'notification-toast small' + (type === 'success' ? ' cool' : '');
const icon = type === 'success' ? '✅' : (type === 'danger' ? '⚠️' : (type === 'warning' ? '⚠' : '️'));
toast.innerHTML = `<span class="nt-icon">${icon}</span><div style="flex:1; font-size:13px">${message}</div>`;
container.appendChild(toast);
// Ensure a sensible minimum duration (1s) so very short toasts don't disappear instantly
const _minToastDuration = 1000;
let _dur = (type === 'success') ? 1000 : 1000;
_dur = Math.max(_dur, _minToastDuration);
setTimeout(() => { if (toast.parentNode) toast.remove(); }, _dur);
} catch (e) {
// fallback to alert if DOM fails
try { alert(message); } catch (e2) { /* ignore */ }
}
}
}
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.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
this.messageLimit = 50;
this.loadingMessages = false;
this.hasMoreMessages = false;
this.earliestMessage = null; // timestamp of earliest loaded message
this.earliestMessageId = null; // id of the earliest loaded message (for stable pagination)
// Filter state: 'all' or 'unread'
this.conversationFilter = 'all';
// Pagination state for conversation list
this.conversationsPage = 1;
this.conversationsLimit = 50;
this.hasMoreConversations = true;
this.loadingConversations = false;
// Track whether the user is actively scrolling/reading to avoid forcing scroll-to-bottom
this._userScrolling = false;
this._userScrollTimer = null;
// Conversation list (sidebar) scroll tracking to preserve position on updates
this._conversationsScrolling = false;
this._conversationsScrollTimer = null;
// Suspend periodic refresh while media playback or recording is active
this._suspendAutoRefresh = false;
this._pendingReloadAfterMedia = false;
// Flag to request a reload after user stops scrolling
this._pendingReloadAfterScroll = false;
// Staged incoming messages when the user is reading history (not near bottom)
this._stagedMessageIds = new Set();
this._stagedCount = 0;
// Store actual staged message objects (for applying later)
this._stagedMessages = [];
// Last message timestamp seen (for delta polling)
this._latestMessage = null;
// Preloaded messages cache used when we explicitly fetch before opening a conversation
this._preloadedMessages = null;
// 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();
this.setupNotificationPolling();
// start background retry loop to ack dismissed notifications on server
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() {
// 🎯 OPTIMIZADO: Las notificaciones ahora solo llegan por SSE
// No necesitamos polling ni carga inicial
console.debug('⚡ setupNotificationPolling: DESHABILITADO - SSE maneja todo en tiempo real');
}
/**
* Conectar a Server-Sent Events para recibir notificaciones en tiempo real
* Esto reemplaza el polling constante y mejora la performance
*/
connectSSE() {
if (this.eventSource) {
try { this.eventSource.close(); } catch(e) {}
}
console.log('Conectando a SSE para eventos en tiempo real...');
try {
// 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);
this.eventSource = new EventSource(sseUrl);
// Evento: conexión establecida
this.eventSource.addEventListener('connected', (e) => {
const data = JSON.parse(e.data);
const mode = data.mode === 'authenticated' ? '🔐 autenticado' : '🌐 global';
console.log(`✅ SSE conectado (${mode}):`, data);
// Mostrar notificación discreta de conexión
if (typeof showAlert === 'function' && !this._sseConnectedNotified) {
showAlert('Notificaciones en tiempo real activadas', 'success');
this._sseConnectedNotified = true;
}
});
// Evento: nuevo mensaje entrante
this.eventSource.addEventListener('new_message', (e) => {
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);
}
});
// Evento: nueva conversación detectada
this.eventSource.addEventListener('new_conversation', (e) => {
console.log('💬 Nueva conversación (SSE):', e.data);
const data = JSON.parse(e.data);
// Agregar la conversación a la lista sin recargar todo
this.addConversationToList(data);
// Reproducir sonido
this.playNotificationSound();
});
// Evento: notificación del sistema
this.eventSource.addEventListener('notification', (e) => {
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);
}
});
// Evento: heartbeat (mantener conexión viva)
this.eventSource.addEventListener('heartbeat', (e) => {
// Silencioso, solo mantiene la conexión
});
// Manejo de errores
this.eventSource.onerror = (error) => {
const state = this.eventSource.readyState;
const stateNames = {
0: 'CONNECTING',
1: 'OPEN',
2: 'CLOSED'
};
console.warn('❌ Error en SSE:');
console.warn(' Estado:', stateNames[state] || state);
console.warn(' Error:', error);
// Si está intentando conectar, dejar que EventSource lo maneje automáticamente
if (state === EventSource.CONNECTING) {
console.log('⏳ Reconectando automáticamente...');
return;
}
// Si está cerrado, intentar reconectar manualmente
if (state === EventSource.CLOSED) {
console.log('🔄 Conexión cerrada, reconectando en 5 segundos...');
// Cerrar completamente
try {
this.eventSource.close();
this.eventSource = null;
} catch(e) {
console.warn('Error cerrando EventSource:', e);
}
// Reconectar después de delay
if (this._sseReconnectTimeout) {
clearTimeout(this._sseReconnectTimeout);
}
this._sseReconnectTimeout = setTimeout(() => {
if (!this._sseReconnecting) {
console.log('🔌 Intentando reconectar SSE...');
this._sseReconnecting = true;
try {
this.connectSSE();
} catch(e) {
console.error('Error al reconectar SSE:', e);
} finally {
this._sseReconnecting = false;
}
}
}, 5000);
}
};
// Detectar evento de error explícito
this.eventSource.addEventListener('error', (e) => {
if (e.data) {
try {
const errorData = JSON.parse(e.data);
console.error('❌ SSE Error:', errorData.message);
// Mostrar notificación al usuario
if (typeof showAlert === 'function') {
showAlert('Error de conexión: ' + errorData.message, 'warning');
}
} catch(err) {
console.error('❌ SSE Error (raw):', e.data);
}
}
});
} catch (error) {
console.error('Error conectando SSE:', error);
}
}
/**
* Actualizar una conversación en la lista (sin recargar todo)
*/
updateConversationInList(data) {
try {
console.log('📝 Actualizando conversación en lista:', data);
// Asegurar que conversations esté inicializado
if (!this.conversations || !Array.isArray(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(userId));
if (existingIndex !== -1) {
console.log('✅ Conversación encontrada, actualizando...');
// Actualizar conversación existente
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;
} else {
// Si es la conversación activa, mantener unread_count en 0
conv.unread_count = 0;
}
// Mover al inicio de la lista
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: 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
console.log('🔄 Re-renderizando lista de conversaciones...');
console.log('🔍 Filtro activo:', this.conversationFilter);
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);
}
}
/**
* Agregar una conversación nueva a la lista
*/
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, 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(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: 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);
}
}
async loadNotifications() {
// 🎯 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;
}
// 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
const containerId = 'notification-toasts';
let container = document.getElementById(containerId);
if (!container) {
container = document.createElement('div');
container.id = containerId;
document.body.appendChild(container);
}
// Determine an id to dedupe notifications (prefer server id)
const nid = notification && notification.id ? String(notification.id) : ('msg:' + String((notification && notification.message) || '').slice(0,200));
// If the user already dismissed/acked this notification earlier, skip it
if (this._dismissedNotifications.has(nid)) {
console.debug('Notification previously dismissed, skipping', nid);
return;
}
if (this._shownNotifications.has(nid)) {
// already shown in this session
console.debug('Notification already shown, skipping', nid);
return;
}
// mark as shown immediately (solo en memoria, no en servidor)
this._shownNotifications.add(nid);
const toast = document.createElement('div');
// compact by default
toast.className = 'notification-toast small';
// Detect special "attention" notifications (e.g. user sent documents or similar system events)
const isAttention = (
// explicit attention flags
notification.type === 'attention' || notification.system === 'attention' || notification.level === 'attention' || notification.attention ||
// known event types/tags for user-sent documents
notification.type === 'usersentdocuments' || notification.tag === 'usersentdocuments' || notification.system === 'usersentdocuments'
);
if (notification.cool || notification.type === 'cool') toast.classList.add('cool');
if (notification.level === 'urgent' || notification.urgent) toast.classList.add('urgent');
if (isAttention) toast.classList.add('attention');
const iconSpan = document.createElement('span');
iconSpan.className = 'nt-icon';
iconSpan.textContent = notification.icon || (isAttention ? '📎' : (notification.cool ? '✨' : (notification.level === 'urgent' ? '⚠️' : '🔔')));
toast.appendChild(iconSpan);
const msg = document.createElement('div');
msg.style.flex = '1';
msg.style.fontSize = '13px';
msg.style.lineHeight = '1.2';
msg.textContent = notification.message || 'Nuevo evento';
toast.appendChild(msg);
const actions = document.createElement('div');
actions.className = 'nt-actions';
const removeToastLocal = () => {
if (toast.parentNode) toast.remove();
if (this._shownNotifications.has(nid)) this._shownNotifications.delete(nid);
};
const openBtn = document.createElement('button');
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 () => {
// Marcar como descartada localmente
this._dismissedNotifications.add(nid);
removeToastLocal();
// navigate to conv or open media if present
let data = {};
try { data = notification.data ? JSON.parse(notification.data) : {}; } catch(e) {}
const userId = data.user_id || notification.user_id;
const fileUrl = data.file_url || data.url || null;
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.conversations.find(c => c.user_id == userId);
if (conv) {
// Prefetch messages then open conversation using preloaded data to avoid double-fetch
try {
const url = `get_user_messages.php?user_id=${encodeURIComponent(userId)}&limit=${this.messageLimit}`;
const resp = await this.apiCall(url);
if (resp && resp.success) {
this._preloadedMessages = { userId: userId, resp: resp };
}
} catch (e) { console.warn('Prefetch messages failed', e); }
this.openConversation(conv.user_id, conv.name, conv.phone_number);
} else {
try {
const url = `get_user_messages.php?user_id=${encodeURIComponent(userId)}&limit=${this.messageLimit}`;
const resp = await this.apiCall(url);
if (resp && resp.success) {
this._preloadedMessages = { userId: userId, resp: resp };
}
} catch (e) { console.warn('Prefetch messages failed', e); }
this.openConversation(userId, 'Usuario', '');
}
}
};
const dismiss = document.createElement('button');
dismiss.className = 'btn btn-sm btn-outline-secondary';
dismiss.textContent = '×';
dismiss.title = 'Descartar';
dismiss.onclick = async () => {
// Marcar como descartada localmente
this._dismissedNotifications.add(nid);
removeToastLocal();
};
actions.appendChild(openBtn);
actions.appendChild(dismiss);
toast.appendChild(actions);
container.appendChild(toast);
// If attention notification and related to current open conversation, insert a system message into the chat view
try {
const dataObj = notification.data ? (typeof notification.data === 'string' ? JSON.parse(notification.data) : notification.data) : {};
const userId = dataObj.user_id || notification.user_id;
if (isAttention && userId && this.currentUserId && String(this.currentUserId) === String(userId) && typeof this.showSystemNotificationInChat === 'function') {
this.showSystemNotificationInChat(notification);
}
} catch (e) { console.warn('showNotificationToast -> showSystemNotificationInChat failed', e); }
// Auto remove (shorter for compact notifications) with minimum enforced
// Reducir mínimo y valores por defecto a 1 segundo (1000 ms)
const _minToastDuration = 1000;
let timeout = notification.duration ? Number(notification.duration) : (notification.cool ? 1000 : 1000);
if (!isFinite(timeout) || timeout <= 0) timeout = (notification.cool ? 1000 : 1000);
timeout = Math.max(timeout, _minToastDuration);
setTimeout(() => {
// Auto-descartar: solo limpiar localmente
this._dismissedNotifications.add(nid);
removeToastLocal();
}, timeout);
}
showSystemNotificationInChat(notification) {
try {
const data = notification.data ? (typeof notification.data === 'string' ? JSON.parse(notification.data) : notification.data) : {};
const userId = data.user_id || notification.user_id;
// Only show in currently open conversation
if (!userId || String(this.currentUserId) !== String(userId)) return;
const container = document.getElementById('chat-conversations');
if (!container) return;
const el = document.createElement('div');
el.className = 'message incoming';
el.dataset.notificationId = String(notification.id || '');
const bubble = document.createElement('div');
bubble.className = 'message-bubble message-template system';
// Friendly title and message (prefer explicit fields if present)
const title = notification.title || data.title || 'Documentos recibidos';
const msg = notification.message || data.text || data.message || (data.summary || 'El usuario envió documentos.');
// File info (if available)
const fileName = data.file_name || data.filename || data.file || null;
const fileUrl = data.file_url || data.url || null;
const fileSize = data.file_size || data.size || null;
// Build inner HTML
const parts = [];
parts.push(`<span class="mt-icon">📎</span>`);
parts.push(`<div style="flex:1">`);
parts.push(`<div style="font-weight:700; margin-bottom:6px">${escapeHtml(String(title))}</div>`);
parts.push(`<div style="font-size:13px; color:#444">${escapeHtml(String(msg))}</div>`);
if (fileName) {
parts.push(`<div style="margin-top:8px; display:flex; gap:8px; align-items:center;">
<i class="${getFileIcon(fileName)}"></i>
<div style="display:flex; flex-direction:column;">
<div class="document-name">${escapeHtml(fileName)}</div>
<div class="document-size text-muted" style="font-size:12px">${fileSize ? this.formatFileSize(Number(fileSize)) : ''}</div>
</div>
</div>`);
}
parts.push(`</div>`);
// Actions
parts.push(`<div style="margin-left:8px; display:flex; gap:6px; align-items:center">`);
if (fileUrl) {
parts.push(`<a href="${escapeHtml(fileUrl)}" target="_blank" class="btn btn-sm btn-outline-primary">Abrir</a>`);
parts.push(`<button class="btn btn-sm btn-primary download-doc-btn">Descargar</button>`);
}
parts.push(`</div>`);
bubble.innerHTML = parts.join('');
el.appendChild(bubble);
container.appendChild(el);
this.scrollToBottom();
// Attach handlers
const downloadBtn = bubble.querySelector('.download-doc-btn');
if (downloadBtn) downloadBtn.addEventListener('click', () => {
if (fileUrl) {
const a = document.createElement('a');
a.href = fileUrl;
a.download = fileName || '';
document.body.appendChild(a);
a.click();
a.remove();
}
});
} catch (e) {
console.warn('showSystemNotificationInChat failed', e);
}
}
// Mostrar mensaje de estado (persistente) en la conversación activa
showStatusMessageInChat(userId, text) {
try {
if (!userId || !text) return;
if (String(this.currentUserId) !== String(userId)) return;
const container = document.getElementById('chat-conversations');
if (!container) return;
// eliminar status previos para evitar duplicados
Array.from(container.querySelectorAll('.message-template.system.system-status')).forEach(el => el.parentNode && el.parentNode.removeChild(el));
const el = document.createElement('div');
el.className = 'message incoming';
el.dataset.status = 'service';
const bubble = document.createElement('div');
bubble.className = 'message-bubble message-template system system-status';
bubble.innerHTML = `<div style="display:flex; gap:8px; align-items:center"><span class="mt-icon">👩‍⚕️</span><div style="flex:1"><div style="font-weight:700">${escapeHtml(String(text))}</div></div></div>`;
el.appendChild(bubble);
container.appendChild(el);
this.scrollToBottom();
} catch (e) { console.warn('showStatusMessageInChat failed', e); }
}
setupEventListeners() {
// Búsqueda de conversaciones
document.getElementById('search-input').addEventListener('input', (e) => {
this.searchConversations(e.target.value);
});
// Envío de mensajes
document.getElementById('send-btn').addEventListener('click', () => {
this.sendMessage();
});
// Forward handler: open preview when user chooses to forward an existing message
this.forwardMessage = function(messageId) {
try {
const input = document.getElementById('message-input');
if (input) {
input.dataset.forwardTo = messageId;
}
this.showReplyPreview(messageId, 'forward');
// Optionally focus the recipient selection by showing a hint or opening the sidebar
} catch (e) {
console.warn('forwardMessage failed', e);
}
};
// Edit user name button
const editUserBtn = document.getElementById('edit-user-btn');
if (editUserBtn) editUserBtn.addEventListener('click', () => this.promptEditUser());
// Reply prompt helper: when user clicks reply on a message
this.promptReply = function(messageId) {
const input = document.getElementById('message-input');
if (!input) return;
input.dataset.replyTo = messageId;
input.dataset.forwardTo = null;
this.showReplyPreview(messageId, 'reply');
input.focus();
};
// Reaction picker: open a small emoji palette near the click
this.openReactionPicker = function(ev, messageId) {
if (ev && ev.stopPropagation) ev.stopPropagation();
const picker = document.getElementById('reaction-picker');
if (!picker) return;
this._pendingReactionMessage = messageId;
// position picker near event or center
picker.style.display = 'block';
picker.classList.remove('show');
// compute position
let left = (window.innerWidth / 2) - (picker.offsetWidth / 2);
let top = (window.innerHeight / 2) - (picker.offsetHeight / 2);
if (ev && ev.clientX) {
left = Math.min(Math.max(8, ev.clientX - 40), window.innerWidth - picker.offsetWidth - 8);
top = Math.min(Math.max(8, ev.clientY - 24), window.innerHeight - picker.offsetHeight - 8);
}
picker.style.left = left + 'px';
picker.style.top = top + 'px';
// small delay to allow CSS transition
setTimeout(() => picker.classList.add('show'), 10);
};
// Hide reaction picker on document click
document.addEventListener('click', (ev) => {
const picker = document.getElementById('reaction-picker');
if (picker && picker.style.display === 'block' && !picker.contains(ev.target)) {
picker.classList.remove('show');
setTimeout(()=>{ picker.style.display='none'; },160);
}
});
// Attach click handler for emojis (event delegation)
document.addEventListener('click', async (ev) => {
const t = ev.target;
if (t && t.dataset && t.dataset.emoji) {
ev.stopPropagation();
const emoji = t.dataset.emoji;
const mid = this._pendingReactionMessage;
if (!mid) return;
try {
const resp = await fetch('api/react_message.php', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({ message_id: mid, emoji: emoji }) });
const j = await resp.json();
if (j && j.success) {
// update local model and UI
const m = this.currentMessages.find(x => x.message_id == mid || x.id == mid);
if (m) m.reaction_emoji = emoji;
this.updateMessageReactionInView && this.updateMessageReactionInView(mid, emoji);
} else {
showAlert && showAlert('Error aplicando reacción', 'danger');
}
} catch (err) {
console.error('Reaction error', err);
showAlert && showAlert('Error aplicando reacción', 'danger');
} finally {
const picker = document.getElementById('reaction-picker');
if (picker) {
picker.classList.remove('show');
setTimeout(()=>{ picker.style.display='none'; },160);
}
this._pendingReactionMessage = null;
}
}
});
// Message type (text/template)
const typeSelect = document.getElementById('message-type');
if (typeSelect) {
typeSelect.addEventListener('change', (e) => {
const tplContainer = document.getElementById('templateSelectContainer');
tplContainer.style.display = (e.target.value === 'template') ? 'inline-block' : 'none';
if (typeof updateSendMicVisibility === 'function') updateSendMicVisibility();
});
}
// Quick replies toggle behavior: close on outside click
const qrToggle = document.getElementById('quick-replies-toggle');
const qrPanel = document.getElementById('quick-replies-panel');
if (qrToggle && qrPanel) {
qrToggle.addEventListener('click', (ev) => {
ev.stopPropagation();
const isOpen = qrPanel.style.display === 'block';
qrPanel.style.display = isOpen ? 'none' : 'block';
qrToggle.setAttribute('aria-expanded', String(!isOpen));
// Focus search when panel opens
if (!isOpen) {
setTimeout(() => {
const s = document.getElementById('quick-replies-search');
if (s) { s.value = ''; s.focus(); s.dispatchEvent(new Event('input')); }
}, 80);
}
});
qrPanel.addEventListener('click', (ev) => { ev.stopPropagation(); });
document.addEventListener('click', () => { qrPanel.style.display = 'none'; qrToggle.setAttribute('aria-expanded','false'); });
// Quick replies search/filter behavior
const qrSearch = document.getElementById('quick-replies-search');
if (qrSearch) {
const filterLists = () => {
const q = (qrSearch.value || '').trim().toLowerCase();
const qrList = qrPanel.querySelector('.qr-list');
const tplList = qrPanel.querySelector('.tpl-list');
let qrMatches = 0, tplMatches = 0;
if (qrList) Array.from(qrList.children).forEach(b => {
const t = (b.textContent || '').toLowerCase();
const ok = q === '' || t.indexOf(q) !== -1;
b.style.display = ok ? 'inline-block' : 'none';
if (ok) qrMatches++;
});
if (tplList) Array.from(tplList.children).forEach(b => {
const t = (b.textContent || '').toLowerCase();
const ok = q === '' || t.indexOf(q) !== -1;
b.style.display = ok ? 'inline-block' : 'none';
if (ok) tplMatches++;
});
const qrCount = document.getElementById('qr-count'); if (qrCount) qrCount.textContent = qrMatches;
const tplCount = document.getElementById('tpl-count'); if (tplCount) tplCount.textContent = tplMatches;
const empty = document.getElementById('qr-empty'); if (empty) empty.style.display = (qrMatches + tplMatches) ? 'none' : 'block';
};
qrSearch.addEventListener('input', filterLists);
}
}
// Mic / recording
const micBtn = document.getElementById('mic-btn');
const sendBtn = document.getElementById('send-btn');
const messageInput = document.getElementById('message-input');
// Move mic button to be next to send (so it appears in the send slot)
try {
if (micBtn && sendBtn && micBtn.parentNode) {
sendBtn.parentNode.insertBefore(micBtn, sendBtn);
}
} catch (e) { /* ignore */ }
// Use lexical captured self to avoid 'this' binding issues in event handlers
const self = this;
if (micBtn) {
micBtn.addEventListener('click', (ev) => {
ev.preventDefault();
// Ensure recording functions are available (they are initialized when opening a conversation
// but can be invoked earlier; initialize lazily if needed)
if (typeof self.startRecording !== 'function' && typeof self.initRecordingHandlers === 'function') {
try { self.initRecordingHandlers(); } catch(e) { console.warn('initRecordingHandlers failed', e); }
}
// While recording: clicking mic cancels the recording (as requested)
if (self._mediaRecorder && self._mediaRecorder.state === 'recording') {
try { self.cancelRecording(); } catch (e) { console.warn('cancelRecording failed', e); }
} else {
try { self.startRecording(); } catch (e) { console.warn('startRecording failed', e); alert('No se pudo iniciar la grabación.'); }
}
});
}
// Toggle visibility: show mic when input empty, show send when there's text, a file selected, or template mode
const updateSendMicVisibility = () => {
const text = (messageInput && messageInput.value) ? messageInput.value.trim() : '';
const typeSelect = document.getElementById('message-type');
const isTemplate = typeSelect && typeSelect.value === 'template';
// Consider both selectedFile and visible media-preview as indicators of media pending send
const previewEl = document.getElementById('media-preview');
const fileSelected = !!this.selectedFile || (previewEl && previewEl.style.display && previewEl.style.display !== 'none');
// If currently recording, show mic and don't switch to send
if (this._mediaRecorder && this._mediaRecorder.state === 'recording') {
if (sendBtn) { sendBtn.style.display = 'none'; }
if (micBtn) { micBtn.style.display = 'inline-flex'; }
return;
}
// If there's a file selected or there's text/template, show send button (media overrides mic)
if (fileSelected || text.length > 0 || isTemplate) {
if (sendBtn) { sendBtn.style.display = 'inline-block'; }
if (micBtn) { micBtn.style.display = 'none'; }
} else {
// Force mic visible when input empty and no file selected
if (sendBtn) { sendBtn.style.display = 'none'; }
if (micBtn) { micBtn.style.display = 'inline-flex'; }
}
};
if (messageInput) {
messageInput.addEventListener('input', updateSendMicVisibility);
}
// Expose small helper to other methods
this._updateSendMicVisibility = updateSendMicVisibility;
// File input selected -> show send button
const fileInput = document.getElementById('file-input');
if (fileInput) {
fileInput.addEventListener('change', (ev) => {
if (ev.target.files && ev.target.files.length) {
// existing showMediaPreview handles storing this.selectedFile
this.showMediaPreview(ev.target.files[0]);
}
updateSendMicVisibility();
});
}
// Initialize visibility
setTimeout(updateSendMicVisibility, 10);
// Attach '+' menu handling
const attachBtn = document.getElementById('attach-btn');
const attachMenu = document.getElementById('attach-menu');
if (attachBtn && attachMenu) {
attachBtn.addEventListener('click', (ev) => {
ev.stopPropagation();
const isOpen = attachMenu.style.display === 'block';
// position menu under the button
const rect = attachBtn.getBoundingClientRect();
attachMenu.style.left = (rect.left) + 'px';
attachMenu.style.top = (rect.bottom + 8) + 'px';
attachMenu.style.display = isOpen ? 'none' : 'block';
});
// Option clicks
attachMenu.addEventListener('click', (ev) => {
const action = ev.target && ev.target.dataset ? ev.target.dataset.action : null;
if (!action) return;
ev.stopPropagation();
attachMenu.style.display = 'none';
if (action === 'file') {
if (fileInput) fileInput.click();
} else if (action === 'template') {
// switch to template mode and focus selector
const typeSelect = document.getElementById('message-type');
if (typeSelect) {
typeSelect.value = 'template';
const evt = new Event('change');
typeSelect.dispatchEvent(evt);
const tplSel = document.getElementById('templateSelect');
if (tplSel) tplSel.focus();
updateSendMicVisibility();
}
}
});
// close when clicking outside
document.addEventListener('click', (ev) => {
if (attachMenu && attachMenu.style.display === 'block' && !attachMenu.contains(ev.target) && ev.target !== attachBtn) {
attachMenu.style.display = 'none';
}
});
}
// Mobile: sidebar toggle
const sidebar = document.querySelector('.chat-sidebar');
const sidebarToggle = document.getElementById('sidebar-toggle');
if (sidebarToggle && sidebar) {
sidebarToggle.addEventListener('click', (ev) => {
ev.preventDefault();
// toggle mobile-hidden class
sidebar.classList.toggle('mobile-hidden');
});
// ensure sidebar hidden by default on small screens
const applyInitialMobile = () => {
const chatAreaEl = document.getElementById('chat-area');
const sidebarToggleBtn = document.getElementById('sidebar-toggle');
if (window.innerWidth <= 768) {
// On small screens, show the conversations list by default
sidebar.classList.remove('mobile-hidden');
// hide chat area if no conversation selected; otherwise show chat and hide sidebar
if (chatAreaEl) {
if (!this.currentUserId) {
chatAreaEl.style.display = 'none';
} else {
chatAreaEl.style.display = 'flex';
sidebar.classList.add('mobile-hidden');
}
}
if (sidebarToggleBtn) sidebarToggleBtn.style.display = 'inline-block';
} else {
// Desktop: show both panels
sidebar.classList.remove('mobile-hidden');
if (chatAreaEl) chatAreaEl.style.display = this.currentUserId ? 'flex' : 'none';
if (sidebarToggleBtn) sidebarToggleBtn.style.display = 'none';
}
};
applyInitialMobile();
window.addEventListener('resize', applyInitialMobile);
// PWA support (install prompt + SW)
let _deferredPWA = null;
const _pwaBtnConv = document.getElementById('pwa-install-btn');
window.addEventListener('beforeinstallprompt', (e) => {
e.preventDefault();
_deferredPWA = e;
if (_pwaBtnConv) { _pwaBtnConv.style.display = 'inline-block'; _pwaBtnConv.classList.remove('d-none'); }
});
if (_pwaBtnConv) {
_pwaBtnConv.addEventListener('click', async () => {
if (!_deferredPWA) return;
_deferredPWA.prompt();
const choice = await _deferredPWA.userChoice;
console.debug('PWA install (conv):', choice);
if (choice && choice.outcome === 'accepted') _pwaBtnConv.style.display = 'none';
_deferredPWA = null;
});
}
window.addEventListener('appinstalled', () => console.debug('PWA installed (conversations)'));
if ('serviceWorker' in navigator) {
(async () => {
try {
// Check if the service-worker script is actually reachable to avoid noisy 404 warnings
const resp = await fetch('/service-worker.js', { method: 'GET', cache: 'no-store' });
if (resp && resp.ok) {
navigator.serviceWorker.register('/service-worker.js')
.then(() => console.debug('SW reg (conv)'))
.catch(e => console.warn('SW reg failed', e));
} else {
console.debug('Service worker not registered: script not found (status ' + (resp && resp.status) + ')');
// Remove manifest link if it's missing to avoid extra 404s
try {
const link = document.querySelector('link[rel="manifest"]');
if (link && (!resp || resp.status === 404)) link.parentNode && link.parentNode.removeChild(link);
} catch(e) {}
}
} catch (e) {
// Network or fetch failed — don't spam console with errors
console.debug('Service worker check failed:', e);
}
})();
}
}
const stopRecBtn = document.getElementById('stop-recording');
if (stopRecBtn) stopRecBtn.addEventListener('click', () => this.stopRecording());
const cancelRecBtn = document.getElementById('cancel-recording');
if (cancelRecBtn) cancelRecBtn.addEventListener('click', () => this.cancelRecording());
document.getElementById('message-input').addEventListener('keypress', (e) => {
if (e.key === 'Enter') {
this.sendMessage();
}
});
// Permitir pegar imágenes desde el portapapeles: convierte el contenido pegado en File y muestra el preview
document.getElementById('message-input').addEventListener('paste', (e) => {
try {
const cd = (e.clipboardData || window.clipboardData);
if (!cd) return;
// Helper: convertir base64 a Blob
const b64ToBlob = (b64, mime) => {
const bytes = atob(b64);
const len = bytes.length;
const arr = new Uint8Array(len);
for (let i = 0; i < len; i++) arr[i] = bytes.charCodeAt(i);
return new Blob([arr], { type: mime });
};
// 1) Si hay archivos directos en clipboard (Chrome/Edge)
if (cd.files && cd.files.length) {
for (const file of cd.files) {
if (file && file.type && file.type.startsWith('image/')) {
e.preventDefault && e.preventDefault();
const max = this.getMaxFileSize(file.type);
if (file.size > max) {
alert('El archivo es demasiado grande. Máximo: ' + (max / 1024 / 1024).toFixed(0) + 'MB');
return;
}
this.showMediaPreview(file);
return;
}
}
}
// 2) Items (Safari / otros) - buscar imagen o HTML con data URI
if (cd.items && cd.items.length) {
for (const item of cd.items) {
try {
if (item.kind === 'file' && item.type && item.type.startsWith('image/')) {
e.preventDefault && e.preventDefault();
const file = item.getAsFile();
if (file) {
const max = this.getMaxFileSize(file.type);
if (file.size > max) { alert('El archivo es demasiado grande. Máximo: ' + (max / 1024 / 1024).toFixed(0) + 'MB'); return; }
this.showMediaPreview(file);
return;
}
} else if (item.kind === 'string' && (item.type === 'text/html' || item.type === 'text/plain')) {
// Extraer data URI desde HTML si existe
item.getAsString((s) => {
const m = s && s.match ? s.match(/src=["']data:(image\/[^;]+);base64,([^"']+)["']/i) : null;
if (m) {
try {
const mime = m[1];
const b64 = m[2];
const blob = b64ToBlob(b64, mime);
const ext = (mime.split('/')[1] || 'png').split('+')[0];
const file = new File([blob], 'pasted_image.' + ext, { type: mime });
const max = this.getMaxFileSize(file.type);
if (file.size > max) { alert('El archivo es demasiado grande. Máximo: ' + (max / 1024 / 1024).toFixed(0) + 'MB'); return; }
this.showMediaPreview(file);
} catch (err) { console.warn('paste image convert failed', err); }
}
});
// no hacer return inmediato: seguir buscando otros items
}
} catch (inner) { console.warn('clipboard item parse failed', inner); }
}
}
} catch (err) {
console.warn('paste handler failed', err);
}
});
// Adjuntar archivos
document.getElementById('attach-btn').addEventListener('click', () => {
document.getElementById('file-input').click();
});
document.getElementById('file-input').addEventListener('change', (e) => {
this.handleFileSelect(e);
});
// Carga paginada: botón "Cargar más" y scroll infinito
const loadMoreBtn = document.getElementById('load-more-btn');
if (loadMoreBtn) {
loadMoreBtn.addEventListener('click', () => this.loadMoreConversations());
}
const convList = document.getElementById('conversation-list');
if (convList) {
convList.addEventListener('scroll', () => {
// mark user scrolling in sidebar so updates don't jump their view
try {
this._conversationsScrolling = true;
if (this._conversationsScrollTimer) clearTimeout(this._conversationsScrollTimer);
this._conversationsScrollTimer = setTimeout(() => { this._conversationsScrolling = false; this._conversationsScrollTimer = null; }, 600);
} catch (e) { /* ignore */ }
if (this.hasMoreConversations && !this.loadingConversations && (convList.scrollTop + convList.clientHeight >= convList.scrollHeight - 60)) {
this.loadMoreConversations();
}
});
}
// Filtro: Todos / No leídos
const filterSelect = document.getElementById('conversation-filter');
if (filterSelect) {
filterSelect.value = this.conversationFilter;
filterSelect.addEventListener('change', (e) => {
this.conversationFilter = e.target.value || 'all';
console.log('🔍 Filtro cambiado a:', this.conversationFilter);
// Re-renderizar la lista actual sin recargar desde el servidor
this.renderConversations();
// También recargar desde el servidor para asegurar datos actualizados
// (pero esto es secundario y puede ser en background)
this.loadConversations(1, false).catch(err => {
console.warn('Error recargando conversaciones después de cambiar filtro:', err);
});
});
}
}
setupAutoRefresh() {
// NOTA: Todos los sistemas de polling automático fueron ELIMINADOS
//
// SSE maneja TODAS las actualizaciones en tiempo real:
//
// 1. new_message → Recarga mensajes de conversación activa
// Ver línea ~1103: this.loadMessages(this.currentUserId, false, true)
//
// 2. new_conversation → Agrega conversación a la lista
// Ver línea ~1122: this.addConversationToList(data)
//
// 3. notification → Muestra toast de notificación
// Ver línea ~1119: this.showNotificationToast(notification)
//
// Esto elimina TODO el polling HTTP y reduce la carga en 95%
// Latencia: 3-30s → < 1s
//
// Sin polling = Sin peticiones constantes = Servidor más eficiente 🚀
}
// New: periodic delta poller to fetch only messages newer than last seen timestamp
async pollNewMessages() {
if (!this.currentUserId) return;
// Do not run delta polling when a full load is in progress (avoids races/clearing)
if (this._fullLoadInProgress) {
if (console && console.debug) console.debug('pollNewMessages skipped due to full load in progress');
return;
}
try {
// If we don't yet have a latest message timestamp, fall back to full load
if (!this._latestMessage) {
return await this.loadMessages(this.currentUserId, true, true);
}
const url = `get_user_messages.php?user_id=${this.currentUserId}&since=${encodeURIComponent(this._latestMessage)}&limit=${this.messageLimit}`;
const resp = await this.apiCall(url);
if (!resp || !resp.success || !Array.isArray(resp.data) || resp.data.length === 0) return;
const messages = resp.data;
// Deduplicate and process
const container = document.getElementById('chat-conversations');
const nearBottomNow = container ? ((container.scrollHeight - container.scrollTop - container.clientHeight) < 150) : true;
let appended = 0;
for (const m of messages) {
try {
// skip duplicates
const exists = this.currentMessages && this.currentMessages.some(x => (x.message_id && m.message_id && String(x.message_id) === String(m.message_id)) || (x.id && m.id && Number(x.id) === Number(m.id)));
if (exists) continue;
if (nearBottomNow && !this._userScrolling) {
// append directly and mark for render
this.currentMessages.push(m);
appended++;
} else {
// stage for later (user is reading history)
this._stagedMessages.push(m);
if (this._stagedMessageIds) this._stagedMessageIds.add(m.message_id || m.id || ('local_' + Date.now()));
this._stagedCount = this._stagedMessages.length;
this.showNewMessagesIndicator(this._stagedCount);
}
} catch (e) { console.warn('pollNewMessages process failed for msg', e); }
}
if (appended > 0) {
// render the new appended messages without disrupting playback/scroll; follow only if near bottom
this.renderMessagesIncremental(false, 0, nearBottomNow && !this._userScrolling);
if (nearBottomNow && !this._userScrolling) this.scrollToBottom();
}
// If we received any delta messages, perform an immediate full fetch to sync any server-side batching
if (messages.length > 0) {
if (!this._fullLoadInProgress) {
try {
if (console && console.debug) console.debug('pollNewMessages: delta returned, triggering immediate full reload');
await this.loadMessages(this.currentUserId, true, nearBottomNow && !this._userScrolling);
} catch (e) {
console.warn('Immediate full reload after delta failed', e);
}
} else {
if (console && console.debug) console.debug('Immediate full reload skipped because another full load is in progress');
}
}
// update latest timestamp from the last message
try {
const last = messages[messages.length - 1];
if (last && last.created_at) this._latestMessage = last.created_at;
} catch (e) { /* ignore */ }
} catch (e) {
console.warn('pollNewMessages failed', e);
}
}
// Helper para llamadas a la API desde esta clase
async apiCall(endpoint, options = {}) {
const defaultOptions = {
method: 'GET',
headers: {
'Content-Type': 'application/json'
},
credentials: 'same-origin' // Incluir cookies de sesión
};
// Si se pasa body, asumimos POST (y serializamos)
const finalOptions = { ...defaultOptions, ...options };
if (options.body && typeof options.body === 'object') {
finalOptions.method = 'POST';
finalOptions.body = JSON.stringify(options.body);
}
// Normalizar endpoint: prefijar 'api/' si no es URL completa y no empieza con 'api/'
let url = endpoint;
if (!/^https?:\/\//i.test(url) && !url.startsWith('api/')) {
url = 'api/' + url;
}
const response = await fetch(url, finalOptions);
if (response.status === 401) {
// Sesión expirada: redirigir al login
console.warn('apiCall: unauthorized, redirecting to login');
window.location.href = 'login.php';
return null;
}
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);
throw new Error('HTTP error! status: ' + response.status + ' -- ' + snippet);
}
if (!text || text.trim() === '') return null;
try {
return JSON.parse(text);
} catch (err) {
console.error('apiCall parse error for', url, err);
const snippet = text.length > 1000 ? text.substr(0, 1000) : text;
throw new Error('Invalid JSON response from ' + url + ': ' + err.message + ' -- response snippet: ' + snippet);
}
}
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');
const loadMoreBtn = document.getElementById('load-more-btn');
if (loadMoreBtn) loadMoreBtn.disabled = true;
try {
const url = `api/get_conversations.php?page=${page}&limit=${this.conversationsLimit}&filter=${encodeURIComponent(this.conversationFilter)}`;
const resp = await fetch(url, { credentials: 'same-origin' });
if (!resp.ok) {
throw new Error(`HTTP ${resp.status}: ${resp.statusText}`);
}
const data = await resp.json();
console.log('Respuesta get_conversations:', data); // Debug
let items = [];
let hasMore = false;
if (data && data.success && Array.isArray(data.data)) {
items = data.data;
hasMore = !!data.has_more || (page * this.conversationsLimit) < (data.total || 0);
} else if (Array.isArray(data)) {
items = data;
hasMore = items.length === this.conversationsLimit;
} else {
console.error('Error cargando conversaciones: formato de datos inválido', data);
alert('Error cargando conversaciones: ' + (data.error || 'formato de datos inválido'));
this.loadingConversations = false;
if (loadMoreBtn) loadMoreBtn.disabled = false;
return;
}
if (append) {
this.conversations = this.conversations.concat(items);
} else {
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) {
loadMoreContainer.style.display = this.hasMoreConversations ? 'block' : 'none';
}
} catch (error) {
console.error('Error loading conversations:', error);
alert('Error cargando conversaciones: ' + error.message);
} finally {
this.loadingConversations = false;
if (loadMoreBtn) loadMoreBtn.disabled = false;
}
}
async loadMoreConversations() {
if (!this.hasMoreConversations || this.loadingConversations) return;
await this.loadConversations(this.conversationsPage + 1, true);
}
renderConversations() {
console.log('🎨 Renderizando conversaciones:', this.conversations.length);
const container = document.getElementById('conversation-list');
if (!container) {
console.error('❌ No se encontró el elemento #conversation-list en el DOM');
return;
}
console.log('✅ Container encontrado:', container);
console.log('🔍 Filtro activo:', this.conversationFilter);
// Aplicar filtro de no leídos
let conversationsToShow = this.conversations;
if (this.conversationFilter === 'unread') {
conversationsToShow = this.conversations.filter(c => (c.unread_count || 0) > 0);
console.log(`🔍 Filtro 'unread' aplicado: ${conversationsToShow.length} de ${this.conversations.length} conversaciones`);
}
if (conversationsToShow.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">
<i class="fas fa-comments fa-3x text-muted mb-3"></i>
<p class="text-muted">${emptyMsg}</p>
</div>
`;
console.log('📭 Lista vacía, mostrando mensaje');
return;
}
console.log('📋 Generando HTML para', conversationsToShow.length, 'conversaciones');
// Ahora el backend devuelve por usuario: last_message, last_time, unread_count y avatar_url
const html = conversationsToShow.map(conv => {
const isActive = conv.user_id == this.currentUserId ? 'active' : '';
const time = this.formatTime(conv.last_time);
// Mostrar miniatura si el último mensaje es multimedia y tiene archivo local
let preview = '';
const hasLocalMedia = conv.last_local_file || conv.last_local_thumb;
const isMediaType = conv.message_type && ['image', 'video', 'audio', 'document'].includes(conv.message_type);
if (hasLocalMedia && isMediaType) {
const thumbSrc = conv.last_local_thumb || conv.last_local_file;
const mediaIcon = {
'image': '🖼️',
'video': '🎥',
'audio': '🎵',
'document': '📎'
}[conv.message_type] || '📎';
if (conv.message_type === 'image' || conv.message_type === 'video') {
preview = `<img src="/${thumbSrc}" alt="media" style="width:40px; height:40px; object-fit:cover; border-radius:4px; margin-right:8px; vertical-align:middle;"> ${mediaIcon} ${conv.last_message || 'Multimedia'}`;
} else {
preview = `${mediaIcon} ${conv.last_message || 'Archivo'}`;
}
} else {
preview = this.truncateText(conv.last_message || 'Sin mensajes', 50);
}
const avatar = conv.avatar_url ? `<img src="${conv.avatar_url}" alt="avatar" class="conversation-avatar-img">` : `<div class="conversation-avatar">${this.getInitials(conv.name)}</div>`;
const unreadBadge = conv.unread_count && conv.unread_count > 0 ? `<span class="badge bg-danger unread-badge">${conv.unread_count}</span>` : '';
const attentionBadge = conv.advisor_requested ? `<span class="badge bg-danger ms-1">¡Atención!</span>` : '';
// different color if has unread and not active
const unreadClass = (conv.unread_count && conv.unread_count > 0 && !isActive) ? 'unread' : '';
const attentionClass = conv.advisor_requested ? ' attention' : '';
return `
<div class="conversation-item ${isActive} ${unreadClass}${attentionClass}" data-user-id="${conv.user_id}" onclick="chat.openConversation(${conv.user_id}, '${conv.name}', '${conv.phone_number}')">
<div class="conversation-avatar-wrapper">
${avatar}
</div>
<div class="conversation-info">
<div class="conversation-name">${conv.name} ${unreadBadge} ${attentionBadge}</div>
<div class="conversation-preview">
${conv.direction === 'outgoing' ? '✓ ' : ''}${preview}
</div>
</div>
<div class="conversation-time">
${time}
</div>
</div>
`;
}).join('');
console.log('📄 HTML generado, longitud:', html.length, 'caracteres');
// Detectar nuevas notificaciones: comparar unread counts previos (usar todas las conversaciones, no solo las filtradas)
if (!this._prevConversations) this._prevConversations = {};
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
this.playNotificationSound();
}
this._prevConversations[c.user_id] = { unread_count: c.unread_count };
});
// Preserve sidebar scroll position as best-effort: snapshot and restore using delta
try {
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;
if (wasNearTop) {
// keep at top
container.scrollTop = 0;
} else {
// preserve visual offset (avoid jumping) whether the user is scrolling or not
container.scrollTop = Math.max(0, prevScrollTop + scrollDelta);
}
} catch (e) {
// fallback to naive replace if anything failed
console.warn('renderConversations: scroll preservation failed', e);
container.innerHTML = html;
}
}
getInitials(name) {
if (!name) return '?';
const parts = name.split(' ');
if (parts.length >= 2) {
return (parts[0][0] + parts[1][0]).toUpperCase();
}
return name.substring(0, 2).toUpperCase();
}
formatTime(dateString) {
const date = new Date(dateString);
const now = new Date();
const diffInHours = (now - date) / (1000 * 60 * 60);
if (diffInHours < 24) {
return date.toLocaleTimeString('es-ES', {
hour: '2-digit',
minute: '2-digit'
});
} else if (diffInHours < 48) {
return 'Ayer';
} else {
return date.toLocaleDateString('es-ES', {
day: '2-digit',
month: '2-digit'
});
}
}
truncateText(text, maxLength) {
if (text.length <= maxLength) return text;
return text.substring(0, maxLength) + '...';
}
// Cache ligero del estado del usuario (in_service, advisor_requested, on_hold)
async getUserState(userId, force = false) {
if (!userId) return null;
this._userStateCache = this._userStateCache || {};
const now = Date.now();
const cached = this._userStateCache[userId];
const TTL = 15000; // 15 segundos
if (!force && cached && (now - cached.ts) < TTL) {
return cached.state;
}
try {
const resp = await this.apiCall(`get_conversation_detail.php?user_id=${userId}`);
if (resp && resp.success && resp.user) {
const s = {
in_service: !!resp.user.in_service,
advisor_requested: !!resp.user.advisor_requested,
on_hold: !!resp.user.on_hold
};
this._userStateCache[userId] = { state: s, ts: Date.now() };
return s;
}
} catch (e) {
console.warn('getUserState failed for', userId, e);
}
// fallback: keep previous cached or return null
if (cached) return cached.state;
this._userStateCache[userId] = { state: null, ts: Date.now() };
return null;
}
getConversationPhone(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;
}
playNotificationSound() {
try {
// Small beep using Web Audio API (no external file needed)
const ctx = new (window.AudioContext || window.webkitAudioContext)();
const o = ctx.createOscillator();
const g = ctx.createGain();
o.type = 'sine';
o.frequency.value = 880;
o.connect(g);
g.connect(ctx.destination);
g.gain.value = 0.05;
o.start();
setTimeout(() => { o.stop(); ctx.close(); }, 180);
} catch (e) {
console.warn('Notification sound failed', e);
}
}
async openConversation(userId, userName, phoneNumber) {
this.currentUserId = userId;
// Clear any staged messages from a previous conversation and hide indicator
try { if (this._stagedMessageIds) { this._stagedMessageIds.clear(); this._stagedCount = 0; this.hideNewMessagesIndicator(); } } catch(e) {}
// Actualizar inmediatamente el contador de no leídos a 0 en la lista local
const conv = this.conversations.find(c => c.user_id === userId);
if (conv) {
conv.unread_count = 0;
// Re-renderizar la lista para reflejar el cambio inmediatamente
this.renderConversations();
}
// Actualizar UI
document.getElementById('no-conversation').style.display = 'none';
document.getElementById('chat-area').style.display = 'flex';
// Actualizar header
const nameEl = document.getElementById('chat-name-text') || document.getElementById('chat-name');
nameEl.textContent = userName;
document.getElementById('chat-phone').textContent = phoneNumber;
document.getElementById('chat-avatar').textContent = this.getInitials(userName);
// Marcar conversación como activa
document.querySelectorAll('.conversation-item').forEach(item => {
item.classList.remove('active');
});
document.querySelector(`[data-user-id="${userId}"]`)?.classList.add('active');
// Actualizar estado del toggle del bot según datos de la conversación (ya obtuvimos conv arriba)
if (conv) {
const btn = document.getElementById('bot-toggle');
const holdIndicator = document.getElementById('hold-indicator');
const releaseBtn = document.getElementById('release-hold-btn');
const markUnreadBtn = document.getElementById('mark-unread-btn');
// On mobile, hide the sidebar when opening a conversation so chat occupies full screen
const sidebar = document.querySelector('.chat-sidebar');
if (sidebar && window.innerWidth <= 768) {
sidebar.classList.add('mobile-hidden');
}
if (markUnreadBtn) {
markUnreadBtn.style.display = 'inline-block';
markUnreadBtn.onclick = async () => {
try {
const resp = await fetch('api/mark_conversation_unread.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ user_id: userId })
});
if (resp.status === 401) {
alert('Sesión expirada. Por favor inicie sesión de nuevo.');
return;
}
const json = await resp.json();
if (json && json.success) {
// 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');
}
} catch (e) {
console.error('Error marking conversation unread', e);
alert('Error marcando conversación como no leída');
}
};
}
if (holdIndicator) {
if (conv.on_hold) {
holdIndicator.textContent = 'EN ESPERA';
holdIndicator.style.color = '#b85';
holdIndicator.style.display = 'inline';
} else if (conv.advisor_requested) {
holdIndicator.textContent = 'SOLICITUD PENDIENTE';
holdIndicator.style.color = '#f39c12';
holdIndicator.style.display = 'inline';
} else {
holdIndicator.style.display = 'none';
}
} else {
console.warn('holdIndicator element not found in DOM');
}
if (releaseBtn) {
// show button only when conversation is explicitly 'on_hold' (do NOT show during advisor request)
releaseBtn.style.display = (conv.on_hold) ? 'inline-block' : 'none';
releaseBtn.onclick = async () => {
try {
const resp = await fetch('api/release_hold.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ user_id: userId })
});
if (resp.status === 401) {
alert('Sesión expirada. Por favor inicie sesión de nuevo.');
return;
}
const json = await resp.json();
if (json && json.success) {
conv.on_hold = false;
conv.advisor_requested = 0;
if (holdIndicator) {
holdIndicator.style.display = 'none';
} else {
console.warn('holdIndicator missing when releasing hold');
}
releaseBtn.style.display = 'none';
// show bot toggle again
if (btn) btn.style.display = 'inline-block';
await this.loadConversations();
} else {
alert('Error al liberar la espera');
}
} catch (e) {
console.error('Error releasing hold', e);
alert('Error liberando espera');
}
};
// Auxiliary attend button is hidden: we use the main Atender/Finalizar control and show status in-chat
try {
const attendToggleBtn = document.getElementById('attend-toggle-btn');
if (attendToggleBtn) {
attendToggleBtn.style.display = 'none';
attendToggleBtn.onclick = null;
}
if (conv && conv.advisor_requested && !conv.in_service) {
try { this.showStatusMessageInChat(userId, 'Solicitud de asesor pendiente'); } catch(e) { /* ignore */ }
}
} catch (e) { console.warn('ensure attend button visibility failed', e); }
// Render attend controls (single toggle + status) — compatible con comportamiento de chat_window.php
const attendToggleBtn = document.getElementById('attend-toggle-btn');
const attendStatus = document.getElementById('attend-status');
const renderAttendControls = () => {
if (!attendToggleBtn || !attendStatus) return;
// Hide auxiliary attend button to avoid having two 'Finalizar' controls
attendToggleBtn.style.display = 'none';
attendToggleBtn.onclick = null;
attendStatus.textContent = '';
// refresh conv reference
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 => {
const s = serverState || { in_service: c.in_service, advisor_requested: c.advisor_requested };
if (s.in_service) {
try { this.showStatusMessageInChat(userId, 'Un asesor te está atendiendo'); } catch(e) {}
} else if (s.advisor_requested || c.advisor_requested) {
try { this.showStatusMessageInChat(userId, 'Solicitud de asesor pendiente'); } catch(e) {}
}
}).catch(e => {
if (c.in_service) {
try { this.showStatusMessageInChat(userId, 'Un asesor te está atendiendo'); } catch(e) {}
} else if (c.advisor_requested) {
try { this.showStatusMessageInChat(userId, 'Solicitud de asesor pendiente'); } catch(e) {}
}
});
};
const toggleAttend = async () => {
try {
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);
const currentlyInService = serverState ? !!serverState.in_service : !!c.in_service;
if (currentlyInService) {
if (!confirm('¿Confirmas finalizar la atención?')) return;
const resp = await this.apiCall('finish_attend.php', { body: { user_id: userId } });
if (resp && resp.success) {
showAlert('Finalizada la atención', 'success');
c.in_service = false;
c.advisor_requested = 0;
// hide hold indicator
if (holdIndicator) holdIndicator.style.display = 'none';
// release_hold is performed by finish_attend.php; avoid extra client-side call to prevent duplicate notifications.
// actualizar cache local
this._userStateCache = this._userStateCache || {};
this._userStateCache[userId] = { state: { in_service: false, advisor_requested: false, on_hold: false }, ts: Date.now() };
await this.loadConversations();
await this.loadMessages(userId, true);
} else {
throw new Error(resp && resp.error ? resp.error : 'Error finalizando');
}
} else {
if (!confirm('¿Confirmas tomar la atención de esta conversación?')) return;
const resp = await this.apiCall('attend.php', { body: { user_id: userId } });
if (resp && resp.success) {
showAlert('Atención iniciada', 'success');
c.in_service = true;
c.advisor_requested = 0;
if (holdIndicator) {
holdIndicator.textContent = 'EN SERVICIO';
holdIndicator.style.color = '#28a745';
holdIndicator.style.display = 'inline';
}
// hide bot toggle while in service
if (btn) btn.style.display = 'none';
// Add status message in chat to inform the user
try { this.showStatusMessageInChat(userId, 'Un asesor te está atendiendo'); } catch(e) { console.warn('showStatusMessageInChat failed', e); }
// actualizar cache local
this._userStateCache = this._userStateCache || {};
this._userStateCache[userId] = { state: { in_service: true, advisor_requested: false, on_hold: false }, ts: Date.now() };
await this.loadConversations();
await this.loadMessages(userId, true);
} else {
throw new Error(resp && resp.error ? resp.error : 'Error iniciando atención');
}
}
} catch (err) {
console.error('Error toggling attend', err);
showAlert('Error al cambiar estado de atención: ' + (err.message || err), 'danger');
} finally {
// re-render controls after any operation
renderAttendControls();
}
};
if (attendToggleBtn) {
// Auxiliary button intentionally disabled; main control handles attend/finish
attendToggleBtn.style.display = 'none';
attendToggleBtn.onclick = null;
}
// inicializar estado
renderAttendControls();
}
if (btn) {
// Usar este botón como control único de Atender / Finalizar
btn.textContent = conv.in_service ? 'Finalizar' : 'Atender';
btn.title = conv.in_service ? 'Finalizar atención' : 'Atender';
// Estilos según estado
btn.classList.toggle('btn-light', !!conv.in_service);
btn.classList.toggle('btn-outline-light', !conv.in_service);
// Mostrar el botón por defecto (se oculta en casos puntuales de on_hold si se desea)
btn.style.display = 'inline-block';
btn.disabled = false;
btn.onclick = async () => {
try {
btn.disabled = true;
// Si ya está en servicio -> finalizar
if (conv.in_service) {
if (!confirm('¿Confirmas finalizar la atención?')) { btn.disabled = false; return; }
const resp = await this.apiCall('finish_attend.php', { body: { user_id: userId } });
if (resp && resp.success) {
showAlert('Finalizada la atención', 'success');
conv.in_service = false;
conv.advisor_requested = 0;
conv.bot_enabled = true;
if (holdIndicator) holdIndicator.style.display = 'none';
// release_hold is performed by finish_attend.php; avoid extra client-side call to prevent duplicate notifications.
// actualizar cache local
this._userStateCache = this._userStateCache || {};
this._userStateCache[userId] = { state: { in_service: false, advisor_requested: false, on_hold: false }, ts: Date.now() };
await this.loadConversations();
await this.loadMessages(userId, true);
} else {
throw new Error(resp && resp.error ? resp.error : 'Error finalizando');
}
} else {
// Tomar la atención
if (!confirm('¿Confirmas tomar la atención de esta conversación?')) { btn.disabled = false; return; }
const resp = await this.apiCall('attend.php', { body: { user_id: userId } });
if (resp && resp.success) {
showAlert('Atención iniciada', 'success');
conv.in_service = true;
conv.advisor_requested = 0;
conv.bot_enabled = false;
if (holdIndicator) {
holdIndicator.textContent = 'EN SERVICIO';
holdIndicator.style.color = '#28a745';
holdIndicator.style.display = 'inline';
}
// Add status message in chat to inform the user
try { this.showStatusMessageInChat(userId, 'Un asesor te está atendiendo'); } catch(e) { console.warn('showStatusMessageInChat failed', e); }
// actualizar cache local
this._userStateCache = this._userStateCache || {};
this._userStateCache[userId] = { state: { in_service: true, advisor_requested: false, on_hold: false }, ts: Date.now() };
await this.loadConversations();
await this.loadMessages(userId, true);
} else {
throw new Error(resp && resp.error ? resp.error : 'Error iniciando atención');
}
}
} catch (err) {
console.error('Error toggling attend via main button', err);
showAlert('Error al cambiar estado de atención: ' + (err.message || err), 'danger');
} finally {
btn.disabled = false;
// Actualizar label y clases
btn.textContent = conv.in_service ? 'Finalizar' : 'Atender';
btn.title = conv.in_service ? 'Finalizar atención' : 'Atender';
btn.classList.toggle('btn-light', !!conv.in_service);
btn.classList.toggle('btn-outline-light', !conv.in_service);
// Re-render controls auxiliares
try { renderAttendControls(); } catch(e) { /* ignore */ }
}
};
}
}
// Cargar mensajes (con paginación)
// Scroll inmediato al fondo para que el usuario vea el final mientras cargamos el bloque completo
try {
const immedContainer = document.getElementById('chat-conversations');
if (immedContainer) {
immedContainer.scrollTop = immedContainer.scrollHeight;
// clear any pending reloads and temporarily suspend auto-refresh to avoid races
this._pendingReloadAfterScroll = false;
this._pendingReloadAfterMedia = false;
this._suspendAutoRefresh = true;
if (console && console.debug) console.debug('openConversation: scrolled to bottom and suspended auto-refresh prior to full load');
}
} catch(e) { /* ignore */ }
await this.loadMessages(userId, true, true);
// Resume auto-refresh after the load
try { this._suspendAutoRefresh = false; } catch(e) {}
// Añadir listener para scroll arriba (cargar más historial)
const chatContainer = document.getElementById('chat-conversations');
if (chatContainer) {
if (!chatContainer._infiniteScrollAdded) {
chatContainer.addEventListener('scroll', async () => {
// Mark that the user is actively scrolling/reading so we don't yank the viewport to bottom
try {
this._userScrolling = true;
if (this._userScrollTimer) clearTimeout(this._userScrollTimer);
this._userScrollTimer = setTimeout(() => {
this._userScrolling = false;
this._userScrollTimer = null;
if (this._pendingReloadAfterScroll && this.currentUserId) {
this._pendingReloadAfterScroll = false;
try { this.loadMessages(this.currentUserId, true, false).catch(e => console.warn('reload after scroll failed', e)); } catch(e) { console.warn('reload after scroll schedule failed', e); }
}
}, 1500);
} catch(e) { /* ignore */ }
if (chatContainer.scrollTop <= 60 && this.hasMoreMessages && !this.loadingMessages && this.currentUserId == userId) {
// User scrolled to top: trigger a full reload (initial=true) but don't jump to bottom (follow=false)
try { if (console && console.debug) console.debug('User scrolled to top - triggering full load (follow=false)'); } catch(e) {}
await this.loadMessages(userId, true, false);
}
// If the user scrolled near the bottom and there are staged messages, apply them
try {
const nearBottomNow = (chatContainer.scrollHeight - chatContainer.scrollTop - chatContainer.clientHeight) < 150;
if (nearBottomNow && this._stagedMessages && this._stagedMessages.length) {
this.applyStagedMessages();
}
} catch(e) { /* ignore */ }
});
chatContainer._infiniteScrollAdded = true;
}
// Media playback listeners: suspend auto-refresh while media is playing and resume when it stops
if (!chatContainer._mediaListenersAdded) {
const onMediaPlay = (ev) => {
try {
this._suspendAutoRefresh = true;
console.debug('Media play detected, suspending periodic reload');
} catch (e) { /* ignore */ }
};
const onMediaEnd = async (ev) => {
try {
this._suspendAutoRefresh = false;
console.debug('Media paused/ended, resuming periodic reload');
if (this._pendingReloadAfterMedia && this.currentUserId) {
this._pendingReloadAfterMedia = false;
await this.loadMessages(this.currentUserId, true, false);
}
} catch (e) { console.warn('media end handler failed', e); }
};
chatContainer.addEventListener('play', onMediaPlay, true);
chatContainer.addEventListener('pause', onMediaEnd, true);
chatContainer.addEventListener('ended', onMediaEnd, true);
chatContainer._mediaListenersAdded = true;
}
}
// Cargar respuestas rápidas y plantillas
await this.loadQuickReplies();
// templates loaded into select by loadQuickReplies
// show template container only if templates exist
const tplContainer = document.getElementById('templateSelectContainer');
const tplSelect = document.getElementById('templateSelect');
const typeSelect = document.getElementById('message-type');
if (tplContainer && tplSelect && tplSelect.children.length) {
// If message-type selector exists, show only when 'template' is selected. Otherwise show (legacy/simple UI).
const showTpl = typeSelect ? (typeSelect.value === 'template') : true;
tplContainer.style.display = showTpl ? 'inline-block' : 'none';
}
// Initialize recording handlers so the mic works even before opening a conversation
if (typeof this.initRecordingHandlers === 'function') {
try { this.initRecordingHandlers(); } catch(e) { console.warn('initRecordingHandlers() failed', e); }
} else {
// fallback: define minimal state
this._mediaRecorder = null;
this._recordingInterval = null;
this._recordingStart = null;
}
}
async loadconversations(userId, showLoading = true) {
// Compatibilidad: ahora delegamos en loadMessages
if (showLoading) {
document.getElementById('chat-conversations').innerHTML = `
<div class="loading">
<i class="fas fa-spinner fa-spin"></i> Cargando mensajes...
</div>
`;
}
try {
await this.loadMessages(userId, true);
} catch (error) {
console.error('Error loading conversations via loadMessages:', error);
document.getElementById('chat-conversations').innerHTML = `
<div class="text-center p-4">
<i class="fas fa-exclamation-triangle text-warning"></i>
<p>Error al cargar los mensajes</p>
</div>
`;
}
}
/**
* Cargar mensajes con paginación. Si initial=true, carga el bloque más reciente; si initial=false, carga mensajes anteriores (before=this.earliestMessage).
*/
async loadMessages(userId, initial = false, follow = true) {
if (this.loadingMessages) return;
this.loadingMessages = true;
// Mark a full load is in progress to avoid race with delta polling
this._fullLoadInProgress = true;
const container = document.getElementById('chat-conversations');
// Guardar posición de scroll y determinar si el usuario estaba en el fondo
let prevScrollHeight = container ? container.scrollHeight : 0;
let prevScrollTop = container ? container.scrollTop : 0;
const wasAtBottom = container ? ((container.scrollHeight - container.scrollTop - container.clientHeight) < 150) : true;
// indicador top cuando cargamos anteriores
let topLoader = null;
if (!initial && container) {
container.insertAdjacentHTML('afterbegin', `<div class="loading loading-top" style="text-align:center; padding:8px; font-size:12px;">Cargando mensajes anteriores...</div>`);
topLoader = container.querySelector('.loading-top');
}
try {
// Not sending 'before' param due to unstable server behavior in some cases.
// The server-side pagination/fallback and client-side dedupe should handle continuity.
// If we prefetched data (e.g. from notification click), use it to avoid double fetch
let data = null;
if (initial && this._preloadedMessages && this._preloadedMessages.userId == userId && this._preloadedMessages.resp) {
data = this._preloadedMessages.resp;
// clear preloaded cache
this._preloadedMessages = null;
if (console && console.debug) console.debug('Using preloaded messages for user', userId);
} else {
let url = `get_user_messages.php?user_id=${userId}&limit=${this.messageLimit}`;
data = await this.apiCall(url);
}
if (!data) throw new Error('No autorizado o error en la petición');
let messages = [];
let hasMore = false;
let earliest = null;
let earliestId = null; // local var for earliest message id
if (data && data.success && Array.isArray(data.data)) {
messages = data.data;
// Filtrar mensajes de tipo 'reaction' para que no aparezcan como elementos en la conversación
messages = messages.filter(m => !(m && m.message_type === 'reaction'));
hasMore = !!data.has_more;
earliest = data.earliest || (messages[0] && messages[0].created_at) || null;
earliestId = data.earliest_id || (messages[0] && messages[0].id) || null;
} else if (Array.isArray(data)) {
messages = data;
messages = messages.filter(m => !(m && m.message_type === 'reaction'));
}
// Filtrar mensajes vacíos (sin texto y sin media) para evitar que aparezcan placeholders "[Mensaje vacío]"
if (Array.isArray(messages) && messages.length > 0) {
const beforeCount = messages.length;
messages = messages.filter(m => {
const content = (typeof m.content !== 'undefined' && m.content !== null) ? String(m.content).trim() : '';
const hasContent = content.length > 0;
const hasMedia = !!(m.local_thumb || m.local_file || m.media_url_external || m.media_url);
const nonText = m.message_type && m.message_type !== 'text';
if (!hasContent && !hasMedia && !nonText) {
// Drop purely empty text message
if (console && console.warn) console.warn('Dropping empty message in pagination:', m && (m.id || m.message_id), m && m.created_at);
return false;
}
return true;
});
const dropped = beforeCount - messages.length;
if (dropped > 0) {
// Indicar de forma no intrusiva en caso de debug o visibilidad limitada
if (typeof showAlert === 'function') {
showAlert(dropped + ' mensajes vacíos omitidos', 'warning');
} else {
if (console && console.info) console.info('Omitidos ' + dropped + ' mensajes vacíos durante paginación');
}
}
}
// Guard: cuando paginamos (initial=false) y la respuesta no trae mensajes,
// no reemplazar ni limpiar la conversación actual; solo indicar que no hay más.
if (!initial && Array.isArray(messages) && messages.length === 0) {
try { if (topLoader && topLoader.parentNode) topLoader.remove(); } catch(e) {}
// Asegurar que no quedemos en estado de loading
this.loadingMessages = false;
this.hasMoreMessages = false;
// show hint and preserve current view
this.showNoMoreMessagesHint();
// preserve current view and stop further processing
return;
}
// Si es carga inicial y el servidor devolvió vacío, pero ya teníamos mensajes, preservarlos.
if (initial && Array.isArray(messages) && messages.length === 0 && Array.isArray(this.currentMessages) && this.currentMessages.length > 0) {
try { if (topLoader && topLoader.parentNode) topLoader.remove(); } catch(e) {}
// Ensure container visibility is restored if it was hidden during render
try { if (container && container.dataset && container.dataset.renderHidden) { container.style.visibility = 'visible'; delete container.dataset.renderHidden; } } catch(e) {}
this.loadingMessages = false;
if (typeof showAlert === 'function') showAlert('No se pudieron recargar mensajes; manteniendo historial actual', 'warning');
console.warn('Initial load returned empty but existing view contains messages - preserving current conversation');
return;
}
if (initial) {
// Reemplazar sólo en carga inicial válida (puede estar vacía si no hay nada en DB)
this.currentMessages = messages;
} else {
// Prepend sólo si hay mensajes nuevos
if (Array.isArray(messages) && messages.length > 0) {
// Evitar introducir duplicados que ya existen en la vista actual
try {
const existingKeys = new Set((this.currentMessages || []).map(m => this.messageKey(m)));
const beforeLen = messages.length;
messages = messages.filter(m => {
const k = this.messageKey(m);
const seen = existingKeys.has(k);
if (!seen) existingKeys.add(k); // mark as seen to avoid duplicates within the batch
else {
if (console && console.debug) console.debug('Skipping duplicate message from server (already in view)', m && (m.id || m.message_id), m && m.created_at);
}
return !seen;
});
const dropped = beforeLen - messages.length;
if (dropped > 0) {
if (console && console.info) console.info(`Omitted ${dropped} messages because they already exist in view`);
}
} catch (e) { console.warn('duplicate filtering failed', e); }
// Prepend mensajes antiguos
this.currentMessages = messages.concat(this.currentMessages || []);
} else {
// No hay mensajes nuevos para añadir; nada que hacer
}
}
// Actualizar paginación (earliest timestamp y id)
if (earliest) this.earliestMessage = earliest;
if (typeof earliestId !== 'undefined' && earliestId !== null) this.earliestMessageId = earliestId;
// Update last/latest message timestamp so we can poll deltas later
try {
if (Array.isArray(this.currentMessages) && this.currentMessages.length > 0) {
const last = this.currentMessages[this.currentMessages.length - 1];
if (last && last.created_at) this._latestMessage = last.created_at;
}
} catch(e) { /* ignore */ }
// Improved dedupe: pick the most "readable" message when duplicates exist
try {
if (!this.currentMessages || this.currentMessages.length === 0) {
// Nothing to dedupe
} else {
const best = new Map(); // key => { index, score }
const toRemove = [];
const scoreFor = (m) => {
let s = 0;
if (!m) return s;
// Prefer explicit media type messages
if (m.message_type && m.message_type !== 'text') s += 50;
// Prefer messages with thumbnails or local files
if (m.local_thumb || m.local_file || m.media_url_external || m.media_url) s += 30;
// Prefer human text content (avoid raw JSON blobs)
if (m.content) {
const c = String(m.content).trim();
const looksJson = c.startsWith('{') && c.endsWith('}');
if (looksJson) {
s -= 20; // penalize raw JSON-looking content
} else {
s += 10;
if (c.length < 200) s += 5;
}
}
if (m.message_id) s += 3;
if (m.reply_to_message_id) s += 2;
return s;
};
for (let i = this.currentMessages.length - 1; i >= 0; i--) {
const m = this.currentMessages[i];
if (!m) continue;
const mid = m.message_id || m.id || null;
let key = null;
if (mid) key = String(mid);
else {
const partMedia = m.media_url || m.media_url_external || m.local_file || m.local_thumb || m.content || '';
const ts = m.created_at ? String(Math.floor(new Date(m.created_at).getTime() / 1000)) : '';
key = `${m.direction||'?'}|${m.message_type||m.media_type||'text'}|${partMedia}|${ts}`;
}
const s = scoreFor(m);
if (!best.has(key)) {
best.set(key, { index: i, score: s });
} else {
const prev = best.get(key);
if (s > prev.score) {
// keep current, remove previous
toRemove.push(prev.index);
best.set(key, { index: i, score: s });
} else {
// remove current
toRemove.push(i);
}
}
}
if (toRemove.length) {
// dedupe unique indices
const uniq = Array.from(new Set(toRemove)).sort((a,b) => a - b);
// remove from highest index down
for (let j = uniq.length - 1; j >= 0; j--) {
const idx = uniq[j];
const removed = this.currentMessages.splice(idx, 1)[0];
console.debug('Dedupe removed message at index', idx, 'removed=', removed && (removed.message_id || removed.id || removed.content || removed.media_url) );
}
}
}
} catch (e) {
console.warn('Dedupe failed', e);
}
// Actualizar estado de paginación
this.hasMoreMessages = hasMore;
// Mostrar o remover el hint "No hay más mensajes anteriores" según corresponda
if (this.hasMoreMessages) {
this.removeNoMoreMessagesHint();
} else {
this.showNoMoreMessagesHint();
}
if (earliest) this.earliestMessage = earliest;
// Renderizar incrementalmente para evitar parpadeos y preservar reproducción de media
// Si cargamos paginación (initial=false), pasar la cantidad de mensajes recién obtenidos para insertarlos al inicio
const newlyFetched = (!initial && Array.isArray(messages)) ? messages.length : 0;
if (this.currentMessages && this.currentMessages.length > 0) {
this.renderMessagesIncremental(initial, newlyFetched, follow);
} else if (initial) {
// Si es carga inicial y no tenemos mensajes, mostrar una vista vacía
const container = document.getElementById('chat-conversations');
if (container) container.innerHTML = `<div class="no-conversation"><i class="fab fa-whatsapp"></i><h4>No hay mensajes</h4><p>Envía un mensaje para iniciar la conversación.</p></div>`;
}
// Replace media rendering uses window.renderMediaMessage inside element creation/update
// Full load finished - unset flag so polling can resume
this._fullLoadInProgress = false;
if (initial) {
// Scroll to bottom SOLO si: (1) follow fue solicitado Y (2) el usuario estaba en el fondo O (3) es el primer mensaje
const shouldScrollToBottom = follow && (wasAtBottom || this.currentMessages.length === messages.length);
if (shouldScrollToBottom) {
this.scrollToBottom();
} else {
// Preservar la posición relativa del scroll
if (container && prevScrollHeight > 0) {
const scrollPercentage = prevScrollTop / prevScrollHeight;
container.scrollTop = container.scrollHeight * scrollPercentage;
}
}
// Re-show the container (we hid it before rendering to avoid jump-to-top)
try {
if (container && container.dataset && container.dataset.renderHidden) {
container.style.visibility = 'visible';
delete container.dataset.renderHidden;
}
} catch(e) { /* ignore */ }
} else if (container) {
// Mantener posición: desplazar por la diferencia de heights
const newScrollHeight = container.scrollHeight;
container.scrollTop = newScrollHeight - prevScrollHeight + prevScrollTop;
}
// remover loader top si existía
if (topLoader && topLoader.parentNode) topLoader.remove();
// Marcar como leídos (comportamiento previo: marcar todos los entrantes como leídos al abrir)
if (initial) {
try {
await this.apiCall('mark_conversation_read.php', { method: 'POST', body: { user_id: userId } });
// Recargar lista de conversaciones para refrescar contadores
await this.loadConversations();
} catch (e) {
console.error('Error marking conversation as read', e);
}
}
} catch (error) {
console.error('Error en loadMessages:', error);
// Si la carga es paginada (no "initial"), preservamos la vista actual en vez de borrar el DOM.
if (!initial) {
try { if (topLoader && topLoader.parentNode) topLoader.remove(); } catch (e) {}
this.hasMoreMessages = false;
// Mostrar hint visual
this.showNoMoreMessagesHint();
// Notificar de forma no intrusiva
if (typeof showAlert === 'function') {
showAlert('No se pudieron cargar mensajes anteriores: ' + (error.message || error), 'warning');
} else {
console.warn('No se pudieron cargar mensajes anteriores:', error);
}
} else {
// En cargas iniciales mostramos el mensaje de error y botón de reintento (comportamiento previo)
if (container) {
// Make sure it's visible again
try { if (container && container.dataset && container.dataset.renderHidden) { container.style.visibility = 'visible'; delete container.dataset.renderHidden; } } catch(e) {}
container.innerHTML = `
<div class="text-center p-4">
<i class="fas fa-exclamation-triangle text-warning fa-2x"></i>
<p class="mt-3">Error al cargar los mensajes: ${error.message || error}</p>
<button class="btn btn-sm btn-primary" id="retry-load-messages">Reintentar</button>
</div>
`;
const retryBtn = document.getElementById('retry-load-messages');
if (retryBtn) retryBtn.addEventListener('click', async () => { await this.loadMessages(userId, true); });
}
}
} finally {
this.loadingMessages = false;
// Ensure the full-load-in-progress flag is cleared even on errors/early returns
try { this._fullLoadInProgress = false; } catch(e) { /* noop */ }
if (console && console.debug) console.debug('loadMessages completed, _fullLoadInProgress cleared');
}
}
// Incremental rendering to avoid DOM replacement that interrupts media playback or scroll
renderMessagesIncremental(initial = false, prependCount = 0, follow = true) {
const container = document.getElementById('chat-conversations');
if (!container) return;
// Safety: remove any empty text-only messages that may have slipped into the model
try {
const before = this.currentMessages ? this.currentMessages.length : 0;
if (Array.isArray(this.currentMessages) && this.currentMessages.length > 0) {
this.currentMessages = this.currentMessages.filter(m => !this.isEmptyMessage(m));
const removed = before - this.currentMessages.length;
if (removed > 0 && console && console.info) console.info('Removed', removed, 'empty messages before render');
}
} catch (e) { console.warn('Failed to trim empty messages before render', e); }
if (initial) {
// Hide the container while we rebuild the DOM to avoid a visible jump to the top
try { container.style.visibility = 'hidden'; container.dataset.renderHidden = '1'; } catch(e) {}
container.innerHTML = '';
}
// Snapshot scroll metrics to preserve user visual position when updating DOM
// prevScrollHeight: total height BEFORE we mutate the DOM
// prevScrollTop: scrollTop BEFORE update (so we can reapply offset)
const prevScrollHeight = container.scrollHeight;
const prevScrollTop = container.scrollTop;
// wasNearBottomBefore: were we following the bottom before update?
const wasNearBottomBefore = (prevScrollHeight - prevScrollTop - container.clientHeight) < 150;
// Map existing nodes
const existing = new Map();
Array.from(container.querySelectorAll('[data-message-id]')).forEach(el => {
existing.set(String(el.dataset.messageId), el);
});
// Compute maximum timestamp among existing DOM messages. This helps detect
// which incoming messages are strictly newer than what the user currently sees.
let maxExistingTs = 0;
existing.forEach(el => {
try {
const d = el.dataset && el.dataset.createdAt ? Date.parse(el.dataset.createdAt) : 0;
if (d && d > maxExistingTs) maxExistingTs = d;
} catch (e) { /* ignore parse errors */ }
});
// helper: format date separators and compare same day
const sameDay = (a,b) => {
try {
const da = new Date(a); const db = new Date(b);
return da.getFullYear() === db.getFullYear() && da.getMonth() === db.getMonth() && da.getDate() === db.getDate();
} catch (e) { return false; }
};
const formatDateLabel = (d) => {
try {
const date = new Date(d);
const today = new Date();
const y = new Date(); y.setDate(today.getDate()-1);
if (sameDay(date, today)) return 'Hoy';
if (sameDay(date, y)) return 'Ayer';
// within last 7 days -> weekday name
const diff = Math.floor((today - date) / (1000*60*60*24));
if (diff < 7) return date.toLocaleDateString('es-ES', { weekday: 'long', day: '2-digit', month: '2-digit' });
return date.toLocaleDateString('es-ES', { day: '2-digit', month: '2-digit', year: 'numeric' });
} catch(e) { return ''; }
};
const prependEls = []; // elements to insert at top if we loaded older messages
// calculate near-bottom late to decide scrolling after DOM updates (avoids stale value)
for (let i = 0; i < this.currentMessages.length; i++) {
const msg = this.currentMessages[i];
const mid = String(msg.message_id || msg.id || ('local_' + i));
const existingEl = existing.get(mid);
// Skip truly empty messages (safety net) and remove any existing placeholder nodes
if (this.isEmptyMessage(msg)) {
if (existingEl && existingEl.parentNode) {
existingEl.parentNode.removeChild(existingEl);
existing.delete(mid);
if (console && console.info) console.info('Removed empty DOM message', mid);
}
continue;
}
if (existingEl) {
// update in place
try {
const rp = existingEl.querySelector('.reply-preview');
if (msg.reply_to_message_id) {
const previewSrc = (this.currentMessages.find(m => m.message_id == msg.reply_to_message_id) || {}).content || ('Mensaje ' + msg.reply_to_message_id);
if (rp) rp.textContent = 'En respuesta a: ' + previewSrc.substring(0,140);
else {
const div = document.createElement('div'); div.className = 'reply-preview'; div.textContent = 'En respuesta a: ' + previewSrc.substring(0,140); existingEl.insertBefore(div, existingEl.firstChild);
}
} else if (rp) rp.remove();
const body = existingEl.querySelector('.message-content');
if (body) {
const mediaPresent = (msg.local_thumb || msg.local_file || msg.media_url_external || msg.media_url);
const existingMedia = body.querySelector('audio,video,img');
let existingSrc = null;
if (existingMedia) existingSrc = existingMedia.getAttribute('src') || existingMedia.getAttribute('data-src');
const newMediaUrl = mediaPresent ? (msg.local_thumb || msg.local_file || msg.media_url_external || msg.media_url) : null;
// If media url unchanged, keep existing element (preserve playback)
if (existingMedia && newMediaUrl && existingSrc && String(existingSrc).includes(newMediaUrl)) {
// nothing to change
} else if (existingMedia && newMediaUrl && (!existingSrc || !String(existingSrc).includes(newMediaUrl))) {
// Replace src but preserve playback state for audio/video
try {
const tag = existingMedia.tagName && existingMedia.tagName.toLowerCase();
if (tag === 'audio' || tag === 'video') {
const currentTime = existingMedia.currentTime || 0;
const wasPaused = existingMedia.paused;
existingMedia.src = newMediaUrl;
existingMedia.addEventListener('loadedmetadata', () => {
try {
if (typeof existingMedia.duration === 'number' && !isNaN(existingMedia.duration)) {
existingMedia.currentTime = Math.min(currentTime, existingMedia.duration || currentTime);
}
} catch (e) { /* ignore */ }
try { if (!wasPaused) existingMedia.play().catch(()=>{}); } catch(e){}
}, { once: true });
} else {
// image or other: just replace src
try { existingMedia.src = newMediaUrl; } catch(e) { body.innerHTML = window.renderMediaMessage ? window.renderMediaMessage(msg) : window.escapeHtml(msg.content || '[Mensaje]'); }
}
} catch (e) {
console.warn('preserve media update failed', e);
body.innerHTML = window.renderMediaMessage ? window.renderMediaMessage(msg) : window.escapeHtml(msg.content || '[Mensaje]');
}
} else if (!existingMedia && newMediaUrl) {
// No existing media element, render new media block without touching other parts
try {
body.innerHTML = window.renderMediaMessage ? window.renderMediaMessage(msg) : window.escapeHtml(msg.content || '[Mensaje]');
// If after inserting media/text the body appears empty and there's no media element, replace with placeholder
try {
const hasMediaNode = !!body.querySelector && !!body.querySelector('img,video,audio,source,.message-media');
if ((body.textContent || '').trim() === '' && !hasMediaNode) {
body.innerHTML = '<em>(Mensaje sin texto)</em>';
console.warn('Post-update: media/text produced empty body, set placeholder for', msg && (msg.id || msg.message_id));
}
} catch (e) { /* ignore DOM query errors */ }
} catch(e) { /* ignore */ }
} else if (existingMedia && !newMediaUrl) {
// Media removed in new message: replace body with text/content
try { body.innerHTML = window.escapeHtml(msg.content || '[Mensaje]'); } catch(e) { /* ignore */ }
}
}
const timeEl = existingEl.querySelector('.message-time');
if (timeEl && msg.created_at) {
const full = new Date(msg.created_at).toLocaleString('es-ES');
const short = new Date(msg.created_at).toLocaleTimeString('es-ES', { hour: '2-digit', minute: '2-digit' });
timeEl.textContent = short;
timeEl.dataset.full = full;
timeEl.dataset.short = short;
// preserve a machine-readable timestamp on the element so we can detect newer messages
try { if (existingEl) existingEl.dataset.createdAt = msg.created_at; } catch(e) {}
if (!timeEl._hasToggleListener) {
timeEl.addEventListener('click', (e) => {
e.stopPropagation();
if (timeEl.dataset._toggled === '1') {
timeEl.textContent = timeEl.dataset.short;
timeEl.dataset._toggled = '0';
} else {
timeEl.textContent = timeEl.dataset.full;
timeEl.dataset._toggled = '1';
setTimeout(() => {
if (timeEl.dataset._toggled === '1') {
timeEl.textContent = timeEl.dataset.short;
timeEl.dataset._toggled = '0';
}
}, 4000);
}
});
timeEl._hasToggleListener = true;
}
} else if (timeEl) {
timeEl.textContent = '';
}
const statusEl = existingEl.querySelector('.message-status');
if (statusEl) statusEl.innerHTML = this.getStatusIcon(msg.status);
const reactBadge = existingEl.querySelector('.reaction-badge');
if (msg.reaction_emoji) {
if (reactBadge) reactBadge.textContent = msg.reaction_emoji; else {
const b = document.createElement('div'); b.className = 'reaction-badge'; b.textContent = msg.reaction_emoji; const bubble = existingEl.querySelector('.message-bubble') || existingEl; bubble.insertBefore(b, bubble.querySelector('.message-actions'));
}
} else if (reactBadge) reactBadge.remove();
} catch (e) { console.warn('update message failed', e); }
existing.delete(mid);
} else {
// create new element
try {
// If this message is strictly newer than anything already in the DOM and the user was
// not near the bottom (i.e., reading history), stage it instead of appending to avoid
// moving the user's viewport.
try {
const msgTs = msg.created_at ? Date.parse(msg.created_at) : 0;
const isNewerThanExisting = msgTs && (msgTs > maxExistingTs);
if (isNewerThanExisting && !wasNearBottomBefore && !this._userScrolling) {
this._stagedMessageIds.add(mid);
this._stagedCount = this._stagedMessageIds.size;
this.showNewMessagesIndicator(this._stagedCount);
// skip creation for now; message will be rendered when user clicks the indicator
continue;
}
} catch (e) { /* ignore staging errors */ }
// insert date separator if this message is the first of a new day
const prev = (i>0) ? this.currentMessages[i-1] : null;
if (!prev || !sameDay(prev.created_at, msg.created_at)) {
const sep = document.createElement('div');
sep.className = 'date-sep';
sep.textContent = formatDateLabel(msg.created_at || Date.now());
// For prepended older messages, collect to insert at top
if (prependCount && i < prependCount) prependEls.push(sep);
else container.appendChild(sep);
}
const div = document.createElement('div');
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) {
const target = this.currentMessages.find(m => m.message_id == msg.reply_to_message_id || m.id == msg.reply_to_message_id);
const previewText = target ? (target.content || target.message_text || '').substring(0,140) : ('Mensaje ' + msg.reply_to_message_id);
replyHtml = `<div class="reply-preview">En respuesta a: ${window.escapeHtml(previewText)}</div>`;
}
const mediaPresent = (msg.local_thumb || msg.local_file || msg.media_url_external || msg.media_url);
// Try to render system-style messages when the message content is a JSON payload
let content = '';
let isSystemJson = false;
try {
const raw = (typeof msg.content === 'string') ? msg.content.trim() : '';
if (!mediaPresent && raw && (raw.startsWith('{') || raw.startsWith('['))) {
try {
const parsed = JSON.parse(raw);
const isSys = parsed && (parsed.type === 'usersentdocuments' || parsed.system === 'usersentdocuments' || parsed.type === 'attention' || parsed.system === 'attention' || parsed.attention);
if (isSys) {
isSystemJson = true;
const title = parsed.title || parsed.message || parsed.text || parsed.summary || 'Documentos recibidos';
const msgText = parsed.message || parsed.text || parsed.summary || '';
const fileName = parsed.file_name || parsed.filename || parsed.file || null;
const fileUrl = parsed.file_url || parsed.url || null;
const fileSize = parsed.file_size || parsed.size || null;
const parts = [];
parts.push(`<div class="message-template system" style="display:flex; gap:8px; align-items:center">`);
parts.push(`<span class="mt-icon">📎</span>`);
parts.push(`<div style="flex:1">`);
parts.push(`<div style="font-weight:700; margin-bottom:6px">${escapeHtml(String(title))}</div>`);
if (msgText) parts.push(`<div style="font-size:13px; color:#444">${escapeHtml(String(msgText))}</div>`);
if (fileName) {
parts.push(`<div style="margin-top:8px; display:flex; gap:8px; align-items:center;">
<i class="${getFileIcon(fileName)}"></i>
<div style="display:flex; flex-direction:column;">
<div class="document-name">${escapeHtml(fileName)}</div>
<div class="document-size text-muted" style="font-size:12px">${fileSize ? this.formatFileSize(Number(fileSize)) : ''}</div>
</div>
</div>`);
}
parts.push(`</div>`);
parts.push(`<div style="margin-left:8px; display:flex; gap:6px; align-items:center">`);
if (fileUrl) {
parts.push(`<a href="${escapeHtml(fileUrl)}" target="_blank" class="btn btn-sm btn-outline-primary">Abrir</a>`);
parts.push(`<button class="btn btn-sm btn-primary download-doc-btn">Descargar</button>`);
}
parts.push(`</div>`);
parts.push(`</div>`);
content = parts.join('');
}
} catch (e) {
// not JSON or parse error — fall back to normal rendering
}
}
} catch (e) { /* ignore */ }
if (!isSystemJson) {
content = mediaPresent
? (window.renderMediaMessage ? window.renderMediaMessage(msg) : (window.formatMessageContent ? window.formatMessageContent(msg.content || '[Mensaje]') : window.escapeHtml(msg.content || '[Mensaje]')))
: (window.formatMessageContent ? window.formatMessageContent(msg.content || '') : window.escapeHtml(msg.content || ''));
// If after formatting the content is empty (spaces or nothing), show a clearer placeholder.
// But allow pure media-only content (images/audio/video) to render instead of being treated as empty.
try {
const tmp = (content || '').replace(/<[^>]*>/g, '').trim(); // strip tags and check
const hasMediaTag = /<(img|audio|video|source|a[^>]*download|div\s+class=["']?message-media)/i.test(content || '');
if (!tmp && !hasMediaTag) {
console.warn('Rendered message content empty, replacing with placeholder', mid, msg.id, msg.created_at);
content = '<em>(Mensaje sin texto)</em>';
}
} catch (e) { /* ignore */ }
} else {
// If it is the system JSON, we may want to highlight it briefly after insertion later
}
const time = msg.created_at ? new Date(msg.created_at).toLocaleTimeString('es-ES', { hour: '2-digit', minute: '2-digit' }) : '';
const statusIcon = this.getStatusIcon(msg.status);
const reactionHtml = msg.reaction_emoji ? `<div class="reaction-badge">${msg.reaction_emoji}</div>` : '';
div.innerHTML = `
<div class="message-bubble">
${replyHtml}
<div class="message-content">${content}</div>
${reactionHtml}
<div class="message-actions mt-1">
<button class="btn btn-sm btn-link" onclick="chat.promptReply('${window.escapeHtml(msg.message_id || msg.id || '')}')" title="Responder"><i class="fas fa-reply"></i></button>
<button class="btn btn-sm btn-link" onclick="chat.openReactionPicker(event, '${window.escapeHtml(msg.message_id || msg.id || '')}')" title="Reaccionar"><i class="far fa-grin"></i></button>
</div>
<div class="message-time">${time} ${msg.direction === 'outgoing' ? `<span class="message-status">${statusIcon}</span>` : ''}</div>
</div>
`;
// For prepended older messages, collect to insert at top later
if (prependCount && i < prependCount) {
prependEls.push(div);
} else {
container.appendChild(div);
}
// attach small behavior: allow tapping the time to reveal full date/time briefly
try {
const timeEl = div.querySelector('.message-time');
if (timeEl && msg.created_at) {
const full = new Date(msg.created_at).toLocaleString('es-ES');
const short = new Date(msg.created_at).toLocaleTimeString('es-ES', { hour: '2-digit', minute: '2-digit' });
timeEl.dataset.full = full;
timeEl.dataset.short = short;
timeEl.addEventListener('click', (e) => {
e.stopPropagation();
if (timeEl.dataset._toggled === '1') {
timeEl.textContent = timeEl.dataset.short;
timeEl.dataset._toggled = '0';
} else {
timeEl.textContent = timeEl.dataset.full;
timeEl.dataset._toggled = '1';
setTimeout(() => {
if (timeEl.dataset._toggled === '1') {
timeEl.textContent = timeEl.dataset.short;
timeEl.dataset._toggled = '0';
}
}, 4000);
}
});
}
} catch (e) { /* ignore */ }
// Attach handlers for system-like messages rendered from JSON (view/download buttons + highlight)
try {
const raw = (typeof msg.content === 'string') ? msg.content.trim() : '';
if (raw && (raw.startsWith('{') || raw.startsWith('['))) {
const parsed = JSON.parse(raw);
const isSys = parsed && (parsed.type === 'usersentdocuments' || parsed.system === 'usersentdocuments' || parsed.type === 'attention' || parsed.system === 'attention' || parsed.attention);
if (isSys) {
const bubble = div.querySelector('.message-template.system') || div.querySelector('.message-template');
if (bubble) {
const viewBtn = bubble.querySelector('.view-docs-btn');
const downloadBtn = bubble.querySelector('.download-doc-btn');
const fileUrl = parsed.file_url || parsed.url || null;
const userId = parsed.user_id || parsed.data && parsed.data.user_id || null;
if (downloadBtn) downloadBtn.addEventListener('click', () => {
if (fileUrl) {
const a = document.createElement('a'); a.href = fileUrl; a.download = parsed.file_name || ''; document.body.appendChild(a); a.click(); a.remove();
}
});
// brief visual highlight
bubble.classList.add('system-highlight');
setTimeout(() => bubble.classList.remove('system-highlight'), 4200);
}
}
}
} catch (e) { /* ignore */ }
} catch (e) { console.warn('create message failed', e); }
}
}
// insert prepended elements (keep order)
if (prependEls.length) {
const frag = document.createDocumentFragment();
prependEls.forEach(e => frag.appendChild(e));
container.insertBefore(frag, container.firstChild);
}
// remove any remaining old elements that weren't updated
existing.forEach((el) => el.remove());
// After DOM changes, compute new heights and preserve user's visual position when appropriate
const newScrollHeight = container.scrollHeight;
const scrollDelta = newScrollHeight - prevScrollHeight; // positive when content grew
if (initial) {
if (follow) {
// Initial load and follow requested: show latest messages
this.scrollToBottom();
} else {
// Initial load but DO NOT follow: preserve visual offset like we do for non-initial updates
if (!wasNearBottomBefore && !this._userScrolling) {
const newTop = Math.max(0, prevScrollTop + scrollDelta);
container.scrollTop = newTop;
} else {
const nearBottomNow = (newScrollHeight - container.scrollTop - container.clientHeight) < 150;
if (nearBottomNow && !this._userScrolling) {
this.scrollToBottom();
}
}
}
} else {
// If the user was reading history (not near bottom before) and is not actively scrolling,
// keep the viewport anchored to the same messages by adjusting scrollTop by the delta.
if (!wasNearBottomBefore && !this._userScrolling) {
// Keep the same visual offset (don't jump)
const newTop = Math.max(0, prevScrollTop + scrollDelta);
container.scrollTop = newTop;
} else {
// If we were near bottom and still not actively scrolling, follow new messages
const nearBottomNow = (newScrollHeight - container.scrollTop - container.clientHeight) < 150;
if (nearBottomNow && !this._userScrolling) {
this.scrollToBottom();
}
// Otherwise (user actively scrolling) do nothing and let user's action control view
}
}
}
// Mostrar / quitar hint "No hay más mensajes anteriores"
showNoMoreMessagesHint() {
try {
const container = document.getElementById('chat-conversations');
if (!container) return;
if (container.querySelector('.no-more-messages-hint')) return; // ya existe
const hint = document.createElement('div');
hint.className = 'no-more-messages-hint text-center text-muted';
hint.style.fontSize = '12px';
hint.style.padding = '6px';
hint.textContent = 'No hay más mensajes anteriores';
container.insertBefore(hint, container.firstChild);
} catch (e) { console.warn('showNoMoreMessagesHint failed', e); }
}
removeNoMoreMessagesHint() {
try {
const container = document.getElementById('chat-conversations');
if (!container) return;
const el = container.querySelector('.no-more-messages-hint');
if (el && el.parentNode) el.parentNode.removeChild(el);
} catch (e) { console.warn('removeNoMoreMessagesHint failed', e); }
}
// Helper: generate stable key for a message to detect duplicates
messageKey(m) {
if (!m) return '';
const mid = m.message_id || m.id || '';
if (mid) return String(mid);
const partMedia = m.media_url || m.media_url_external || m.local_file || m.local_thumb || (m.content || '');
const ts = m.created_at ? String(Math.floor(new Date(m.created_at).getTime() / 1000)) : '';
return `${m.direction||'?'}|${m.message_type||m.media_type||'text'}|${partMedia}|${ts}`;
}
// Helper: determine if a message is effectively empty (no text, no media, and is text type)
isEmptyMessage(m) {
if (!m) return true;
const content = (typeof m.content !== 'undefined' && m.content !== null) ? String(m.content).trim() : '';
const hasContent = content.length > 0;
const hasMedia = !!(m.local_thumb || m.local_file || m.media_url_external || m.media_url);
const nonText = m.message_type && m.message_type !== 'text';
return !(hasContent || hasMedia || nonText);
}
// Helper: detect active media playback or recording to avoid interrupting with reloads
isMediaActive() {
// Recording in progress -> active
if (this._mediaRecorder && this._mediaRecorder.state === 'recording') return true;
// Explicit suspend flag
if (this._suspendAutoRefresh) return true;
try {
const container = document.getElementById('chat-conversations');
if (!container) return false;
const mediaEls = container.querySelectorAll('audio,video');
for (const el of mediaEls) {
if (!el.paused && !el.ended) return true;
if (el.readyState > 2 && !el.paused) return true;
}
} catch (e) {
console.warn('isMediaActive: failed to inspect media elements', e);
}
return false;
}
// Initialize recording handlers (so mic works before openConversation) and helper to manage recording state
initRecordingHandlers() {
if (this._recordingHandlersInitialized) return;
this._recordingHandlersInitialized = true;
this._mediaRecorder = null;
this._recordingInterval = null;
this._recordingStart = null;
this.startRecording = async function() {
try {
// Suspend periodic reload while recording
this._suspendAutoRefresh = true;
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
this._mediaRecorder = new MediaRecorder(stream);
const chunks = [];
this._mediaRecorder.ondataavailable = (e) => { if (e.data && e.data.size) chunks.push(e.data); };
this._mediaRecorder.onstop = async () => {
const blob = new Blob(chunks, { type: 'audio/webm' });
let file = new File([blob], `record_${Date.now()}.webm`, { type: 'audio/webm' });
// Try convert if FFmpeg available
try {
if (window && window.ensureFFmpeg) {
await window.ensureFFmpeg();
const converted = await window.convertWebmToOgg(file);
file = converted;
}
} catch (convErr) { console.warn('conversion failed', convErr); }
// set as selected file and show preview
this.selectedFile = file;
this.showMediaPreview(file);
// restore mic UI to default (not recording)
try {
const micEl2 = document.getElementById('mic-btn');
if (micEl2) {
micEl2.innerHTML = micEl2.dataset._wa_prev || '<i class="fas fa-microphone"></i>';
micEl2.title = 'Grabar audio';
micEl2.classList.remove('recording-cancel');
}
} catch(e) { /* ignore */ }
// Clear recorder reference to indicate stopped
this._mediaRecorder = null;
// Resume auto-refresh and trigger a reload if one was pending
try {
this._suspendAutoRefresh = false;
if ((this._pendingReloadAfterMedia || this._pendingReloadAfterScroll) && this.currentUserId) {
this._pendingReloadAfterMedia = false;
this._pendingReloadAfterScroll = false;
await this.loadMessages(this.currentUserId, true, false);
}
} catch (e) { console.warn('Failed to resume reload after recording', e); }
};
this._mediaRecorder.start();
this._recordingStart = Date.now();
const ind = document.getElementById('recording-indicator'); if (ind) ind.style.display = 'block';
const micEl = document.getElementById('mic-btn'); if (micEl) {
// Show cancel on same button
micEl.dataset._wa_prev = micEl.innerHTML;
micEl.innerHTML = '<i class="fas fa-times"></i>';
micEl.title = 'Cancelar';
micEl.classList.add('recording');
micEl.classList.add('recording-cancel');
}
// Ensure UI shows mic and hides send while recording
if (this._updateSendMicVisibility) this._updateSendMicVisibility();
const update = () => {
if (!this._recordingStart) return;
const elapsed = Math.floor((Date.now() - this._recordingStart) / 1000);
const m = Math.floor(elapsed/60); const s = elapsed % 60;
const rt = document.getElementById('recording-time'); if (rt) rt.textContent = `${m}:${String(s).padStart(2,'0')}`;
};
update();
this._recordingInterval = setInterval(update, 1000);
} catch (err) {
console.error('startRecording error', err);
alert('No se pudo acceder al micrófono: ' + (err.message||err));
}
};
this.stopRecording = function() {
if (this._mediaRecorder && this._mediaRecorder.state !== 'inactive') {
this._mediaRecorder.stop();
}
if (this._recordingInterval) clearInterval(this._recordingInterval);
this._recordingInterval = null;
this._recordingStart = null;
const ind = document.getElementById('recording-indicator'); if (ind) ind.style.display = 'none';
const micEl = document.getElementById('mic-btn'); if (micEl) micEl.classList.remove('recording');
if (this._updateSendMicVisibility) this._updateSendMicVisibility();
// Ensure we resume periodic reloads and trigger pending reload
try {
this._suspendAutoRefresh = false;
if ((this._pendingReloadAfterMedia || this._pendingReloadAfterScroll) && this.currentUserId) {
this._pendingReloadAfterMedia = false;
this._pendingReloadAfterScroll = false;
this.loadMessages(this.currentUserId, true, false).catch(e => console.warn('Reload after stopRecording failed', e));
}
} catch (e) { console.warn('stopRecording resume failed', e); }
};
this.cancelRecording = function() {
if (this._mediaRecorder && this._mediaRecorder.state !== 'inactive') {
this._mediaRecorder.stop();
}
this._mediaRecorder = null;
this.selectedFile = null;
if (this._recordingInterval) clearInterval(this._recordingInterval);
this._recordingInterval = null;
this._recordingStart = null;
const ind = document.getElementById('recording-indicator'); if (ind) ind.style.display = 'none';
const micEl = document.getElementById('mic-btn'); if (micEl) {
micEl.classList.remove('recording');
micEl.classList.remove('recording-cancel');
micEl.innerHTML = micEl.dataset._wa_prev || '<i class="fas fa-microphone"></i>';
micEl.title = 'Grabar audio';
}
// hide delete button if present
const delBtn = document.getElementById('delete-file-btn'); if (delBtn) delBtn.style.display = 'none';
if (this._updateSendMicVisibility) this._updateSendMicVisibility();
// Resume auto-refresh if it was suspended and trigger pending reload
try {
this._suspendAutoRefresh = false;
if ((this._pendingReloadAfterMedia || this._pendingReloadAfterScroll) && this.currentUserId) {
this._pendingReloadAfterMedia = false;
this._pendingReloadAfterScroll = false;
this.loadMessages(this.currentUserId, true, false).catch(e => console.warn('Reload after cancelRecording failed', e));
}
} catch (e) { console.warn('cancelRecording resume failed', e); }
};
return true;
}
// --- New messages indicator helpers ---
showNewMessagesIndicator(count) {
try {
if (this._applyingStaged) return; // avoid toggling while applying
const container = document.getElementById('chat-area') || document.body;
let el = document.getElementById('new-messages-indicator');
if (!el) {
el = document.createElement('div');
el.id = 'new-messages-indicator';
el.style.position = 'absolute';
el.style.left = '50%';
el.style.transform = 'translateX(-50%)';
el.style.bottom = '84px';
el.style.zIndex = '1500';
el.className = 'btn btn-primary';
el.style.padding = '8px 14px';
el.style.borderRadius = '20px';
el.style.boxShadow = '0 6px 18px rgba(2,6,23,0.12)';
el.style.cursor = 'pointer';
el.onclick = async () => {
try {
// Hide indicator immediately and suspend auto-refresh to avoid races
this.hideNewMessagesIndicator();
this._suspendAutoRefresh = true;
if (this.currentUserId) {
// Trigger a full load and follow to bottom
await this.loadMessages(this.currentUserId, true, true);
}
// Clear staged messages since full load will include them
this._stagedMessages = [];
if (this._stagedMessageIds) this._stagedMessageIds.clear();
this._stagedCount = 0;
} catch (e) {
console.warn('Indicator click failed to load full conversation', e);
} finally {
try { this._suspendAutoRefresh = false; } catch(e) {}
}
};
// subtle fade-in
el.style.opacity = '0';
el.style.transition = 'opacity 220ms ease, transform 220ms ease';
container.appendChild(el);
setTimeout(() => { try { el.style.opacity = '1'; el.style.transform = 'translateX(-50%) translateY(-6px)'; } catch(e) {} }, 20);
}
const c = (typeof count === 'number') ? count : (this._stagedMessages ? this._stagedMessages.length : 0);
el.textContent = (c && c > 1) ? `Nuevos mensajes (${c})` : 'Nuevo mensaje';
el.style.display = 'inline-block';
} catch (e) { console.warn('showNewMessagesIndicator failed', e); }
}
hideNewMessagesIndicator() {
try {
// remove any instances of the indicator (handle duplicates)
const els = document.querySelectorAll('#new-messages-indicator');
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 {
// If nothing to apply, ensure indicator is gone
if (!this._stagedMessages || this._stagedMessages.length === 0) {
this.hideNewMessagesIndicator();
return;
}
// Mark as applying so we don't re-show indicator mid-apply
this._applyingStaged = true;
// Immediately hide indicator to provide instant feedback
this.hideNewMessagesIndicator();
// Append staged messages to conversation model
const toApplyCount = this._stagedMessages.length;
console.debug('applyStagedMessages: applying', toApplyCount, 'messages');
this._stagedMessages.forEach(m => { this.currentMessages.push(m); });
// clear staged storage
this._stagedMessages = [];
if (this._stagedMessageIds) this._stagedMessageIds.clear();
this._stagedCount = 0;
// Render newly added messages and follow to bottom
this.renderMessagesIncremental(false, 0, true);
this.scrollToBottom();
// Update latest timestamp
try { const last = this.currentMessages[this.currentMessages.length - 1]; if (last && last.created_at) this._latestMessage = last.created_at; } catch(e){}
} catch (e) {
console.warn('applyStagedMessages failed', e);
} finally {
// allow indicator to show again for future arrivals after short delay
setTimeout(() => { this._applyingStaged = false; }, 250);
}
}
getStatusIcon(status) {
switch (status) {
case 'sent': return '<i class="fas fa-check status-sent"></i>';
case 'delivered': return '<i class="fas fa-check-double status-delivered"></i>';
case 'read': return '<i class="fas fa-check-double status-read"></i>';
default: return '<i class="fas fa-clock status-sent"></i>';
}
}
// Update reaction badge for a single message in the DOM
updateMessageReactionInView(messageId, emoji) {
try {
const container = document.getElementById('chat-conversations');
if (!container) return;
const el = container.querySelector(`[data-message-id="${messageId}"]`);
if (!el) return;
let badge = el.querySelector('.reaction-badge');
if (emoji) {
if (badge) {
badge.textContent = emoji;
} else {
const div = document.createElement('div');
div.className = 'reaction-badge';
div.textContent = emoji;
const bubble = el.querySelector('.message-bubble');
if (bubble) bubble.insertBefore(div, bubble.querySelector('.message-actions'));
}
} else {
if (badge && badge.parentNode) badge.parentNode.removeChild(badge);
}
} catch (e) {
console.warn('updateMessageReactionInView failed', e);
}
}
async loadQuickReplies() {
const panel = document.getElementById('quick-replies-panel');
if (!panel) return;
// Clear both lists
const qrList = panel.querySelector('.qr-list');
const tplList = panel.querySelector('.tpl-list');
const qrCount = panel.querySelector('#qr-count');
const tplCount = panel.querySelector('#tpl-count');
if (qrList) qrList.innerHTML = '';
if (tplList) tplList.innerHTML = '';
if (qrCount) qrCount.textContent = '0';
if (tplCount) tplCount.textContent = '0';
try {
// Load autoresponses (text quick replies)
const respText = await apiCall('get_autoresponses.php');
if (respText && respText.success && Array.isArray(respText.data)) {
let added = 0;
respText.data.forEach(r => {
if (!r.is_active) return;
const btn = document.createElement('button');
btn.className = 'btn btn-sm btn-outline-primary';
let text = (r.response_text || '').replace(/\n/g,' ');
text = this.stripEmoji(text);
btn.textContent = text.length > 30 ? text.substring(0,30) + '...' : text;
btn.title = r.response_text;
btn.addEventListener('click', () => {
// send as text message and close panel
this.sendQuickReply(r.response_text);
panel.style.display = 'none';
document.getElementById('quick-replies-toggle').setAttribute('aria-expanded','false');
});
if (qrList) qrList.appendChild(btn);
added++;
});
if (qrCount) qrCount.textContent = String(added);
}
} catch (e) {
console.error('Error loading quick replies (text)', e);
}
try {
const resp = await apiCall('get_templates.php?approved_only=1&limit=30');
if (resp && resp.success && Array.isArray(resp.data)) {
let added = 0;
resp.data.forEach(t => {
const btn = document.createElement('button');
btn.className = 'btn btn-sm btn-outline-secondary';
let text = (t.body_text || t.name || t.template_name || '').replace(/\n/g,' ');
text = this.stripEmoji(text);
btn.textContent = text.length > 30 ? text.substring(0,30) + '...' : text;
btn.title = t.name;
btn.addEventListener('click', () => { this.sendTemplateQuick(t.template_name, t.language_code || 'es'); panel.style.display='none'; document.getElementById('quick-replies-toggle').setAttribute('aria-expanded','false'); });
if (tplList) tplList.appendChild(btn);
added++;
});
if (tplCount) tplCount.textContent = String(added);
}
} catch (e) {
console.error('Error loading quick replies (templates)', e);
}
// Also populate the template select
try { await loadTemplatesInto('#templateSelect'); } catch(e){console.warn('Could not load templates into select', e);}
// Show a small hint if there are no quick replies and no templates
const empty = panel.querySelector('#qr-empty');
const total = (qrList ? qrList.children.length : 0) + (tplList ? tplList.children.length : 0);
if (empty) empty.style.display = total ? 'none' : 'block';
// Trigger an initial filter (useful when search has an active value)
const sq = panel.querySelector('#quick-replies-search'); if (sq) sq.dispatchEvent(new Event('input'));
}
async sendTemplateQuick(templateName, language) {
// delegate to unified sendTemplateMessage helper
await this.sendTemplateMessage(templateName, language, null);
}
async sendTemplateMessage(templateName, language = 'es', parameters = null) {
if (!this.currentUserId) return;
const recipient = this.getConversationPhone(this.currentUserId);
if (!recipient) {
alert('No se pudo determinar el número de destino para la plantilla');
console.error('sendTemplateMessage: missing recipient for user', this.currentUserId);
return;
}
try {
// Build parameters: support NAMED parameters for certain templates
let paramsPayload = parameters || [];
// 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.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');
if (nameEl && nameEl.textContent) resolvedName = nameEl.textContent.trim();
}
// If no resolvedName and parameters don't include a name, prompt the operator for the name
const hasNameInParams = (paramsPayload && typeof paramsPayload === 'object' && (paramsPayload.name || (Array.isArray(paramsPayload) && paramsPayload.some(p => p.parameter_name === 'name' || p.name === 'name'))));
if (!resolvedName && !hasNameInParams) {
const inputName = prompt('Ingrese el nombre del paciente para la plantilla (dejar vacío para usar el número):');
if (inputName === null) {
// user cancelled
return;
}
resolvedName = inputName.trim() || '';
}
if (!resolvedName) resolvedName = recipient; // fallback to phone number
// Use an associative object so backend treats it as NAMED parameter
paramsPayload = { name: resolvedName };
}
const requestBody = {
recipient: recipient,
type: 'template',
template: templateName,
language: language || 'es',
parameters: paramsPayload
};
const resp = await this.apiCall('send_message.php', { body: requestBody });
// hide reply preview if any
this.hideReplyPreview();
if (resp && resp.success) {
const replyToTemplate = document.getElementById('message-input').dataset.replyTo || null;
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);
// SSE actualizará las conversaciones automáticamente
// await this.loadConversations();
} else {
throw new Error(resp && resp.error ? resp.error : 'Error enviando plantilla');
}
} catch (e) {
console.error('Error sending template message', e);
alert('Error enviando plantilla: ' + (e.message || e));
}
}
async sendQuickReply(text) {
if (!text) return;
const input = document.getElementById('message-input');
if (!input) return;
input.value = text;
await this.sendMessage();
}
scrollToBottom() {
const container = document.getElementById('chat-conversations');
container.scrollTop = container.scrollHeight;
}
// Insertar mensaje saliente en la vista inmediatamente (simular envío)
addMessageToView(content, type = 'outgoing', options = {}) {
const msg = {
message_id: 'local_' + Date.now(),
direction: type === 'outgoing' ? 'outgoing' : 'incoming',
content: content,
created_at: new Date().toISOString(),
status: 'sent'
};
// Soportar reply preview inmediato
if (options && options.reply_to_message_id) {
msg.reply_to_message_id = options.reply_to_message_id;
}
// Soportar attachments/meta si es necesario
if (options && options.message_type) {
msg.message_type = options.message_type;
}
this.currentMessages.push(msg);
this.renderMessagesIncremental();
this.scrollToBottom();
}
async showReplyPreview(messageId, type = 'reply') {
try {
const preview = document.getElementById('reply-preview');
const previewText = document.getElementById('reply-preview-text');
const cancelBtn = document.getElementById('cancel-reply-btn');
let msg = this.currentMessages.find(m => m.message_id == messageId || m.id == messageId);
const setPreviewFromMsg = (m) => {
const text = m ? (m.content || m.message_text || (m.caption || '[Mensaje]')) : ('Mensaje ' + messageId);
if (preview && previewText) {
if (type === 'reply') previewText.textContent = 'En respuesta a: ' + (String(text).replace(/\n/g,' ')).substring(0,140);
else if (type === 'forward') previewText.textContent = 'Reenviando: ' + (String(text).replace(/\n/g,' ')).substring(0,140);
preview.style.display = 'flex';
}
};
if (!msg) {
// intentar obtener mensaje por API
try {
const resp = await fetch(`api/get_message.php?message_id=${encodeURIComponent(messageId)}`);
if (resp.ok) {
const j = await resp.json();
if (j && j.success && j.data) {
msg = j.data;
}
}
} catch (err) {
console.warn('Could not fetch referenced message', err);
}
}
setPreviewFromMsg(msg);
if (cancelBtn) {
cancelBtn.onclick = () => this.hideReplyPreview();
}
} catch (e) {
console.warn('showReplyPreview failed', e);
}
}
hideReplyPreview() {
try {
const preview = document.getElementById('reply-preview');
if (preview) preview.style.display = 'none';
const input = document.getElementById('message-input');
if (input) {
input.dataset.replyTo = null;
input.dataset.forwardTo = null;
}
} catch (e) {
console.warn('hideReplyPreview failed', e);
}
}
async sendMessage() {
// If a file is selected (media preview active), delegate to sendMediaMessage to avoid duplicate sends
if (this.selectedFile) {
await this.sendMediaMessage();
return;
}
const input = document.getElementById('message-input');
const message = input.value.trim();
const messageType = document.getElementById('message-type') ? document.getElementById('message-type').value : 'text';
if (!message && messageType === 'text') return;
if (!this.currentUserId) return;
// Deshabilitar input
input.disabled = true;
document.getElementById('send-btn').disabled = true;
try {
const replyTo = input.dataset.replyTo || null;
if (messageType === 'template') {
const template = document.getElementById('templateSelect').value;
if (!template) {
alert('Seleccione una plantilla');
return;
}
const recipient = this.getConversationPhone(this.currentUserId);
if (!recipient) {
alert('No se pudo determinar el número de destino para la plantilla');
console.error('sendMessage(template): missing recipient for user', this.currentUserId);
return;
}
// send template via helper (match chat_window format)
const templateName = template;
const language = (document.getElementById('templateSelect').selectedOptions[0] ? document.getElementById('templateSelect').selectedOptions[0].dataset.language : 'es');
await this.sendTemplateMessage(templateName, language, null);
} else {
// send text message using recipient (phone number) to match chat_window behavior
const recipient = this.getConversationPhone(this.currentUserId);
if (!recipient) {
alert('No se pudo determinar el número de destino');
console.error('sendMessage(text): missing recipient for user', this.currentUserId);
return;
}
// prepare request body similar to chat_window
const requestBody = {
recipient: recipient,
type: 'text',
message: message
};
if (replyTo) requestBody.reply_to = replyTo;
try {
const result = await this.apiCall('send_message.php', { body: requestBody });
// Clear reply data attribute
input.dataset.replyTo = null;
this.hideReplyPreview();
if (result && result.success) {
input.value = '';
// Mostrar inmediatamente en la vista
this.addMessageToView(message, 'outgoing', { reply_to_message_id: replyTo });
typeof showAlert !== 'undefined' && showAlert('Mensaje enviado correctamente', 'success');
// Recargar mensajes y conversaciones en background
this.loadconversations(this.currentUserId, false).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');
}
} catch (err) {
console.error('Error sending text message', err);
alert('Error al enviar mensaje: ' + (err.message || err));
}
}
} catch (error) {
console.error('Error sending message:', error);
alert('Error al enviar mensaje: ' + (error.message||error));
} finally {
input.disabled = false;
document.getElementById('send-btn').disabled = false;
input.focus();
// Ensure send/mic visibility is recalculated (restore mic when input empty)
try { if (typeof this._updateSendMicVisibility === 'function') this._updateSendMicVisibility(); } catch (e) { console.warn('updateSendMicVisibility failed', e); }
}
}
// loadQuickReplies is implemented earlier (combined autoresponses & templates)
// kept here as a no-op to avoid accidentally overriding the real implementation.
async promptEditUser() {
const nameEl = document.getElementById('chat-name-text') || document.getElementById('chat-name');
const currentName = nameEl ? nameEl.textContent.trim() : '';
const newName = prompt('Nombre del usuario:', currentName);
if (!newName) return;
try {
const resp = await fetch('api/update_user.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ user_id: this.currentUserId, name: newName })
});
const json = await resp.json();
if (json && json.success) {
if (nameEl) nameEl.textContent = newName;
this.showSuccess && this.showSuccess('Usuario actualizado');
this.loadConversations();
} else {
this.showError && this.showError('No se pudo actualizar usuario');
}
} catch (err) {
console.error(err);
this.showError && this.showError('Error actualizando usuario');
}
}
async deleteCurrentConversation() {
if (!confirm('¿Eliminar esta conversación? Se borrará todo el historial.')) return;
try {
const resp = await fetch('api/delete_conversation.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ user_id: this.currentUserId })
});
const json = await resp.json();
if (json && json.success) {
this.showSuccess('Conversación eliminada');
// reset UI
document.getElementById('chat-area').style.display = 'none';
document.getElementById('no-conversation').style.display = 'block';
this.currentUserId = null;
this.loadConversations();
} else {
this.showError('No se pudo eliminar la conversación');
}
} catch (err) {
console.error(err);
this.showError('Error eliminando la conversación');
}
}
searchConversations(query) {
const items = document.querySelectorAll('.conversation-item');
items.forEach(item => {
const name = item.querySelector('.conversation-name').textContent.toLowerCase();
const preview = item.querySelector('.conversation-preview').textContent.toLowerCase();
const matches = name.includes(query.toLowerCase()) || preview.includes(query.toLowerCase());
item.style.display = matches ? 'flex' : 'none';
});
}
// ========== FUNCIONES MULTIMEDIA ==========
handleFileSelect(event) {
const file = event.target.files[0];
if (!file) return;
// Validar tamaño
const maxSize = this.getMaxFileSize(file.type);
if (file.size > maxSize) {
alert(`El archivo es demasiado grande. Máximo: ${(maxSize / 1024 / 1024).toFixed(0)}MB`);
return;
}
// Mostrar preview
this.showMediaPreview(file);
}
getMaxFileSize(fileType) {
if (fileType.startsWith('image/')) return 5 * 1024 * 1024; // 5MB
if (fileType.startsWith('application/')) return 100 * 1024 * 1024; // 100MB
return 16 * 1024 * 1024; // 16MB para video/audio
}
showMediaPreview(file) {
const preview = document.getElementById('media-preview');
const thumbnail = document.getElementById('preview-thumbnail');
const filename = document.getElementById('preview-filename');
const filesize = document.getElementById('preview-filesize');
// Establecer información del archivo
filename.textContent = file.name;
filesize.textContent = this.formatFileSize(file.size);
// Mostrar thumbnail para imágenes
if (file.type.startsWith('image/')) {
const reader = new FileReader();
reader.onload = (e) => {
thumbnail.src = e.target.result;
thumbnail.style.display = 'block';
};
reader.readAsDataURL(file);
} else {
thumbnail.style.display = 'none';
}
// Guardar archivo temporalmente
this.selectedFile = file;
// Mostrar preview
preview.style.display = 'block';
// Show delete button next to send
const delBtn = document.getElementById('delete-file-btn');
if (delBtn) {
delBtn.style.display = 'inline-flex';
delBtn.onclick = () => { this.cancelMediaUpload(); };
}
// Use the main send handler: `sendMessage` will delegate to media send when a file is selected
const sendBtn = document.getElementById('send-btn');
// ensure send button is enabled
if (sendBtn) sendBtn.disabled = false;
// Update mic/send visibility (hide mic when a file is selected)
if (this._updateSendMicVisibility) this._updateSendMicVisibility();
}
formatFileSize(bytes) {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i];
}
// Remove common emoji/pictograph characters for minimal quick reply labels
stripEmoji(text) {
if (!text) return '';
try {
return String(text).replace(/[\u{1F300}-\u{1FAFF}\u{1F600}-\u{1F64F}\u{1F680}-\u{1F6FF}\u{2600}-\u{26FF}\u{2700}-\u{27BF}]/gu, '').trim();
} catch (e) {
return String(text);
}
}
async sendMediaMessage() {
if (!this.selectedFile || !this.currentUserId) return;
const caption = document.getElementById('media-caption').value.trim();
const sendBtn = document.getElementById('send-btn');
const attachBtn = document.getElementById('attach-btn');
// Deshabilitar botones
sendBtn.disabled = true;
attachBtn.disabled = true;
sendBtn.innerHTML = '<i class="fas fa-spinner fa-spin"></i>';
try {
// 1. Subir archivo
const formData = new FormData();
formData.append('file', this.selectedFile);
const uploadResponse = await fetch('api/upload_media.php', {
method: 'POST',
credentials: 'same-origin', // Enviar cookies de sesión
body: formData
});
const uploadContentType = uploadResponse.headers.get('content-type') || '';
let uploadResult;
if (uploadContentType.indexOf('application/json') !== -1) {
uploadResult = await uploadResponse.json();
} else {
const text = await uploadResponse.text();
console.error('upload_media returned non-JSON response:\n', text);
throw new Error('Respuesta inválida de upload_media.php. Ver consola para más detalles.');
}
if (!uploadResult.success) {
throw new Error(uploadResult.error || 'Error subiendo archivo');
}
// 2. Enviar mensaje con el archivo
const phone = this.getConversationPhone(this.currentUserId);
if (!phone) {
throw new Error('No se encontró el número de teléfono del usuario');
}
const sendResponse = await fetch('api/send_media_message.php', {
method: 'POST',
credentials: 'same-origin', // Enviar cookies de sesión
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
recipient: phone,
media_url: uploadResult.data.url,
media_type: uploadResult.data.type,
caption: caption || null,
filename: this.selectedFile.name
})
});
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 = JSON.parse(sendResponseText);
} catch (err) {
// Content-Type claims JSON but parsing failed: attempt to extract JSON from body
console.warn('send_media_message: Content-Type JSON but parse failed. Response body will be inspected for JSON.');
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); }
}
if (!sendResult) {
// If HTTP status is OK, be forgiving: treat as success but log for investigation
if (sendResponse.ok) {
console.warn('send_media_message: non-parseable JSON returned but HTTP ok; treating as success.');
sendResult = { success: true };
} else {
throw new Error('Respuesta JSON inválida de send_media_message.php. Ver consola para más detalles.');
}
}
}
} else {
// Non-JSON content-type: try to find embedded JSON; if HTTP 200, be forgiving
console.warn('send_media_message returned non-JSON response:');
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); }
}
if (!sendResult) {
if (sendResponse.ok) {
// Message was likely sent successfully despite odd response — avoid showing false error to user
console.warn('send_media_message: non-JSON response but HTTP OK. Treating as success.');
sendResult = { success: true };
} else {
console.error('send_media_message returned non-JSON response and HTTP not OK.');
throw new Error('Respuesta inválida de send_media_message.php. Revisa logs del servidor (send_media_message_debug.log y el servidor PHP) para más detalles.');
}
}
}
if (!sendResult.success) {
throw new Error(sendResult.error || 'Error enviando mensaje');
}
// Éxito
console.log('✅ Archivo enviado exitosamente, recargando mensajes...');
this.cancelMediaUpload();
// Recargar mensajes para mostrar el multimedia recién enviado
await this.loadMessages(this.currentUserId, true, true);
// SSE actualizará las conversaciones en la lista automáticamente
// this.loadConversations();
} catch (error) {
console.error('Error sending media:', error);
alert('Error al enviar archivo: ' + error.message);
} finally {
sendBtn.disabled = false;
attachBtn.disabled = false;
sendBtn.innerHTML = '<i class="fas fa-paper-plane"></i>';
}
}
cancelMediaUpload() {
document.getElementById('media-preview').style.display = 'none';
document.getElementById('media-caption').value = '';
document.getElementById('file-input').value = '';
this.selectedFile = null;
// Restaurar botón enviar
const sendBtn = document.getElementById('send-btn');
if (sendBtn) { sendBtn.onclick = null; sendBtn.disabled = false; sendBtn.innerHTML = '<i class="fas fa-paper-plane"></i>'; }
const delBtn = document.getElementById('delete-file-btn'); if (delBtn) delBtn.style.display = 'none';
// Ensure mic/send visibility reflects the current state
if (this._updateSendMicVisibility) this._updateSendMicVisibility();
}
renderMediaMessage(message) {
if (!message) return '';
const mediaType = message.message_type || message.media_type || 'text';
const mediaUrl = message.media_url || '';
const mediaUrlExternal = message.media_url_external || '';
const caption = message.content || '';
const localFile = message.local_file || '';
const localThumb = message.local_thumb || '';
const messageId = message.id || message.message_id || '';
// Debug completo del mensaje
if (mediaType === 'audio' || mediaType === 'image' || mediaType === 'video') {
console.log(`🎵 Media message (${mediaType}) - Datos completos:`, {
id: messageId,
localFile: localFile,
localThumb: localThumb,
mediaUrl: mediaUrl,
mediaUrlExternal: mediaUrlExternal,
hasLocalFile: !!localFile,
messageType: mediaType
});
}
// Prioridad: archivo local > proxy con DB id > media_url_external > URL externa
let full = '';
let thumb = '';
if (localFile) {
// ✅ PRIORIDAD 1: Archivo local guardado
full = `/${localFile}`;
thumb = localThumb ? `/${localThumb}` : `/${localFile}`;
console.log('✅ Usando archivo local:', full);
} else if (messageId && !mediaUrlExternal) {
// ⚠️ PRIORIDAD 2: Usar proxy con el ID de la base de datos solo si no hay media_url_external
full = `/api/version/media-url.php?id=${encodeURIComponent(messageId)}`;
thumb = full;
console.log('⚠️ No hay local_file, usando proxy con messageId:', messageId);
} else if (mediaUrlExternal) {
// 🔄 PRIORIDAD 3: Usar media_url_external (puede ser api/get_media.php o URL directa)
full = mediaUrlExternal.startsWith('/') ? mediaUrlExternal : `/${mediaUrlExternal}`;
thumb = full;
console.log('🔄 Usando media_url_external:', full);
} else if (mediaUrl && /^\d+$/.test(mediaUrl)) {
// 📱 PRIORIDAD 4: Es un ID de WhatsApp (solo números)
full = `/api/get_media.php?id=${encodeURIComponent(mediaUrl)}`;
thumb = full;
console.log('📱 Usando whatsapp_media_id con get_media.php:', mediaUrl);
} else if (mediaUrl && /^https?:\/\//i.test(mediaUrl)) {
// 🌐 PRIORIDAD 5: URL externa directa (puede estar caducada)
full = mediaUrl;
thumb = mediaUrl;
console.log('🌐 Usando URL externa:', full);
} else {
// ❌ Fallback: sin media disponible
console.warn('❌ No hay fuente de media disponible para el mensaje', messageId);
full = '';
thumb = '';
}
switch (mediaType) {
case 'image':
return `
<div class="message-media">
<a href="#" data-full="${escapeHtml(full)}" data-type="image" data-caption="${escapeHtml(caption)}" onclick="openMediaLightbox(this.dataset.full, this.dataset.type, this.dataset.caption); return false;" title="Abrir imagen">
<img src="${escapeHtml(thumb)}" alt="Imagen" style="cursor:zoom-in">
</a>
</div>
${caption ? `<div>${escapeHtml(caption)}</div>` : ''}
`;
case 'video':
return `
<div class="message-media">
<a href="#" data-full="${escapeHtml(full)}" data-type="video" data-caption="${escapeHtml(caption)}" onclick="openMediaLightbox(this.dataset.full, this.dataset.type, this.dataset.caption); return false;" title="Abrir video">
<div style="position:relative; display:inline-block;">
<img src="${escapeHtml(thumb)}" alt="Video" style="cursor:zoom-in; max-width:100%; border-radius:6px;">
<div style="position:absolute; left:50%; top:50%; transform:translate(-50%,-50%); font-size:28px; color:white; text-shadow:0 1px 6px rgba(0,0,0,0.6);"><i class="fas fa-play-circle"></i></div>
</div>
</a>
</div>
${caption ? `<div>${escapeHtml(caption)}</div>` : ''}
`;
case 'audio': {
// Para audio, usar la misma lógica de full
let audioSrc = full;
return `
<div class="message-media">
<audio controls>
<source src="${escapeHtml(audioSrc)}" type="audio/mpeg">
Tu navegador no soporta audio.
</audio>
</div>
`;
}
case 'document': {
let docUrl = full;
if (!docUrl) {
return `
<div class="message-document disabled">
<i class="fas fa-file-pdf"></i>
<div class="document-info">
<div class="document-name">${escapeHtml(caption || 'Documento')}</div>
<div class="document-size text-muted">No disponible para descargar</div>
</div>
</div>
`;
}
return `
<div class="message-document">
<i class="fas fa-file-pdf"></i>
<div class="document-info">
<div class="document-name">${escapeHtml(caption || 'Documento')}</div>
<div class="document-size">
<a href="${escapeHtml(docUrl)}" target="_blank" rel="noopener noreferrer" ${docUrl && docUrl.indexOf('download=1') !== -1 ? 'download' : ''}>Descargar</a>
</div>
</div>
<i class="fas fa-download"></i>
</div>
`;
}
default:
return escapeHtml(caption) || '';
}
}
}
// Función global para cancelar
function cancelMediaUpload() {
if (window.chatApp) {
window.chatApp.cancelMediaUpload();
}
}
// Helper para renderizar WhatsApp-like formatted text and interactive messages
function renderInteractiveMessage(obj) {
try {
const interactive = obj.interactive || obj;
const t = interactive.type || (interactive.header && 'button');
if (t === 'button' || interactive.button) {
const title = escapeHtml(interactive.title || interactive.body || '');
const buttons = interactive.buttons || interactive.button || [];
const html = [`<div class="wa-interactive">`, title ? `<div class="wa-title">${title}</div>` : ''];
html.push(`<div class="wa-buttons">`);
buttons.forEach(b => {
const label = escapeHtml(b.title || b.text || b.body || b.label || '');
html.push(`<button class="btn btn-sm btn-outline-primary wa-interactive-btn" data-value="${label}">${label}</button>`);
});
html.push(`</div></div>`);
return html.join('');
}
if (t === 'list' || interactive.sections) {
const title = escapeHtml(interactive.title || interactive.header || '');
const sections = interactive.sections || [];
const html = [`<div class="wa-interactive">`, title ? `<div class="wa-title">${title}</div>` : ''];
html.push(`<div class="wa-list">`);
sections.forEach(s => {
const secTitle = escapeHtml(s.title || '');
if (secTitle) html.push(`<div class="wa-section-title">${secTitle}</div>`);
(s.rows || []).forEach(r => {
const rowTitle = escapeHtml(r.title || r.name || r.id || '');
const rowDesc = escapeHtml(r.description || '');
html.push(`<div class="wa-list-item" data-value="${rowTitle}"><div class="wa-list-name">${rowTitle}</div>${rowDesc?`<div class="wa-list-desc small text-muted">${rowDesc}</div>`:''}</div>`);
});
});
html.push(`</div></div>`);
return html.join('');
}
} catch (e) { console.warn('renderInteractiveMessage failed', e); }
return '';
}
function formatMessageInlineCode(s) {
return s.replace(/`([^`]+)`/g, (m, g1) => `<span class="wa-inline-code">${escapeHtml(g1)}</span>`);
}
function formatMessageContent(text) {
if (!text && text !== 0) return '';
try {
// If looks like JSON with interactive payload, render interactive UI
const t = String(text).trim();
if ((t.startsWith('{') && t.endsWith('}')) || (t.startsWith('[') && t.endsWith(']'))) {
try {
const parsed = JSON.parse(t);
if (parsed && (parsed.interactive || parsed.type === 'interactive')) {
return renderInteractiveMessage(parsed.interactive || parsed);
}
} catch (e) { /* not json */ }
}
// Escape HTML first
let s = escapeHtml(String(text));
// Code block (triple backticks) -> <pre>
s = s.replace(/```([\s\S]*?)```/g, (m, g1) => `<div class="wa-code">${escapeHtml(g1)}</div>`);
// Inline code
s = formatMessageInlineCode(s);
// Bold *text*
s = s.replace(/\*([^*]+)\*/g, '<strong>$1</strong>');
// Italic _text_
s = s.replace(/_([^_]+)_/g, '<em>$1</em>');
// Strikethrough ~text~
s = s.replace(/~([^~]+)~/g, '<del>$1</del>');
// Auto-link URLs
s = s.replace(/(https?:\/\/[^\s]+)/g, '<a href="$1" target="_blank" rel="noopener noreferrer">$1</a>');
return s;
} catch (e) {
console.warn('formatMessageContent failed', e);
return escapeHtml(String(text));
}
}
window.formatMessageContent = formatMessageContent;
// Delegate clicks on interactive buttons/list items to send quick replies
document.addEventListener('click', (ev) => {
const btn = ev.target.closest('.wa-interactive-btn');
if (btn) {
ev.stopPropagation();
const v = btn.dataset.value;
if (v && window.chatApp && typeof window.chatApp.sendQuickReply === 'function') {
window.chatApp.sendQuickReply(v);
}
}
const item = ev.target.closest('.wa-list-item');
if (item) {
ev.stopPropagation();
const v = item.dataset.value;
if (v && window.chatApp && typeof window.chatApp.sendQuickReply === 'function') {
window.chatApp.sendQuickReply(v);
}
}
});
// Helper para iconos de archivos (copiado de chat_window)
function getFileIcon(filename) {
if (!filename) return 'fas fa-file text-muted';
const ext = String(filename).split('.').pop().toLowerCase();
const iconMap = {
'pdf': 'fas fa-file-pdf text-danger',
'doc': 'fas fa-file-word text-primary',
'docx': 'fas fa-file-word text-primary',
'xls': 'fas fa-file-excel text-success',
'xlsx': 'fas fa-file-excel text-success',
'ppt': 'fas fa-file-powerpoint text-warning',
'pptx': 'fas fa-file-powerpoint text-warning',
'zip': 'fas fa-file-archive text-secondary',
'rar': 'fas fa-file-archive text-secondary',
'txt': 'fas fa-file-alt text-muted',
'csv': 'fas fa-file-csv text-success'
};
return iconMap[ext] || 'fas fa-file text-muted';
}
// Media lightbox helper (image/video with download)
function openMediaLightbox(url, type = 'image', caption = '') {
if (!url) {
showAlert && showAlert('Media no disponible', 'warning');
return;
}
const body = document.getElementById('imageLightboxBody');
const cap = document.getElementById('imageLightboxCaption');
const downloadBtn = document.getElementById('imageLightboxDownload');
if (!body || !cap) return;
// clear existing content
body.innerHTML = '';
// Decide rendering by type or by URL extension
const isVideo = (type === 'video') || /\.(mp4|webm|ogg)(\?|$)/i.test(url);
if (isVideo) {
body.innerHTML = `<video controls class="w-100" style="border-radius:10px;"><source src="${escapeHtml(url)}" type="video/mp4">Tu navegador no soporta video.</video>`;
} else {
body.innerHTML = `<img src="${escapeHtml(url)}" class="img-fluid w-100" style="border-radius:10px;">`;
}
cap.textContent = caption || '';
if (downloadBtn) {
downloadBtn.href = url;
downloadBtn.style.display = 'inline-block';
}
const modalEl = document.getElementById('imageLightboxModal');
const bsModal = new bootstrap.Modal(modalEl);
bsModal.show();
}
// backward compatibility wrapper
function openImageLightbox(url, caption = '') {
openMediaLightbox(url, 'image', caption);
}
// Inicializar cuando la página cargue
let chat;
document.addEventListener('DOMContentLoaded', () => {
chat = new WhatsAppChat();
window.chatApp = chat; // Exponer globalmente para funciones auxiliares
// Helpers para pruebas manuales y debugging
window.__showNoMoreMessagesHint = () => chat.showNoMoreMessagesHint && chat.showNoMoreMessagesHint();
window.__removeNoMoreMessagesHint = () => chat.removeNoMoreMessagesHint && chat.removeNoMoreMessagesHint();
});
</script>
<!-- Lightbox Modal -->
<div class="modal fade" id="imageLightboxModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered modal-lg">
<div class="modal-content bg-transparent border-0">
<div class="modal-body p-0 position-relative">
<button type="button" class="btn-close btn-close-white position-absolute top-0 end-0 m-3" data-bs-dismiss="modal" aria-label="Cerrar" style="z-index:10;"></button>
<div id="imageLightboxBody" style="max-height:75vh; overflow:auto;"></div>
<div id="imageLightboxCaption" class="mt-2 text-center text-white small"></div>
<div class="position-absolute bottom-0 end-0 m-3">
<a id="imageLightboxDownload" class="btn btn-sm btn-light" href="#" target="_blank" download style="display:none;"><i class="fas fa-download"></i> Descargar</a>
</div>
</div>
</div>
</div>
</div>
<!-- Reaction picker -->
<div id="reaction-picker" aria-hidden="true">
<div style="display:flex; flex-wrap:wrap; gap:6px;">
<button class="emoji" data-emoji="👍">👍</button>
<button class="emoji" data-emoji="❤️">❤️</button>
<button class="emoji" data-emoji="😂">😂</button>
<button class="emoji" data-emoji="😮">😮</button>
<button class="emoji" data-emoji="😢">😢</button>
<button class="emoji" data-emoji="👏">👏</button>
<button class="emoji" data-emoji="🎉">🎉</button>
<button class="emoji" data-emoji="🔥">🔥</button>
<button class="emoji" data-emoji="🙏">🙏</button>
</div>
</div>
<!-- Footer U-Site -->
<footer style="position: fixed; bottom: 0; left: 0; right: 0; background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%); padding: 8px 20px; text-align: center; font-size: 12px; color: rgba(255,255,255,0.7); z-index: 999; border-top: 1px solid rgba(255,255,255,0.1);">
Desarrollado con 💚 por <a href="https://u-site.app" target="_blank" style="color: #25d366; text-decoration: none; font-weight: 600;">U-Site.app</a>
</footer>
</body>
</html>