Files
whatsapp/conversations.php
T

4089 lines
213 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.
<!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);
}
.message-bubble .message-time {
position: absolute;
right: 8px;
bottom: 4px;
font-size: 10px;
color: #666;
opacity: 0;
transition: opacity 0.12s ease-in-out;
white-space: nowrap;
}
/* 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: 9999;
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; }
.quick-replies-panel {
display: none;
background: rgba(255,255,255,0.96);
border: none;
box-shadow: 0 6px 18px rgba(2,6,23,0.04);
padding: 6px;
border-radius: 6px;
max-height: 180px;
overflow-y: auto;
display: flex;
gap: 6px;
flex-wrap: wrap;
align-items: center;
}
.quick-replies-panel .btn { min-width: 72px; max-width: 200px; font-size: 13px; padding:6px 10px; }
#quick-replies-toggle { background: transparent; border-radius: 20px; }
@media (max-width: 768px) {
.quick-replies-panel { left: 8px; right: 8px; bottom: 56px; min-width: auto; }
}
/* Improve audio control visibility and color in supporting browsers */
.message-media audio { background: #fff; border-radius: 8px; padding: 4px; accent-color: var(--whatsapp-green); }
/* Ensure mic icon contrast */
#mic-btn i { color: white; font-size: 14px; }
/* Make mic button more visible */
#mic-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 { font-size: 14px; }
/* Attach '+' style and menu */
#attach-btn { background: transparent; border-radius: 6px; padding: 4px 10px; border: 1px solid transparent; font-weight:700; }
#attach-btn:hover { background: rgba(0,0,0,0.03); border-color: rgba(0,0,0,0.06); }
.attach-menu { background:#fff; border:1px solid #ddd; box-shadow:0 6px 18px rgba(0,0,0,0.08); border-radius:6px; padding:6px; display:none; position:absolute; z-index:1300; }
.attach-menu .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; }
.message.incoming .message-bubble::before,
.message.outgoing .message-bubble::after { content: ''; }
.notification-toast.urgent { border-left: 4px solid #e74c3c; }
/* Visual destacado para notificaciones de tipo "attention" (ej. usuario subió documentos) */
.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;
}
@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); }
.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;
}
/* 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;
}
/* Ensure mic button contrast */
#mic-btn { background: var(--whatsapp-green); color: #fff; border-radius: 8px; padding: 8px 10px; }
#mic-btn.recording { background: #c0392b; color: white; }
@media (max-width: 768px) {
/* Fullscreen sidebar that can slide in/out */
.chat-container { padding: 0; gap: 0; }
.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;
background: #fff;
z-index: 1200;
border-top: 1px solid #e9eef2;
}
/* 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:2000; background:#fff; border-radius:10px; box-shadow:0 6px 24px rgba(0,0,0,0.16); padding:8px; min-width:160px; transition:transform .12s ease, opacity .12s ease; transform:scale(.96); opacity:0; }
#reaction-picker.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>
</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</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>
<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" style="position:relative;">
<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>
<?php $asset_v = file_exists(__DIR__ . '/assets/js/chat-common.js') ? filemtime(__DIR__ . '/assets/js/chat-common.js') : time(); ?>
<script src="assets/js/chat-common.js?v=<?php echo $asset_v; ?>"></script>
<script>
// 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 (3s) so very short toasts don't disappear instantly
const _minToastDuration = 3000;
let _dur = (type === 'success') ? 4000 : 8000;
_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() {
this.currentConversationId = null;
this.currentUserId = null;
this.conversations = [];
// 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();
// 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;
// Suspend periodic refresh while media playback or recording is active
this._suspendAutoRefresh = false;
this._pendingReloadAfterMedia = false;
// Removed: automatic "Nuevos mensajes" indicator — we always reload full conversation now
// this._lastRenderMessageCount = 0;
this.init();
}
init() {
this.loadConversations();
this.setupEventListeners();
this.setupAutoRefresh();
this.setupNotificationPolling();
// start background retry loop to ack dismissed notifications on server
this.setupNotificationAckRetry && this.setupNotificationAckRetry();
}
setupNotificationPolling() {
// Poll every 7 seconds for unread notifications
this.loadNotifications();
setInterval(() => this.loadNotifications(), 7000);
}
async loadNotifications() {
try {
const resp = await fetch('api/get_notifications.php', { credentials: 'same-origin' });
if (resp.status === 401) {
console.warn('Notifications fetch: unauthorized (session may have expired)');
return;
}
const json = await resp.json();
console.debug('loadNotifications response:', json);
if (json && json.success && Array.isArray(json.data)) {
json.data.forEach(n => this.showNotificationToast(n));
} else if (json && json.success === false) {
console.warn('get_notifications returned error:', json.error || json);
}
} catch (e) {
console.error('Error loading notifications', e);
}
}
// Try to mark a notification as read on the server. Returns true on success.
async ackNotification(notification) {
if (!notification || !notification.id) return true; // nothing to ack on server
try {
const resp = await fetch('api/mark_notification_read.php', {
method: 'POST',
headers: {'Content-Type':'application/json'},
body: JSON.stringify({ id: notification.id })
});
if (!resp.ok) {
console.warn('ackNotification: server responded with', resp.status);
return false;
}
const j = await resp.json().catch(() => null);
return !!(j && j.success) || resp.ok;
} catch (e) {
console.warn('ackNotification failed', e);
return false;
}
}
setupNotificationAckRetry() {
if (this._ackInterval) return;
this._ackInterval = setInterval(async () => {
if (this._pendingAck.size === 0) return;
for (const [nid, notification] of Array.from(this._pendingAck.entries())) {
try {
const ok = await this.ackNotification(notification);
if (ok) {
this._pendingAck.delete(nid);
this._dismissedNotifications.add(nid);
console.debug('Ack retry succeeded for', nid);
} else {
console.debug('Ack retry still failing for', nid);
}
} catch (e) {
console.warn('Ack retry error for', nid, e);
}
}
}, 30000); // every 30s
}
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 and try to ack on server so it doesn't reappear
this._shownNotifications.add(nid);
try {
const ok = await this.ackNotification(notification);
if (ok) {
this._dismissedNotifications.add(nid);
this._pendingAck.delete(nid);
} else {
// schedule for retry
this._pendingAck.set(nid, notification);
}
} catch (e) {
this._pendingAck.set(nid, notification);
}
const toast = document.createElement('div');
// compact by default
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 () => {
// Try ack on server; if fails, schedule retry. Always mark as dismissed locally so it won't reappear.
try {
const ok = await this.ackNotification(notification);
if (ok) {
this._dismissedNotifications.add(nid);
this._pendingAck.delete(nid);
} else {
this._pendingAck.set(nid, notification);
}
} catch (e) { this._pendingAck.set(nid, notification); }
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) {
this.openConversation(conv.user_id, conv.name, conv.phone_number);
} else {
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 () => {
try {
const ok = await this.ackNotification(notification);
if (ok) {
this._dismissedNotifications.add(nid);
this._pendingAck.delete(nid);
} else {
this._pendingAck.set(nid, notification);
}
} catch(e) { this._pendingAck.set(nid, notification); }
removeToastLocal();
};
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
const _minToastDuration = 3000;
let timeout = notification.duration ? Number(notification.duration) : (notification.cool ? 15000 : 8000);
if (!isFinite(timeout) || timeout <= 0) timeout = (notification.cool ? 15000 : 8000);
timeout = Math.max(timeout, _minToastDuration);
setTimeout(() => {
// on automatic timeout, attempt ack and schedule retry if needed
(async () => {
try {
const ok = await this.ackNotification(notification);
if (ok) {
this._dismissedNotifications.add(nid);
this._pendingAck.delete(nid);
} else {
this._pendingAck.set(nid, notification);
}
} catch (e) { this._pendingAck.set(nid, notification); }
removeToastLocal();
})();
}, 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.conversations.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);
}
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();
}
});
// 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', () => {
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';
// reload conversations from first page
this.loadConversations(1, false);
});
}
}
setupAutoRefresh() {
// Actualizar conversaciones cada 30 segundos
setInterval(() => {
this.loadConversations();
}, 30000);
// Actualizar mensajes del chat activo cada 10 segundos
// Use full reload (initial=true) — always fetch the full conversation and scroll to bottom,
// but suspend while media playback or recording is active (to avoid interrupting)
setInterval(() => {
if (this.currentUserId) {
try {
if (typeof this.isMediaActive === 'function' && this.isMediaActive()) {
console.debug('Periodic load skipped: media active or recording in progress; scheduling reload after media ends');
this._pendingReloadAfterMedia = true;
return;
}
} catch (e) { console.warn('isMediaActive check failed', e); }
// full reload forces scroll-to-bottom
this.loadMessages(this.currentUserId, true).catch(err => console.warn('Periodic loadMessages failed', err));
}
}, 10000);
}
// Helper para llamadas a la API desde esta clase
async apiCall(endpoint, options = {}) {
const defaultOptions = {
method: 'GET',
headers: {
'Content-Type': 'application/json'
}
};
// 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) {
// 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) {
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);
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;
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() {
const container = document.getElementById('conversation-list');
if (this.conversations.length === 0) {
const emptyMsg = this.conversationFilter === 'unread' ? 'No hay conversaciones no leídas' : 'No hay conversaciones aún';
container.innerHTML = `
<div class="text-center p-4">
<i class="fas fa-comments fa-3x text-muted mb-3"></i>
<p class="text-muted">${emptyMsg}</p>
</div>
`;
return;
}
// Ahora el backend devuelve por usuario: last_message, last_time, unread_count y avatar_url
const html = this.conversations.map(conv => {
const isActive = conv.user_id == this.currentUserId ? 'active' : '';
const time = this.formatTime(conv.last_time);
const preview = this.truncateText(conv.last_message || 'Sin mensajes', 50);
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('');
// Detectar nuevas notificaciones: comparar unread counts previos
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 };
});
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;
// 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
const conv = this.conversations.find(c => c.user_id === userId);
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) {
// Recargar lista de conversaciones para reflejar cambios
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)
await this.loadMessages(userId, true);
// 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; }, 1500);
} catch(e) { /* ignore */ }
if (chatContainer.scrollTop <= 60 && this.hasMoreMessages && !this.loadingMessages && this.currentUserId == userId) {
await this.loadMessages(userId, false);
}
});
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);
}
} 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) {
if (this.loadingMessages) return;
this.loadingMessages = true;
const container = document.getElementById('chat-conversations');
// si cargamos más antiguos, preservar scroll
let prevScrollHeight = container ? container.scrollHeight : 0;
let prevScrollTop = container ? container.scrollTop : 0;
// 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.
let url = `get_user_messages.php?user_id=${userId}&limit=${this.messageLimit}`;
const 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.conversations) && this.conversations.length > 0) {
try { if (topLoader && topLoader.parentNode) topLoader.remove(); } 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.conversations = 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.conversations || []).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.conversations = messages.concat(this.conversations || []);
} 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;
// Improved dedupe: pick the most "readable" message when duplicates exist
try {
if (!this.conversations || this.conversations.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.conversations.length - 1; i >= 0; i--) {
const m = this.conversations[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.conversations.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.conversations && this.conversations.length > 0) {
this.renderMessagesIncremental(initial, newlyFetched);
} 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
if (initial) {
this.scrollToBottom();
} 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) {
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;
}
}
// Incremental rendering to avoid DOM replacement that interrupts media playback or scroll
renderMessagesIncremental(initial = false, prependCount = 0) {
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.conversations ? this.conversations.length : 0;
if (Array.isArray(this.conversations) && this.conversations.length > 0) {
this.conversations = this.conversations.filter(m => !this.isEmptyMessage(m));
const removed = before - this.conversations.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) {
container.innerHTML = '';
}
// Map existing nodes
const existing = new Map();
Array.from(container.querySelectorAll('[data-message-id]')).forEach(el => {
existing.set(String(el.dataset.messageId), el);
});
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.conversations.length; i++) {
const msg = this.conversations[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.conversations.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) timeEl.textContent = msg.created_at ? new Date(msg.created_at).toLocaleTimeString('es-ES', { hour: '2-digit', minute: '2-digit' }) : '';
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 {
const div = document.createElement('div');
div.className = 'message ' + (msg.direction || 'incoming');
div.dataset.messageId = mid;
let replyHtml = '';
if (msg.reply_to_message_id) {
const target = this.conversations.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 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());
// If user was near bottom before update and not actively scrolling, keep it at bottom to follow conversation
const nearBottom = (container.scrollHeight - container.scrollTop - container.clientHeight) < 150;
const shouldScroll = initial ? true : (nearBottom && !this._userScrolling);
if (shouldScroll) {
this.scrollToBottom();
}
}
// 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.currentUserId) {
this._pendingReloadAfterMedia = false;
await this.loadMessages(this.currentUserId, true);
}
} 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.currentUserId) {
this._pendingReloadAfterMedia = false;
this.loadMessages(this.currentUserId, true).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.currentUserId) {
this._pendingReloadAfterMedia = false;
this.loadMessages(this.currentUserId, true).catch(e => console.warn('Reload after cancelRecording failed', e));
}
} catch (e) { console.warn('cancelRecording resume failed', e); }
};
return true;
}
// --- New messages indicator helpers (removed) ---
showNewMessagesIndicator(count) {
// No-op: indicator removed — full reload & scroll-to-bottom now used
}
hideNewMessagesIndicator() {
// No-op
}
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);
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.conversations.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.conversations.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));
this.loadConversations().catch(e=>console.warn(e));
this.loadQuickReplies().catch(e=>console.warn(e));
} 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',
body: formData
});
const uploadResult = await uploadResponse.json();
if (!uploadResult.success) {
throw new Error(uploadResult.error || 'Error subiendo archivo');
}
// 2. Enviar mensaje con el archivo
const user = this.conversations.find(c => c.user_id === this.currentUserId);
const phone = user ? user.phone_number : null;
if (!phone) {
throw new Error('No se encontró el número de teléfono');
}
const sendResponse = await fetch('api/send_media_message.php', {
method: 'POST',
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 sendResult = await sendResponse.json();
if (!sendResult.success) {
throw new Error(sendResult.error || 'Error enviando mensaje');
}
// Éxito
this.cancelMediaUpload();
await this.loadconversations(this.currentUserId, false);
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 || message.media_url_external || '';
const caption = message.content || '';
const localFile = message.local_file || '';
const localThumb = message.local_thumb || '';
const thumb = localThumb ? (`/${localThumb}`) : (localFile ? (`/${localFile}`) : (message.media_url_external || mediaUrl));
let full = '';
if (localFile) {
full = `/api/version/media-url.php?local=${encodeURIComponent(localFile)}`;
} else if (message.media_url_external) {
if (message.media_url_external.indexOf('api/get_media.php?id=') !== -1) {
const parts = message.media_url_external.split('id=');
const mid = parts[1] ? decodeURIComponent(parts[1]) : '';
full = `/api/version/media-url.php?id=${encodeURIComponent(mid)}`;
} else if (/^https?:\/\//i.test(message.media_url_external)) {
full = message.media_url_external;
} else {
full = `/api/version/media-url.php?url=${encodeURIComponent(message.media_url_external)}`;
}
} else {
full = mediaUrl || '';
}
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': {
let audioSrc = '';
if (localFile) {
audioSrc = `/${localFile}`;
} else if (message.media_url_external) {
if (message.media_url_external.indexOf('api/get_media.php?id=') !== -1) {
const parts = message.media_url_external.split('id=');
const mid = parts[1] ? decodeURIComponent(parts[1]) : '';
audioSrc = `/api/version/media-url.php?id=${encodeURIComponent(mid)}`;
} else if (/^https?:\/\//i.test(message.media_url_external)) {
audioSrc = message.media_url_external;
} else {
audioSrc = `/api/version/media-url.php?url=${encodeURIComponent(message.media_url_external)}`;
}
} else {
audioSrc = mediaUrl;
}
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 = '';
if (localFile) {
docUrl = `/api/version/media-url.php?local=${encodeURIComponent(localFile)}&download=1`;
} else if (message.media_url_external) {
if (message.media_url_external.indexOf('api/get_media.php?id=') !== -1) {
const parts = message.media_url_external.split('id=');
const mid = parts[1] ? decodeURIComponent(parts[1]) : '';
docUrl = `/api/version/media-url.php?id=${encodeURIComponent(mid)}&download=1`;
} else if (/^https?:\/\//i.test(message.media_url_external)) {
docUrl = `/api/version/media-url.php?url=${encodeURIComponent(message.media_url_external)}&download=1`;
} else {
docUrl = `/api/version/media-url.php?url=${encodeURIComponent(message.media_url_external)}&download=1`;
}
}
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>
</body>
</html>