Files
whatsapp/conversations.php
T

3435 lines
170 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; }
.message-template {
background: #fffacd;
border: 1px solid #f0e68c;
padding: 10px;
border-radius: 8px;
margin-bottom: 10px;
font-size: 13px;
}
.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
// 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;
// Track last known messages count to show "Nuevos mensajes" indicator when new messages arrive while user is reading
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';
if (notification.cool || notification.type === 'cool') toast.classList.add('cool');
if (notification.level === 'urgent' || notification.urgent) toast.classList.add('urgent');
const iconSpan = document.createElement('span');
iconSpan.className = 'nt-icon';
iconSpan.textContent = notification.icon || (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' : 'btn btn-sm btn-primary';
openBtn.textContent = '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 if present
let data = {};
try { data = notification.data ? JSON.parse(notification.data) : {}; } catch(e) {}
const userId = data.user_id || notification.user_id;
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);
// 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);
}
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';
const fileSelected = !!this.selectedFile;
// 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 (text.length > 0 || isTemplate || fileSelected) {
if (sendBtn) sendBtn.style.display = 'inline-block';
if (micBtn) micBtn.style.display = 'none';
} else {
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 incremental load (initial=false) to avoid forcing scroll to bottom while the user reads
setInterval(() => {
if (this.currentUserId) {
// loadMessages with initial=false fetches older/newer messages without forcing a scroll
this.loadMessages(this.currentUserId, false).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) + '...';
}
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 when on_hold or advisor_requested
releaseBtn.style.display = (conv.on_hold || conv.advisor_requested) ? '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');
}
};
// 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;
// refresh conv reference
const c = this.conversations.find(x => x.user_id === userId) || conv;
if (c.in_service) {
attendToggleBtn.innerHTML = '<i class="fas fa-user-times"></i> Finalizar';
attendToggleBtn.classList.remove('btn-outline-light');
attendToggleBtn.classList.add('btn-light','text-dark');
attendToggleBtn.style.display = 'inline-block';
attendStatus.textContent = `Atendido por ${c.in_service_by_name || 'un asesor'}`;
} else if (c.advisor_requested) {
attendToggleBtn.innerHTML = '<i class="fas fa-user-check"></i> Atender';
attendToggleBtn.classList.remove('btn-light','text-dark');
attendToggleBtn.classList.add('btn-outline-light');
attendToggleBtn.style.display = 'inline-block';
attendStatus.textContent = '';
} else {
attendToggleBtn.style.display = 'none';
attendStatus.textContent = '';
}
};
const toggleAttend = async () => {
try {
const c = this.conversations.find(x => x.user_id === userId) || conv;
if (c.in_service) {
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';
await this.loadConversations();
await this.loadMessages(userId, true);
} else {
throw new Error(resp && resp.error ? resp.error : 'Error finalizando');
}
} else {
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';
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) {
attendToggleBtn.onclick = toggleAttend;
}
// inicializar estado
renderAttendControls();
}
if (btn) {
// Mostrar como acciones de Atender / Finalizar
btn.textContent = conv.bot_enabled ? 'Finalizar' : 'Atender';
btn.title = conv.bot_enabled ? 'Finalizar atención' : 'Atender';
btn.classList.toggle('btn-outline-danger', !conv.bot_enabled);
btn.classList.toggle('btn-outline-secondary', conv.bot_enabled);
// Hide bot toggle when advisor requested or in service or on hold (we expect attend/finish actions)
if (conv.advisor_requested || conv.in_service || conv.on_hold) {
btn.style.display = 'none';
} else {
btn.style.display = 'inline-block';
}
btn.onclick = async () => {
try {
const resp = await fetch('api/set_bot_enabled.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ user_id: userId, enabled: conv.bot_enabled ? 0 : 1 })
});
const json = await resp.json();
if (json && json.success) {
conv.bot_enabled = !conv.bot_enabled;
// update button label to match new state
btn.textContent = conv.bot_enabled ? 'Finalizar' : 'Atender';
btn.title = conv.bot_enabled ? 'Finalizar atención' : 'Atender';
btn.classList.toggle('btn-outline-danger', !conv.bot_enabled);
btn.classList.toggle('btn-outline-secondary', conv.bot_enabled);
} else {
alert('Error al cambiar el estado del bot');
}
} catch (e) {
console.error('Error toggling bot', e);
}
};
}
}
// 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;
}
}
// 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 {
let url = `get_user_messages.php?user_id=${userId}&limit=${this.messageLimit}`;
if (!initial && this.earliestMessage) {
url += `&before=${encodeURIComponent(this.earliestMessage)}`;
}
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;
if (data && data.success && Array.isArray(data.data)) {
messages = data.data;
hasMore = !!data.has_more;
earliest = data.earliest || (messages[0] && messages[0].created_at) || null;
} else if (Array.isArray(data)) {
messages = data;
}
if (initial) {
this.conversations = messages;
} else {
// Prepend mensajes antiguos
this.conversations = messages.concat(this.conversations);
}
// Improved dedupe: pick the most "readable" message when duplicates exist
try {
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;
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;
this.renderMessagesIncremental(initial, newlyFetched);
// 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);
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;
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);
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]'); } 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);
const 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 || '[Mensaje vacío]') : window.escapeHtml(msg.content || '[Mensaje vacío]'));
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);
}
} 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);
// Determine if we received new messages since last render
const prevCount = this._lastRenderMessageCount || 0;
const newCount = this.conversations.length || 0;
const added = Math.max(0, newCount - prevCount);
if (shouldScroll) {
this.scrollToBottom();
// hide indicator if visible
if (added) this.hideNewMessagesIndicator();
} else {
if (added > 0) this.showNewMessagesIndicator(added);
}
// Save for next render
this._lastRenderMessageCount = newCount;
}
// 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 {
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;
};
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();
};
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();
};
return true;
}
// --- New messages indicator helpers ---
showNewMessagesIndicator(count) {
try {
let el = document.getElementById('new-messages-indicator');
if (!el) {
el = document.createElement('div');
el.id = 'new-messages-indicator';
el.style.position = 'fixed';
el.style.bottom = '90px';
el.style.left = '50%';
el.style.transform = 'translateX(-50%)';
el.style.background = '#0d6efd';
el.style.color = 'white';
el.style.padding = '8px 12px';
el.style.borderRadius = '20px';
el.style.boxShadow = '0 2px 8px rgba(0,0,0,0.2)';
el.style.zIndex = '2000';
el.style.cursor = 'pointer';
el.onclick = () => { this.scrollToBottom(); this.hideNewMessagesIndicator(); };
document.body.appendChild(el);
}
el.textContent = (count > 1) ? (`${count} mensajes nuevos`) : '1 mensaje nuevo';
el.style.display = 'block';
} catch (e) { console.warn('showNewMessagesIndicator failed', e); }
}
hideNewMessagesIndicator() {
try {
const el = document.getElementById('new-messages-indicator');
if (el) el.style.display = 'none';
} catch (e) { console.warn('hideNewMessagesIndicator failed', e); }
}
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 {
const requestBody = {
recipient: recipient,
type: 'template',
template: templateName,
language: language || 'es',
parameters: parameters || []
};
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();
}
}
// 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';
}
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
});
</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>