de flujo (data-flujo + data-btn). */
function botonAccion(b, i, j, oculto) {
var etiqueta = vacio(b.texto) ? "Botón " + (j + 1) : String(b.texto);
var url = urlSegura(b.url);
var h = oculto ? " hidden" : "";
var lbl = '' + esc(etiqueta) + " ";
if (url) {
return '' +
icono(tipoIconoUrl(url)) + lbl + " ";
}
if (!vacio(b.url) || b.urlBloqueada) {
return '' + icono("url") + lbl + " ";
}
return '' +
icono("responder") + lbl + " ";
}
/* Botones dentro de la burbuja (template / quick replies). Con >3 botones
se ven 2 + "Ver todas las opciones" (abre la hoja WAM.abrirOpciones); el
resto se pinta oculto para que los data-btn sigan existiendo en la fila. */
function tarjetaBotones(botones, i) {
var bs = Array.isArray(botones) ? botones : [];
var total = Math.min(bs.length, 10);
if (!total) return "";
var plegar = total > 3;
var html = "";
for (var j = 0; j < total; j++) {
if (plegar && j === 2) {
html += '' + icono("lista") +
'Ver todas las opciones ';
}
html += botonAccion(bs[j] || {}, i, j, plegar && j >= 2);
}
return '' + html + "
";
}
/* Listener propio de B (presentación): "Ver todas las opciones" abre la hoja. */
function activarPlegado(li, m) {
var toggle = li.querySelector("[data-ver-todas]");
if (!toggle) return;
toggle.addEventListener("click", function () { WAM.abrirOpciones(m, li); });
}
/* Header compartido por template y buttons: imagen / vídeo / documento / texto. */
function headerHTML(m) {
var ht = m.headerTipo || "";
if (ht === "imagen") {
var src = mediaSrc(m.headerMedia, vacio(m.headerTexto) ? "imagen" : m.headerTexto);
return ' ';
}
if (ht === "video") {
var srcv = srcSeguro(m.headerMedia);
var nodo = srcv
? ' '
: ' ';
return '' + nodo + insigniaPlay(40) + "
";
}
if (ht === "documento") {
var nom = vacio(m.headerTexto) ? "documento.pdf" : String(m.headerTexto);
return '";
}
if (ht === "texto") {
return vacio(m.headerTexto) ? "" : '";
}
return "";
}
/* Pie común: footer gris + hora/ticks */
function pieBurbuja(m, footer) {
return '' + (vacio(footer) ? "" : '") + metaHTML(m) + "
";
}
function cuerpoTpl(texto) {
return vacio(texto) ? "" : '' + formatear(texto) + "
";
}
/* template / buttons: header → body → footer+hora → separador → botones,
todo en una sola burbuja con cola (así se ven igual en iOS). */
function tplHTML(m, i) {
return '' + headerHTML(m) + cuerpoTpl(m.body) +
pieBurbuja(m, m.footer) + tarjetaBotones(m.botones, i) + "
";
}
/* list: header + body + footer + botón ≡. Sin data-btn/sec/fila/card:
es el marcador "plano" que main.js resuelve abriendo la hoja. */
function listHTML(m, i) {
var cabecera = vacio(m.header) ? "" : '";
var etiqueta = vacio(m.boton) ? "Ver opciones" : String(m.boton);
return '' + cabecera + cuerpoTpl(m.body) + pieBurbuja(m, m.footer) +
'
' + icono("lista") +
'' + esc(etiqueta) + "
";
}
/* cta_url: una sola burbuja (media inset, body, footer, hora, botón ↗).
Sin url válida el botón queda inerte. */
function ctaHTML(m) {
var media = vacio(m.headerMedia) ? "" :
' ';
var etiqueta = vacio(m.textoBoton) ? "Abrir enlace" : String(m.textoBoton);
var url = urlSegura(m.url);
var lbl = '' + esc(etiqueta) + " ";
var boton = url
? '' + icono(tipoIconoUrl(url)) + lbl + " "
: '' + icono("url") + lbl + " ";
return '' + media + cuerpoTpl(m.body) + pieBurbuja(m, m.footer) +
'
' + boton + "
";
}
/* carousel: burbuja de body + tarjetas horizontales (sin puntos, sin cola) */
function carouselHTML(m, i) {
var cards = Array.isArray(m.cards) ? m.cards : [];
var html = '';
if (!vacio(m.body)) {
html += '
' + formatear(m.body) + metaHTML(m) + "
";
}
html += '
';
for (var j = 0; j < cards.length; j++) {
var c = cards[j] || {};
var media = mediaSrc(c.media, vacio(c.texto) ? "card" : c.texto);
var lbl = '
' + esc(vacio(c.textoBoton) ? "Ver más" : c.textoBoton) + " ";
var url = urlSegura(c.url);
var boton;
if (url) boton = '
' + icono(tipoIconoUrl(url)) + lbl + " ";
else if (!vacio(c.url) || c.urlBloqueada) boton = '
' + icono("url") + lbl + " ";
else boton = '
' + icono("responder") + lbl + " ";
html += '
' +
'
' +
(vacio(c.texto) ? "" : '
' + formatear(c.texto) + "
") +
'
' + boton + "
";
}
return html + "
";
}
/* Fecha "Hoy" + aviso de cifrado de negocio (se omite con cuenta.avisoCifrado === false) */
function nodosSistema() {
var c = estado().cuenta || {};
if (c.avisoCifrado === false) return [];
var fecha = document.createElement("li");
fecha.className = "wa-sistema";
fecha.innerHTML = 'Hoy ';
var aviso = document.createElement("li");
aviso.className = "wa-sistema";
aviso.innerHTML = '' + svgCandado() +
"Este negocio usa un servicio seguro de Meta para gestionar este chat. Más información
";
return [fecha, aviso];
}
function asegurarSistema(cuerpo) {
if (cuerpo.querySelector(".wa-sistema")) return;
var nodos = nodosSistema();
for (var k = nodos.length - 1; k >= 0; k--) cuerpo.insertBefore(nodos[k], cuerpo.firstChild);
}
/* ------------------------------ API pública ------------------------------ */
function textoSubtitulo() {
if (ESCRIBIENDO) return "escribiendo…";
var c = estado().cuenta || {};
return vacio(c.subtitulo) ? "en línea" : String(c.subtitulo);
}
/* Avatar/nombre/subtítulo/verificado en #chat-header (chevron + iconos) */
WAM.renderHeader = function () {
var header = byId("chat-header");
if (!header) return;
var c = estado().cuenta || {};
var nombre = vacio(c.nombre) ? "Chat" : String(c.nombre);
var avatar = mediaSrc(c.avatar, nombre);
header.innerHTML =
'' + svgChevronAtras() + " " +
' ' +
'" +
'' + svgVideoLlamada() + " " +
'' + svgLlamada(24) + " ";
};
/* li.wa-row con todo el contenido del tipo + data-* para la interactividad.
Clases de grupo: .grupo (primero tras cambio de emisor) y .ultimo (último
del grupo: lleva la cola). anadirMensajeDOM las recalcula según el DOM. */
WAM.renderMensaje = function (msg, i) {
var m = msg || {};
var idx = typeof i === "number" ? i : 0;
var dir = m.dir === "out" ? "out" : "in";
var mensajes = estado().mensajes || [];
var prev = idx > 0 ? mensajes[idx - 1] : null;
var sig = mensajes[idx + 1];
var grupo = !prev || prev.dir !== m.dir ? " grupo" : "";
var ultimo = !sig || sig.dir !== m.dir ? " ultimo" : "";
var cuerpo;
switch (m.tipo) {
case "image": cuerpo = burbujaMedia(m, "imagen"); break;
case "video": cuerpo = burbujaMedia(m, "video"); break;
case "audio": cuerpo = burbujaAudio(m); break;
case "document": cuerpo = burbujaDocumento(m); break;
case "sticker": cuerpo = stickerHTML(m); break;
case "location": cuerpo = burbujaUbicacion(m); break;
case "contact": cuerpo = burbujaContacto(m); break;
case "template":
case "buttons": cuerpo = tplHTML(m, idx); break;
case "list": cuerpo = listHTML(m, idx); break;
case "cta_url": cuerpo = ctaHTML(m); break;
case "carousel": cuerpo = carouselHTML(m, idx); break;
default: cuerpo = burbujaTexto(m);
}
var li = document.createElement("li");
li.className = "wa-row " + dir + grupo + ultimo;
if (!vacio(m.id)) li.setAttribute("data-id", String(m.id));
li.innerHTML = cuerpo;
activarPlegado(li, m);
/* Re-render parcial (editor) del mensaje cuya hoja está abierta: la hoja
quedaría desfasada (títulos viejos, fila destino fuera del DOM). */
if (HOJA_ID && !vacio(m.id) && String(m.id) === HOJA_ID) WAM.cerrarSheet();
observarAltura(li);
return li;
};
/* renderHeader() + repinta TODOS los mensajes en #chat-cuerpo.
Cierra la hoja abierta: sus índices dejarían de ser válidos. */
WAM.render = function () {
WAM.cerrarSheet();
WAM.renderHeader();
var cuerpo = byId("chat-cuerpo");
if (!cuerpo) return;
var ind = byId("chat-escribiendo");
var indEnCuerpo = !!(ind && ind.parentNode === cuerpo);
var mensajes = estado().mensajes || [];
var frag = document.createDocumentFragment();
var sis = nodosSistema();
for (var s = 0; s < sis.length; s++) frag.appendChild(sis[s]);
for (var i = 0; i < mensajes.length; i++) frag.appendChild(WAM.renderMensaje(mensajes[i], i));
cuerpo.innerHTML = "";
if (RO) { RO.disconnect(); RO.observe(cuerpo); }
if (indEnCuerpo && ind) frag.appendChild(ind); /* conserva el indicador */
cuerpo.appendChild(frag);
if (RO) for (var k = 0; k < cuerpo.children.length; k++) RO.observe(cuerpo.children[k]);
WAM.scrollAbajo();
};
/* Última fila de mensaje presente en el DOM (excluye sistema y typing) */
function ultimaFila(cuerpo) {
var filas = cuerpo.children;
for (var k = filas.length - 1; k >= 0; k--) {
var f = filas[k];
if (f.id !== "chat-escribiendo" && f.classList.contains("wa-row")) return f;
}
return null;
}
/* append + scrollAbajo (lo usa la interactividad, sin re-render total) */
WAM.anadirMensajeDOM = function (msg) {
var cuerpo = byId("chat-cuerpo");
if (!cuerpo) return;
asegurarSistema(cuerpo);
var mensajes = estado().mensajes || [];
var i = mensajes.indexOf ? mensajes.indexOf(msg) : -1;
if (i < 0) i = mensajes.length;
var nodo = WAM.renderMensaje(msg, i);
/* grupo/cola según lo que ya está pintado (demo paso a paso, sesión) */
var prev = ultimaFila(cuerpo);
var dir = msg && msg.dir === "out" ? "out" : "in";
var mismo = !!(prev && prev.classList.contains(dir));
nodo.classList.toggle("grupo", !mismo);
nodo.classList.add("ultimo");
if (mismo) prev.classList.remove("ultimo");
var ind = byId("chat-escribiendo");
if (ind && ind.parentNode === cuerpo && !ind.hidden) cuerpo.insertBefore(nodo, ind);
else cuerpo.appendChild(nodo);
WAM.scrollAbajo();
};
/* body[data-tema] + clase oscuro en #pantalla */
WAM.setTema = function (tema) {
var oscuro = tema === "oscuro";
if (document.body) document.body.setAttribute("data-tema", oscuro ? "oscuro" : "claro");
var pantalla = byId("pantalla");
if (pantalla) {
if (oscuro) pantalla.classList.add("oscuro");
else pantalla.classList.remove("oscuro");
}
};
var HTML_ESCRIBIENDO = '' +
'
';
/* Muestra/oculta #chat-escribiendo; subtítulo "escribiendo…" en verde */
WAM.setEscribiendo = function (visible) {
ESCRIBIENDO = !!visible;
var cuerpo = byId("chat-cuerpo");
var ind = byId("chat-escribiendo");
if (!ind && cuerpo) {
ind = document.createElement("li");
ind.id = "chat-escribiendo";
cuerpo.appendChild(ind);
}
if (ind) {
/* Respeta la colocación de A (fuera de #chat-cuerpo): rellena contenido,
alterna hidden y hereda la fila visual (con cola). */
ind.className = "wa-row in ultimo";
if (String(ind.innerHTML).indexOf("wa-bubble-escribiendo") === -1) ind.innerHTML = HTML_ESCRIBIENDO;
ind.hidden = !ESCRIBIENDO;
if (ESCRIBIENDO && ind.parentNode === cuerpo) cuerpo.appendChild(ind); /* al final */
}
var sub = byId("chat-subtitulo");
if (sub) {
sub.textContent = textoSubtitulo();
sub.className = "header-sub" + (ESCRIBIENDO ? " wa-sub-verde" : "");
}
if (ESCRIBIENDO) WAM.scrollAbajo();
};
/* Desplazamiento INSTANTÁNEO al final (un scroll suave se quedaría en el
máximo del momento si el contenido crece durante la animación). */
WAM.scrollAbajo = function () {
var cuerpo = byId("chat-cuerpo");
if (!cuerpo) return;
vigilarCuerpo(cuerpo);
var prev = cuerpo.style.scrollBehavior;
cuerpo.style.scrollBehavior = "auto";
cuerpo.scrollTop = cuerpo.scrollHeight;
cuerpo.style.scrollBehavior = prev;
PEGADO = true;
};
/* "Pegado abajo" (como WhatsApp): mientras el usuario no suba, cualquier
crecimiento del chat (imágenes/SVG que decodifican tarde, vídeo, la
pestaña Vista previa que pasa de oculta a visible, una fila repintada)
vuelve a bajar. Si el usuario sube, se respeta su posición. */
var PEGADO = true;
var RO = null;
function distanciaFondo(c) { return c.scrollHeight - c.scrollTop - c.clientHeight; }
function reajustar() {
var c = byId("chat-cuerpo");
if (c && PEGADO && distanciaFondo(c) > 1) WAM.scrollAbajo();
}
function vigilarCuerpo(cuerpo) {
if (cuerpo.__wamVigilado) return;
cuerpo.__wamVigilado = true;
cuerpo.addEventListener("scroll", function () {
if (cuerpo.clientHeight > 0) PEGADO = distanciaFondo(cuerpo) < 60;
}, { passive: true });
if (typeof ResizeObserver === "function") {
RO = new ResizeObserver(reajustar);
RO.observe(cuerpo);
for (var k = 0; k < cuerpo.children.length; k++) RO.observe(cuerpo.children[k]);
}
}
/* Observa la altura de una fila (aunque aún no esté en el DOM). Sin
ResizeObserver, recurre al load de sus imágenes/vídeos. */
function observarAltura(li) {
if (RO) { RO.observe(li); return; }
if (typeof ResizeObserver === "function") return; /* aún sin vigilar: vigilarCuerpo lo hará */
var medios = li.querySelectorAll("img, video");
for (var k = 0; k < medios.length; k++) {
medios[k].addEventListener("load", reajustar, { once: true });
medios[k].addEventListener("loadedmetadata", reajustar, { once: true });
}
}
/* Hoja inferior iOS genérica (B la crea/destruye) en #pantalla.
alClic: manejador opcional de presentación para las filas. */
function montarHoja(titulo, contenido, pie, alClic, opts) {
var pantalla = byId("pantalla");
if (!pantalla) return null;
WAM.cerrarSheet();
var hoja = document.createElement("div");
hoja.id = "sheet";
hoja.className = "sheet";
hoja.innerHTML = '
' +
'' +
'
' + esc(titulo) + " " +
'' + svgX() + "
" +
'
' + contenido + "
" +
(pie ? '
' + esc(pie) + "
" : "") + "
";
pantalla.appendChild(hoja);
/* Foco (diálogo modal): se guarda el origen, se enfoca la primera fila
activa (o la X) y Tab / Mayús+Tab quedan dentro del panel. Durante
«Reproducir demo» (o con opts.sinFoco) no se roba el foco. */
ORIGEN_HOJA = document.activeElement && document.activeElement !== document.body ? document.activeElement : null;
var enDemo = typeof WAM.demoActiva === "function" && WAM.demoActiva();
if (!enDemo && !(opts && opts.sinFoco)) {
var primera = hoja.querySelector('.sheet-fila:not([aria-disabled="true"]):not(span)') || hoja.querySelector(".sheet-cerrar");
if (primera) { try { primera.focus({ preventScroll: true }); } catch (e) { primera.focus(); } }
FOCO_EN_HOJA = !!primera;
}
hoja.addEventListener("keydown", function (e) {
if (e.key !== "Tab") return;
var panel = hoja.querySelector(".sheet-panel");
var focos = panel ? Array.prototype.filter.call(panel.querySelectorAll("a[href], button:not([disabled])"), function (x) {
return !x.hidden;
}) : [];
if (!focos.length) return;
var ini = focos[0], fin = focos[focos.length - 1], act = document.activeElement;
var dentro = panel.contains(act);
if (e.shiftKey && (act === ini || !dentro)) { e.preventDefault(); fin.focus(); }
else if (!e.shiftKey && (act === fin || !dentro)) { e.preventDefault(); ini.focus(); }
});
hoja.addEventListener("click", function (e) {
var t = e.target;
if (!t || !t.closest) return;
if (t.closest(".sheet-fondo") || t.closest(".sheet-cerrar")) { WAM.cerrarSheet(); return; }
if (alClic) alClic(e, t);
});
return hoja;
}
/* Hoja de la lista (i = índice del guion o el propio Mensaje de sesión):
título = texto del botón; filas título/descripción; pie de ayuda. */
WAM.abrirSheet = function (i) {
var mensajes = estado().mensajes || [];
var m = (i && typeof i === "object" ? i : (typeof i === "number" ? mensajes[i] : null)) || {};
var flujo = typeof i === "number" ? String(i) : "";
var secciones = Array.isArray(m.secciones) ? m.secciones : [];
var titulo = vacio(m.boton) ? "Ver opciones" : String(m.boton);
var html = "", total = 0, s, f;
for (s = 0; s < secciones.length && total < 10; s++) {
var sec = secciones[s] || {};
var filas = Array.isArray(sec.filas) ? sec.filas : [];
if (!vacio(sec.titulo)) html += '' + esc(sec.titulo) + "
";
html += '';
for (f = 0; f < filas.length && total < 10; f++) {
var fila = filas[f] || {};
total++;
html += '' +
'' + esc(vacio(fila.titulo) ? "Opción" : fila.titulo) + " " +
(vacio(fila.descripcion) ? "" : '' + esc(fila.descripcion) + " ") +
" ";
}
html += "
";
}
if (!total) html = 'Sin opciones
';
montarHoja(titulo, html, total ? "Toca un elemento para seleccionarlo" : "");
HOJA_ID = vacio(m.id) ? "" : String(m.id);
};
/* Hoja "Todas las opciones" de un mensaje con >3 botones (m = Mensaje o
índice; fila = su li.wa-row). URL/llamada primero, separador, respuestas.
Pulsar una respuesta reenvía el clic al botón [data-btn] (oculto) de la
burbuja, así main.js/interactivity.js lo atienden igual que en línea. */
/* Última li.wa-row pintada de ese mensaje (o null) */
function filaDe(msg) {
if (!msg || vacio(msg.id)) return null;
var filas = document.querySelectorAll("#chat-cuerpo .wa-row");
for (var k = filas.length - 1; k >= 0; k--) if (filas[k].getAttribute("data-id") === String(msg.id)) return filas[k];
return null;
}
WAM.abrirOpciones = function (m, fila) {
var mensajes = estado().mensajes || [];
var msg = (m && typeof m === "object" ? m : (typeof m === "number" ? mensajes[m] : null)) || {};
var li = fila || null;
if (!li) li = filaDe(msg);
var bs = Array.isArray(msg.botones) ? msg.botones.slice(0, 10) : [];
var enlaces = "", respuestas = "";
for (var j = 0; j < bs.length; j++) {
var b = bs[j] || {};
var etiqueta = '' +
esc(vacio(b.texto) ? "Botón " + (j + 1) : b.texto) + " ";
var url = urlSegura(b.url);
if (url) {
enlaces += '' +
icono(tipoIconoUrl(url)) + etiqueta + " ";
} else if (!vacio(b.url) || b.urlBloqueada) {
/* Igual que botonAccion: URL no permitida o bloqueada → fila inerte */
enlaces += '' +
icono("url") + etiqueta + " ";
} else {
var usado = li && li.querySelector('[data-btn="' + j + '"].usado');
respuestas += '" + icono("responder") + etiqueta + " ";
}
}
var html = (enlaces ? '' + enlaces + "
" : "") +
(respuestas ? '' + respuestas + "
" : "");
var hojaOp = montarHoja("Todas las opciones", html || 'Sin opciones
', "", function (e, t) {
if (t.closest("a.sheet-fila")) { WAM.cerrarSheet(); return; }
var op = t.closest("[data-opcion]");
if (!op) return;
e.preventDefault();
if (op.getAttribute("aria-disabled") === "true") return;
/* la fila viva AHORA (el editor puede haberla repintado con la hoja abierta) */
var viva = li && li.isConnected ? li : filaDe(msg);
var destino = viva ? viva.querySelector('[data-btn="' + op.getAttribute("data-opcion") + '"]') : null;
WAM.cerrarSheet();
if (destino) destino.click(); /* burbujea a #chat-cuerpo (delegación de main.js) */
});
if (hojaOp) HOJA_ID = vacio(msg.id) ? "" : String(msg.id);
};
WAM.cerrarSheet = function () {
var hoja = byId("sheet");
var idHoja = HOJA_ID;
var origen = ORIGEN_HOJA;
var movido = FOCO_EN_HOJA;
ORIGEN_HOJA = null;
FOCO_EN_HOJA = false;
var act = document.activeElement;
/* Safari no enfoca los botones al hacer clic: el foco cae en body */
var focoDentro = !!hoja && (hoja.contains(act) || (movido && (!act || act === document.body)));
if (hoja && hoja.parentNode) hoja.parentNode.removeChild(hoja);
HOJA_ID = "";
if (!focoDentro) return;
/* Devolver el foco: al origen si sigue en el DOM; si hubo re-render, al
mismo control en la fila nueva del mensaje; si no, a la conversación. */
var destino = origen && origen.isConnected ? origen : null;
if (!destino && idHoja) {
var filaNueva = filaDe({ id: idHoja });
if (filaNueva) destino = filaNueva.querySelector("[data-ver-todas], [data-flujo]:not([data-btn]):not([data-card])");
}
if (!destino) destino = byId("chat-cuerpo");
if (destino) { try { destino.focus({ preventScroll: true }); } catch (e) { destino.focus(); } }
};
})();