| 1 | /** |
| 2 | * MAPS Connect - Mensajes |
| 3 | * Las conversaciones y los mensajes se renderizan solo cuando el backend |
| 4 | * (/mensajes y /mensajes/{id}) devuelve datos. |
| 5 | */ |
| 6 | |
| 7 | const Mensajes = { |
| 8 | state: { |
| 9 | conversaciones: [], |
| 10 | seleccionada: null, |
| 11 | eventSource: null, |
| 12 | adjunto: null |
| 13 | }, |
| 14 | |
| 15 | async init() { |
| 16 | Layout.init('mensajes.html'); |
| 17 | Layout.setPageTitle('Mensajes'); |
| 18 | |
| 19 | this.bindEventos(); |
| 20 | await this.loadConversaciones(); |
| 21 | |
| 22 | // Si llegamos desde una notificación, abre la conversación indicada. |
| 23 | const params = new URLSearchParams(window.location.search); |
| 24 | const conv = params.get('conv'); |
| 25 | if (conv && this.state.conversaciones.some(c => String(this.field(c, 'id')) === String(conv))) { |
| 26 | this.seleccionar(conv); |
| 27 | } |
| 28 | }, |
| 29 | |
| 30 | bindEventos() { |
| 31 | const list = document.getElementById('conversacion-list'); |
| 32 | list?.addEventListener('click', e => { |
| 33 | const item = e.target.closest('.chat-item'); |
| 34 | if (item && item.dataset.id) this.seleccionar(item.dataset.id); |
| 35 | }); |
| 36 | |
| 37 | const search = document.getElementById('chat-search'); |
| 38 | search?.addEventListener('input', () => this.renderConversaciones()); |
| 39 | |
| 40 | const body = document.getElementById('chat-body'); |
| 41 | body?.addEventListener('click', e => { |
| 42 | const btn = e.target.closest('.adjunto-descargar'); |
| 43 | if (!btn) return; |
| 44 | this.descargarAdjunto(btn.dataset.id, btn.dataset.nombre, btn.dataset.tipo); |
| 45 | }); |
| 46 | |
| 47 | document.getElementById('enviar-mensaje')?.addEventListener('click', () => this.enviar()); |
| 48 | document.getElementById('mensaje-input')?.addEventListener('keydown', e => { |
| 49 | if (e.key === 'Enter') this.enviar(); |
| 50 | }); |
| 51 | document.getElementById('btn-adjuntar')?.addEventListener('click', () => { |
| 52 | document.getElementById('adjunto-input')?.click(); |
| 53 | }); |
| 54 | |
| 55 | const fileInput = document.getElementById('adjunto-input'); |
| 56 | fileInput?.addEventListener('change', () => { |
| 57 | const archivo = fileInput.files && fileInput.files[0]; |
| 58 | if (!archivo) return; |
| 59 | this.state.adjunto = archivo; |
| 60 | this.renderAdjuntoPendiente(); |
| 61 | fileInput.value = ''; |
| 62 | }); |
| 63 | |
| 64 | document.getElementById('adjunto-quitar')?.addEventListener('click', |
| 65 | () => this.limpiarAdjunto()); |
| 66 | }, |
| 67 | |
| 68 | renderAdjuntoPendiente() { |
| 69 | const chip = document.getElementById('adjunto-pendiente'); |
| 70 | const nombre = document.getElementById('adjunto-pendiente-nombre'); |
| 71 | const archivo = this.state.adjunto; |
| 72 | if (!chip || !nombre || !archivo) return; |
| 73 | nombre.textContent = `${archivo.name} (${this.formatBytes(archivo.size)})`; |
| 74 | chip.hidden = false; |
| 75 | }, |
| 76 | |
| 77 | limpiarAdjunto() { |
| 78 | this.state.adjunto = null; |
| 79 | const chip = document.getElementById('adjunto-pendiente'); |
| 80 | if (chip) chip.hidden = true; |
| 81 | }, |
| 82 | |
| 83 | async loadConversaciones() { |
| 84 | try { |
| 85 | const res = await API.request('/mensajes'); |
| 86 | this.state.conversaciones = this.toArray(res); |
| 87 | } catch (e) { |
| 88 | this.state.conversaciones = []; |
| 89 | } |
| 90 | this.renderConversaciones(); |
| 91 | }, |
| 92 | |
| 93 | renderConversaciones() { |
| 94 | const host = document.getElementById('conversacion-list'); |
| 95 | if (!host) return; |
| 96 | |
| 97 | const q = (document.getElementById('chat-search')?.value || '').trim().toLowerCase(); |
| 98 | const items = this.state.conversaciones.filter(c => { |
| 99 | const nombre = this.nombreConv(c).toLowerCase(); |
| 100 | const preview = this.field(c, 'preview').toLowerCase(); |
| 101 | return !q || nombre.includes(q) || preview.includes(q); |
| 102 | }); |
| 103 | |
| 104 | if (!items.length) { host.innerHTML = ''; return; } |
| 105 | |
| 106 | host.innerHTML = items.map(c => { |
| 107 | const id = this.field(c, 'id'); |
| 108 | const active = this.state.seleccionada && String(this.state.seleccionada) === String(id) ? ' active' : ''; |
| 109 | const profCls = this.esProf(c) ? ' prof' : ''; |
| 110 | const tiempo = this.field(c, 'tiempo') || this.field(c, 'fechaRelativa'); |
| 111 | const preview = this.field(c, 'preview'); |
| 112 | const nombre = this.nombreConv(c); |
| 113 | return ` |
| 114 | <div class="chat-item${active}" data-id="${this.esc(id)}"> |
| 115 | ${Utils.avatarHtml(nombre, c.foto, `avatar${profCls}`, nombre)} |
| 116 | <div class="chat-item-info"> |
| 117 | <div class="chat-item-header"> |
| 118 | <strong>${this.esc(nombre)}</strong> |
| 119 | ${tiempo ? `<span>${this.esc(tiempo)}</span>` : ''} |
| 120 | </div> |
| 121 | <p class="chat-item-preview">${this.esc(preview)}</p> |
| 122 | </div> |
| 123 | </div>`; |
| 124 | }).join(''); |
| 125 | }, |
| 126 | |
| 127 | async seleccionar(id) { |
| 128 | this.state.seleccionada = id; |
| 129 | this.cerrarStream(); |
| 130 | this.renderConversaciones(); |
| 131 | |
| 132 | // Cabecera provisional con el dato de la lista; el detalle viene del backend. |
| 133 | const conv = this.state.conversaciones.find(c => String(this.field(c, 'id')) === String(id)); |
| 134 | if (conv) this.renderCabecera(conv, true); |
| 135 | |
| 136 | this.setEntradaHabilitada(false); |
| 137 | this.renderMensajes([]); |
| 138 | |
| 139 | let data; |
| 140 | try { |
| 141 | const res = await API.request(`/mensajes/${encodeURIComponent(id)}`); |
| 142 | data = res && res.data != null ? res.data : res; |
| 143 | } catch (e) { |
| 144 | // El endpoint se implementará en el backend. La UI mantiene estados vacíos. |
| 145 | data = null; |
| 146 | } |
| 147 | |
| 148 | if (!data) return; |
| 149 | |
| 150 | // Refresca cabecera con el detalle completo si viene. |
| 151 | this.renderCabecera(data, false); |
| 152 | const mensajes = Array.isArray(data.mensajes) ? data.mensajes : (Array.isArray(data) ? data : []); |
| 153 | this.renderMensajes(mensajes); |
| 154 | this.setEntradaHabilitada(true); |
| 155 | |
| 156 | // Marca la conversación como leída para limpiar el badge de la campana. |
| 157 | this.marcarLeido(id); |
| 158 | |
| 159 | // Conexión en tiempo real (SSE) a la conversación abierta. |
| 160 | this.abrirStream(id); |
| 161 | }, |
| 162 | |
| 163 | cerrarStream() { |
| 164 | if (this.state.eventSource) { |
| 165 | this.state.eventSource.close(); |
| 166 | this.state.eventSource = null; |
| 167 | } |
| 168 | }, |
| 169 | |
| 170 | abrirStream(id) { |
| 171 | const token = localStorage.getItem('token'); |
| 172 | if (!token) return; |
| 173 | |
| 174 | const source = new EventSource(`${API_BASE_URL}/mensajes/${encodeURIComponent(id)}/stream?token=${encodeURIComponent(token)}`); |
| 175 | source.addEventListener('mensaje', () => { |
| 176 | if (String(this.state.seleccionada) !== String(id)) return; |
| 177 | this.refrescarConversacion(id); |
| 178 | this.marcarLeido(id); |
| 179 | this.loadConversaciones(); |
| 180 | if (window.Layout && Layout.refreshNotificaciones) Layout.refreshNotificaciones(); |
| 181 | }); |
| 182 | // Sin cierre manual: EventSource reconecta automáticamente ante fallos transitorios. |
| 183 | this.state.eventSource = source; |
| 184 | }, |
| 185 | |
| 186 | async refrescarConversacion(id) { |
| 187 | if (String(this.state.seleccionada) !== String(id)) return; |
| 188 | try { |
| 189 | const res = await API.request(`/mensajes/${encodeURIComponent(id)}`); |
| 190 | const data = res && res.data != null ? res.data : res; |
| 191 | if (!data) return; |
| 192 | this.renderCabecera(data, false); |
| 193 | const mensajes = Array.isArray(data.mensajes) ? data.mensajes : []; |
| 194 | this.renderMensajes(mensajes); |
| 195 | } catch (e) { |
| 196 | // Se ignora; el stream sigue activo y reintentará en el siguiente evento. |
| 197 | } |
| 198 | }, |
| 199 | |
| 200 | renderCabecera(data, fromList) { |
| 201 | const host = document.getElementById('chat-header'); |
| 202 | if (!host) return; |
| 203 | const nombre = this.nombreConv(data); |
| 204 | const profCls = this.esProf(data) ? ' prof' : ''; |
| 205 | const sub = this.field(data, 'subtitulo') || (fromList ? '' : ''); |
| 206 | const meId = Auth.getUser() && Auth.getUser().id; |
| 207 | const idUsuario = data.idUsuario; |
| 208 | const verPerfil = (idUsuario && String(idUsuario) !== String(meId)) |
| 209 | ? `<a class="btn-clean" href="usuario.html?id=${encodeURIComponent(idUsuario)}">Ver Perfil Académico</a>` |
| 210 | : ''; |
| 211 | |
| 212 | host.innerHTML = ` |
| 213 | <div class="chat-header-user"> |
| 214 | ${Utils.avatarHtml(nombre, data.foto, `avatar${profCls}`, nombre)} |
| 215 | <div> |
| 216 | <h3>${this.esc(nombre)}</h3> |
| 217 | ${sub ? `<span>${this.esc(sub)}</span>` : ''} |
| 218 | </div> |
| 219 | </div> |
| 220 | <div>${verPerfil}</div> |
| 221 | `; |
| 222 | }, |
| 223 | |
| 224 | renderMensajes(mensajes) { |
| 225 | const host = document.getElementById('chat-body'); |
| 226 | const empty = document.getElementById('chat-empty'); |
| 227 | if (!host) return; |
| 228 | |
| 229 | if (!mensajes.length) { |
| 230 | host.innerHTML = ''; |
| 231 | if (empty) host.appendChild(empty); |
| 232 | return; |
| 233 | } |
| 234 | |
| 235 | if (empty) empty.remove(); |
| 236 | host.innerHTML = mensajes.map(m => this.messageEl(m)).join(''); |
| 237 | host.scrollTop = host.scrollHeight; |
| 238 | }, |
| 239 | |
| 240 | messageEl(m) { |
| 241 | const texto = this.field(m, 'texto') || this.field(m, 'contenido'); |
| 242 | const tiempo = this.field(m, 'tiempo') || this.field(m, 'fechaRelativa'); |
| 243 | const enviado = m.enviado === true || m.enviado === 'true' || m.propio === true; |
| 244 | const cls = enviado ? 'sent' : 'received'; |
| 245 | const adjunto = m.adjuntoNombre |
| 246 | ? ` |
| 247 | <div class="message-attachment"> |
| 248 | <div class="attachment-info"> |
| 249 | <span class="attachment-icon">${this.iconoAdjunto(m.adjuntoNombre)}</span> |
| 250 | <span> |
| 251 | <strong class="attachment-nombre">${this.esc(m.adjuntoNombre)}</strong> |
| 252 | <span class="attachment-peso">${m.adjuntoTamano ? this.formatBytes(m.adjuntoTamano) : ''}</span> |
| 253 | </span> |
| 254 | </div> |
| 255 | <button type="button" class="adjunto-descargar" |
| 256 | data-id="${m.id}" data-nombre="${this.esc(m.adjuntoNombre)}" |
| 257 | data-tipo="${this.esc(m.adjuntoTipo || '')}"> |
| 258 | Descargar |
| 259 | </button> |
| 260 | </div>` |
| 261 | : ''; |
| 262 | return ` |
| 263 | <div class="message ${cls}"> |
| 264 | ${adjunto} |
| 265 | ${texto ? `<p class="message-texto">${this.esc(texto)}</p>` : ''} |
| 266 | ${tiempo ? `<span class="message-time">${this.esc(tiempo)}</span>` : ''} |
| 267 | </div>`; |
| 268 | }, |
| 269 | |
| 270 | async enviar() { |
| 271 | const input = document.getElementById('mensaje-input'); |
| 272 | if (!input) return; |
| 273 | const texto = input.value.trim(); |
| 274 | if ((!texto && !this.state.adjunto) || !this.state.seleccionada) return; |
| 275 | |
| 276 | try { |
| 277 | if (this.state.adjunto) { |
| 278 | await this.enviarAdjunto(texto); |
| 279 | } else { |
| 280 | await API.request(`/mensajes/${encodeURIComponent(this.state.seleccionada)}`, { |
| 281 | method: 'POST', |
| 282 | body: JSON.stringify({ texto }) |
| 283 | }); |
| 284 | } |
| 285 | input.value = ''; |
| 286 | this.limpiarAdjunto(); |
| 287 | await this.seleccionar(this.state.seleccionada); |
| 288 | } catch (e) { |
| 289 | const msg = (e && e.data && e.data.message) || (e && e.message) || 'No se pudo enviar el mensaje'; |
| 290 | Utils.toast(msg, 'error'); |
| 291 | } |
| 292 | }, |
| 293 | |
| 294 | async enviarAdjunto(texto) { |
| 295 | const token = localStorage.getItem('token'); |
| 296 | const formData = new FormData(); |
| 297 | formData.append('archivo', this.state.adjunto); |
| 298 | if (texto) formData.append('texto', texto); |
| 299 | |
| 300 | const response = await fetch(`${API_BASE_URL}/mensajes/${encodeURIComponent(this.state.seleccionada)}/adjunto`, { |
| 301 | method: 'POST', |
| 302 | headers: token ? { Authorization: `Bearer ${token}` } : {}, |
| 303 | body: formData |
| 304 | }); |
| 305 | const body = await response.json().catch(() => null); |
| 306 | if (!response.ok) { |
| 307 | const error = new Error((body && body.message) || `Error ${response.status}`); |
| 308 | error.status = response.status; |
| 309 | error.data = body; |
| 310 | throw error; |
| 311 | } |
| 312 | return (body && body.data !== undefined) ? body.data : body; |
| 313 | }, |
| 314 | |
| 315 | marcarLeido(id) { |
| 316 | if (!id) return; |
| 317 | API.request(`/mensajes/${encodeURIComponent(id)}/leido`, { method: 'POST' }).catch(() => { |
| 318 | // Si falla, el siguiente evento o selección lo reintentará. |
| 319 | }); |
| 320 | if (window.Layout && Layout.refreshNotificaciones) Layout.refreshNotificaciones(); |
| 321 | }, |
| 322 | |
| 323 | async descargarAdjunto(id, nombre, tipo) { |
| 324 | const token = localStorage.getItem('token'); |
| 325 | try { |
| 326 | const response = await fetch(`${API_BASE_URL}/mensajes/${encodeURIComponent(id)}/adjunto`, { |
| 327 | headers: token ? { Authorization: `Bearer ${token}` } : {} |
| 328 | }); |
| 329 | if (!response.ok) { |
| 330 | Utils.toast('No se pudo descargar el archivo', 'error'); |
| 331 | return; |
| 332 | } |
| 333 | const blob = await response.blob(); |
| 334 | const url = URL.createObjectURL(blob); |
| 335 | const a = document.createElement('a'); |
| 336 | a.href = url; |
| 337 | a.download = nombre || 'adjunto'; |
| 338 | document.body.appendChild(a); |
| 339 | a.click(); |
| 340 | a.remove(); |
| 341 | URL.revokeObjectURL(url); |
| 342 | } catch (e) { |
| 343 | Utils.toast('No se pudo descargar el archivo', 'error'); |
| 344 | } |
| 345 | }, |
| 346 | |
| 347 | setEntradaHabilitada(on) { |
| 348 | const input = document.getElementById('mensaje-input'); |
| 349 | const btn = document.getElementById('enviar-mensaje'); |
| 350 | if (input) input.disabled = !on; |
| 351 | if (btn) btn.disabled = !on; |
| 352 | }, |
| 353 | |
| 354 | // ---- helpers ---- |
| 355 | nombreConv(c) { |
| 356 | if (!c) return ''; |
| 357 | return this.field(c, 'nombre') || this.field(c, 'nombreCompleto') || (c.autor && (c.autor.nombre || c.autor.nombreCompleto)) || ''; |
| 358 | }, |
| 359 | |
| 360 | esProf(c) { |
| 361 | if (!c) return false; |
| 362 | if (c.esProf === true || c.esProfesor === true || c.rol === 'DOCENTE' || c.rol === 'PROFESOR') return true; |
| 363 | return false; |
| 364 | }, |
| 365 | |
| 366 | field(obj, key) { |
| 367 | if (!obj) return ''; |
| 368 | return obj[key] != null ? String(obj[key]) : ''; |
| 369 | }, |
| 370 | |
| 371 | toArray(res) { |
| 372 | if (!res) return []; |
| 373 | if (Array.isArray(res)) return res; |
| 374 | if (Array.isArray(res.data)) return res.data; |
| 375 | if (Array.isArray(res.contenido)) return res.contenido; |
| 376 | return []; |
| 377 | }, |
| 378 | |
| 379 | esc(str) { |
| 380 | return String(str == null ? '' : str) |
| 381 | .replace(/&/g, '&') |
| 382 | .replace(/</g, '<') |
| 383 | .replace(/>/g, '>') |
| 384 | .replace(/"/g, '"') |
| 385 | .replace(/'/g, '''); |
| 386 | }, |
| 387 | |
| 388 | formatBytes(bytes) { |
| 389 | const n = Number(bytes); |
| 390 | if (!isFinite(n) || n <= 0) return ''; |
| 391 | if (n < 1024) return `${n} B`; |
| 392 | if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`; |
| 393 | return `${(n / (1024 * 1024)).toFixed(1)} MB`; |
| 394 | }, |
| 395 | |
| 396 | iconoAdjunto(nombre) { |
| 397 | const ext = String(nombre || '').split('.').pop().toLowerCase(); |
| 398 | if (['pdf'].includes(ext)) return 'PDF'; |
| 399 | if (['doc', 'docx'].includes(ext)) return 'DOC'; |
| 400 | if (['xls', 'xlsx', 'csv'].includes(ext)) return 'XLS'; |
| 401 | if (['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg'].includes(ext)) return 'IMG'; |
| 402 | if (['zip', 'rar', '7z'].includes(ext)) return 'ZIP'; |
| 403 | return 'FILE'; |
| 404 | } |
| 405 | }; |
| 406 | |
| 407 | document.addEventListener('DOMContentLoaded', () => Mensajes.init()); |