main
js 407 lines 15.3 KB
Raw
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, '&amp;')
382 .replace(/</g, '&lt;')
383 .replace(/>/g, '&gt;')
384 .replace(/"/g, '&quot;')
385 .replace(/'/g, '&#39;');
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());