Files
urieljarethandClaude Opus 5.5 875ae9ad67 Deshacer con demo activa, avisos obsoletos, hoja de opciones y foco en hojas
- Deshacer/rehacer guardan y reponen «wam-demo-activa» con cada instantánea
  ({estado, activa}); «Actualizar» pide un segundo clic si el contenido cambió
  de cuenta o de número de mensajes respecto a la demo guardada.
- WAM.puntoDeshacer cierra los avisos con acción: su «Deshacer» ya no
  desharía el cambio que anuncian.
- «Todas las opciones» replica botonAccion: URL bloqueada o no válida se
  pinta como fila inerte (aria-disabled, sin data-opcion) en el grupo de
  enlaces.
- Hojas inferiores: enfocan la primera fila (o la X), retienen Tab dentro del
  panel y devuelven el foco al cerrar (origen, fila repintada o
  #chat-cuerpo); no roban el foco durante «Reproducir demo».
- Tests e2e de regresión en tests/e2e/regresiones.spec.mjs.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-27 22:08:46 -06:00

942 lines
46 KiB
JavaScript

/* ==========================================================================
js/render.js — dueño B: pinta el chat de WhatsApp (iOS) dentro del iPhone.
API (SPEC §2): WAM.render / renderHeader / renderMensaje / anadirMensajeDOM /
setTema / setEscribiendo / scrollAbajo / abrirSheet / abrirOpciones /
cerrarSheet / tick. Campos por tipo: SPEC §1. Aspecto: docs/referencias/README.md.
Cero listeners de flujo (los clics los resuelven main.js + interactivity.js con
los data-* estampados aquí). Listeners propios, solo de presentación: cierre
de la hoja (#sheet-fondo / X / enlaces), "Ver todas las opciones" y el reenvío
del clic de una fila de esa hoja al botón (oculto) equivalente de la burbuja.
No toca DOM al cargar.
========================================================================== */
(function () {
"use strict";
window.WAM = window.WAM || {};
/* ------------------------------ helpers ------------------------------ */
function byId(id) { return document.getElementById(id); }
function esc(s) {
return String(s == null ? "" : s).replace(/[&<>"']/g, function (c) {
return { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c];
});
}
function vacio(s) { return s == null || String(s) === ""; }
function estado() { return WAM.estado || { cuenta: {}, mensajes: [] }; }
var ESCRIBIENDO = false;
var HOJA_ID = ""; /* id del mensaje cuya hoja está abierta ("" = ninguna) */
var ORIGEN_HOJA = null; /* elemento que tenía el foco al abrir la hoja */
var FOCO_EN_HOJA = false; /* la hoja movió el foco a su interior al abrirse */
/* Solo esquemas seguros en href (defensa en profundidad para JSON importados). */
function urlSegura(u) {
var s = String(u == null ? "" : u).trim();
if (/^(https?:|mailto:|tel:)/i.test(s)) return s;
/* Sin esquema ("www.cafe.com", "cafe.com/menu"): WhatsApp lo abre como https */
if (/^www\.[^\s]+$/i.test(s) || /^[\w-]+(\.[\w-]+)+(:\d+)?([\/?#][^\s]*)?$/.test(s)) return "https://" + s;
return "";
}
/* src de <img>/<video>: bloquea javascript:/vbscript: y data: que no sea media. */
function srcSeguro(u) {
var s = String(u == null ? "" : u).trim();
if (/^(javascript|vbscript):/i.test(s)) return "";
if (/^data:/i.test(s) && !/^data:(image|video|audio)\//i.test(s)) return "";
return s;
}
/* Placeholder local; en la práctica usa WAM.svgPlaceholder (js/store.js). */
function placeholder(texto) {
if (typeof WAM.svgPlaceholder === "function") {
try {
var u = WAM.svgPlaceholder(String(texto == null ? "" : texto));
if (u) return u;
} catch (e) { /* fallback abajo */ }
}
var t = esc(String(texto == null ? "" : texto).slice(0, 42));
var svg = '<svg xmlns="http://www.w3.org/2000/svg" width="320" height="240">' +
'<rect width="320" height="240" fill="#e7e3da"/>' +
'<rect x="122" y="70" width="76" height="60" rx="9" fill="none" stroke="#b9b2a4" stroke-width="4"/>' +
'<circle cx="143" cy="89" r="6" fill="#b9b2a4"/>' +
'<path d="M126 122 L148 104 L163 117 L176 106 L194 121" fill="none" stroke="#b9b2a4" stroke-width="4" stroke-linejoin="round"/>' +
'<text x="160" y="176" text-anchor="middle" font-family="Arial, sans-serif" font-size="13" fill="#9a927f">' + t + "</text></svg>";
return "data:image/svg+xml," + encodeURIComponent(svg);
}
/* Media con fallback al placeholder si el src está vacío o no es aceptable */
function mediaSrc(src, etiqueta) {
var s = srcSeguro(src);
return s ? s : placeholder(etiqueta);
}
/* ------------------------------ iconos SVG inline ------------------------------ */
function svgTrazo(px, d, grosor) {
return '<svg width="' + px + '" height="' + px + '" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="' + (grosor || 1.6) +
'" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' + d + "</svg>";
}
function svgChevronAtras() {
return '<svg width="13" height="22" viewBox="0 0 13 22" aria-hidden="true"><path d="M11 1.8 2 11l9 9.2" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"/></svg>';
}
function svgChevronDer(px) {
var s = px || 14;
return '<svg width="' + Math.round(s * .6) + '" height="' + s + '" viewBox="0 0 12 20" aria-hidden="true"><path d="M2 2 10 10l-8 8" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"/></svg>';
}
/* Videocámara y teléfono de línea (header iOS) */
function svgVideoLlamada() {
return svgTrazo(28, '<rect x="2.2" y="6.2" width="13.6" height="11.6" rx="2.6"/><path d="M15.8 10.2 21 7.2v9.6l-5.2-3Z"/>', 1.5);
}
var PATH_TEL = '<path d="M8.1 3.6c-.5-.6-1.4-.7-2-.2L4.4 4.9c-.9.8-1.2 2-.8 3.1 2.2 6 6.4 10.2 12.4 12.4 1.1.4 2.3.1 3.1-.8l1.5-1.7c.5-.6.4-1.5-.2-2l-2.6-2.1c-.6-.5-1.4-.5-2 0l-1.3 1.2c-2.2-1.1-4-2.9-5.1-5.1l1.2-1.3c.5-.6.5-1.4 0-2Z"/>';
function svgLlamada(px) { return svgTrazo(px || 24, PATH_TEL, 1.5); }
/* Insignia de verificado (Meta Verified, azul; tono [estimado]) */
function svgVerificado() {
return '<svg width="15" height="15" viewBox="0 0 16 16" aria-hidden="true">' +
'<polygon points="16,8 14.1,10.53 13.66,13.66 10.53,14.1 8,16 5.47,14.1 2.34,13.66 1.9,10.53 0,8 1.9,5.47 2.34,2.34 5.47,1.9 8,0 10.53,1.9 13.66,2.34 14.1,5.47" fill="#0866ff"/>' +
'<path d="M4.8 8.2 7 10.4 11.2 5.9" fill="none" stroke="#fff" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/></svg>';
}
function svgPlay(px) {
var s = px || 18;
return '<svg width="' + s + '" height="' + s + '" viewBox="0 0 24 24" aria-hidden="true"><path d="M7 4.6c0-.8.9-1.3 1.6-.9l11.1 7.4c.6.4.6 1.4 0 1.8L8.6 20.3c-.7.4-1.6-.1-1.6-.9Z" fill="currentColor"/></svg>';
}
function svgMicrofono(px) {
var s = px || 19;
return '<svg width="' + s + '" height="' + s + '" viewBox="0 0 24 24" aria-hidden="true"><path d="M12 14.6a3.4 3.4 0 0 0 3.4-3.4V5.9a3.4 3.4 0 0 0-6.8 0v5.3a3.4 3.4 0 0 0 3.4 3.4Z" fill="currentColor"/><path d="M6.2 11.2a5.8 5.8 0 0 0 11.6 0M12 17v3.2" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/></svg>';
}
function svgMusica(px) {
var s = px || 22;
return '<svg width="' + s + '" height="' + s + '" viewBox="0 0 24 24" aria-hidden="true"><path d="M4.5 16v-3a7.5 7.5 0 0 1 15 0v3" fill="none" stroke="currentColor" stroke-width="1.8"/><rect x="3.5" y="14" width="4.2" height="6.4" rx="1.6" fill="currentColor"/><rect x="16.3" y="14" width="4.2" height="6.4" rx="1.6" fill="currentColor"/></svg>';
}
function svgDescarga() {
return svgTrazo(16, '<path d="M12 5v11M7.5 11.5 12 16l4.5-4.5"/>', 1.9);
}
function svgCamaraVideo() {
return '<svg width="16" height="12" viewBox="0 0 16 12" aria-hidden="true"><rect x="0" y="1.5" width="10.5" height="9" rx="2" fill="currentColor"/><path d="M11.5 4.6 16 2v8l-4.5-2.6Z" fill="currentColor"/></svg>';
}
function svgCandado() {
return '<svg width="10" height="11" viewBox="0 0 10 12" aria-hidden="true"><rect x="0.5" y="5" width="9" height="6.6" rx="1.4" fill="currentColor"/><path d="M2.6 5V3.6a2.4 2.4 0 0 1 4.8 0V5" fill="none" stroke="currentColor" stroke-width="1.3"/></svg>';
}
function svgSilueta() {
return '<svg width="36" height="36" viewBox="0 0 36 36" aria-hidden="true"><circle cx="18" cy="14" r="7" fill="currentColor"/><path d="M4 36c1.5-8 7.2-12 14-12s12.5 4 14 12Z" fill="currentColor"/></svg>';
}
function svgX() {
return '<svg width="13" height="13" viewBox="0 0 14 14" aria-hidden="true"><path d="M2 2l10 10M12 2 2 12" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"/></svg>';
}
/* Iconos de los botones de acción (20 px, verde --wa-action) */
var ICONOS = {
responder: '<path d="M9.5 5.5 4 11l5.5 5.5"/><path d="M4 11h9.5a6.5 6.5 0 0 1 6.5 6.5v1"/>',
url: '<rect x="3.5" y="3.5" width="17" height="17" rx="4"/><path d="M9.5 14.5 15 9M10.2 9H15v4.8"/>',
llamada: PATH_TEL,
lista: '<circle cx="4.6" cy="6.5" r=".9" fill="currentColor"/><circle cx="4.6" cy="12" r=".9" fill="currentColor"/><circle cx="4.6" cy="17.5" r=".9" fill="currentColor"/><path d="M9 6.5h11M9 12h11M9 17.5h11"/>'
};
function icono(tipo) {
return '<span class="qr-ico">' + svgTrazo(20, ICONOS[tipo] || ICONOS.responder, 1.6) + "</span>";
}
function tipoIconoUrl(url) { return /^tel:/i.test(url) ? "llamada" : "url"; }
/* Página blanca con esquina doblada y la extensión en gris (documento iOS) */
function extDe(nombre) {
var n = String(nombre == null ? "" : nombre);
var partes = n.split(".");
return partes.length > 1 ? partes.pop().toLowerCase() : "doc";
}
function docIcon(ext) {
var etiqueta = ext.length <= 4 ? ext.toUpperCase() : "DOC";
return '<svg width="30" height="38" viewBox="0 0 30 38" aria-hidden="true">' +
'<path d="M1.5 3.5a2.5 2.5 0 0 1 2.5-2.5h15.5l9 9v24.5a2.5 2.5 0 0 1-2.5 2.5H4a2.5 2.5 0 0 1-2.5-2.5Z" fill="#fff" stroke="#e0e0e0"/>' +
'<path d="M19.5 1v6.5a2.5 2.5 0 0 0 2.5 2.5h6.5" fill="#f1f1f1" stroke="#e0e0e0"/>' +
'<text x="15" y="29" text-anchor="middle" font-family="-apple-system, Helvetica, Arial, sans-serif" font-size="9" font-weight="600" fill="#8a8a8a">' + esc(etiqueta) + "</text></svg>";
}
/* Mapa estático con pin rojo (data-URI sin red, cacheado) */
var MAPA = null;
function svgMapa() {
if (MAPA) return MAPA;
var svg = '<svg xmlns="http://www.w3.org/2000/svg" width="320" height="170" viewBox="0 0 320 170">' +
'<rect width="320" height="170" fill="#f2efe9"/>' +
'<path d="M0 0h90L60 55H0Z" fill="#e3efdc"/>' +
'<path d="M230 170v-60l90-22v82Z" fill="#e3efdc"/>' +
'<path d="M0 132q80-24 160-6t160-12v56H0Z" fill="#aadaff"/>' +
'<path d="M118 0q10 55-8 110t-30 60" stroke="#fff" stroke-width="10" fill="none"/>' +
'<path d="M0 78q90 16 170 2t150-28" stroke="#fde9a6" stroke-width="9" fill="none"/>' +
'<path d="M205 0q-6 45 12 85t40 85" stroke="#fff" stroke-width="7" fill="none"/>' +
'<path d="M40 36q60 22 120 11" stroke="#fff" stroke-width="5" fill="none"/>' +
'<path d="M252 36q40 9 68 5" stroke="#fff" stroke-width="5" fill="none"/>' +
'<ellipse cx="160" cy="93" rx="10" ry="3.5" fill="rgba(0,0,0,.2)"/>' +
'<path d="M160 30c-12.7 0-21 9-21 20 0 13.5 21 42 21 42s21-28.5 21-42c0-11-8.3-20-21-20Z" fill="#ea4335"/>' +
'<circle cx="160" cy="50" r="7" fill="#a52714"/>' +
"</svg>";
MAPA = "data:image/svg+xml," + encodeURIComponent(svg);
return MAPA;
}
/* Avatar genérico (emisor saliente de la nota de voz), cacheado */
var AVATAR_GEN = null;
function avatarGenerico() {
if (AVATAR_GEN) return AVATAR_GEN;
var svg = '<svg xmlns="http://www.w3.org/2000/svg" width="44" height="44" viewBox="0 0 44 44">' +
'<defs><linearGradient id="g" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#a9adb5"/><stop offset="1" stop-color="#8a8e96"/></linearGradient></defs>' +
'<circle cx="22" cy="22" r="22" fill="url(#g)"/><circle cx="22" cy="17" r="8" fill="#fff"/>' +
'<path d="M7 38c2-8 8-12 15-12s13 4 15 12a22 22 0 0 1-30 0Z" fill="#fff"/></svg>';
AVATAR_GEN = "data:image/svg+xml," + encodeURIComponent(svg);
return AVATAR_GEN;
}
/* ------------------------------ texto enriquecido ------------------------------ */
/* Formato WhatsApp: *negrita* _cursiva_ ~tachado~ ```bloque``` `en línea`
+ URLs hipervinculadas. Dentro del código no se aplica ningún otro formato. */
/* Marcadores de WhatsApp: el marcador va pegado a texto (sin espacio por
dentro) y por fuera no puede haber letra/número ("2 * 3 * 4" no es negrita). */
function reMarca(m) {
var e = m === "*" ? "\\*" : m; /* con flag u, "\_" y "\~" son escapes inválidos */
return new RegExp("(^|[^\\p{L}\\p{N}_" + e + "])" + e + "(?=\\S)([^" + e + "\\n]*?\\S)" + e +
"(?=[^\\p{L}\\p{N}_" + e + "]|$)", "gu");
}
var RE_NEGRITA = reMarca("*"), RE_CURSIVA = reMarca("_"), RE_TACHADO = reMarca("~");
/* Deja fuera del enlace la puntuación final y los ')' sin su '(' */
function separarUrl(u) {
var url = u, resto = "";
for (;;) {
var m = /[.,;:!?'"\]}]+$/.exec(url);
if (m) { resto = m[0] + resto; url = url.slice(0, -m[0].length); continue; }
if (/\)$/.test(url) && (url.split(")").length > url.split("(").length)) {
resto = ")" + resto; url = url.slice(0, -1); continue;
}
break;
}
return [url, resto];
}
function formatoTexto(p) {
var codigos = [];
var guardar = function (_, c) { codigos.push(c); return "\u0000" + (codigos.length - 1) + "\u0000"; };
var t = esc(p.replace(/\u0000/g, ""));
t = t.replace(/```([\s\S]+?)```/g, guardar);
t = t.replace(/`([^`\n]+)`/g, guardar);
t = t.replace(RE_NEGRITA, "$1<strong>$2</strong>");
t = t.replace(RE_CURSIVA, "$1<em>$2</em>");
t = t.replace(RE_TACHADO, "$1<del>$2</del>");
return t.replace(/\u0000(\d+)\u0000/g, function (_, n) { return "<code>" + codigos[+n] + "</code>"; });
}
function formatear(txt) {
var partes = String(txt == null ? "" : txt).split(/(https?:\/\/[^\s<>"]+)/g);
var html = "", pendiente = "";
for (var i = 0; i < partes.length; i++) {
var p = partes[i];
if (p === "") continue;
if (/^https?:\/\//.test(p)) {
var par = separarUrl(p);
if (pendiente) { html += formatoTexto(pendiente); pendiente = ""; }
if (par[0].length > p.indexOf("//") + 2) {
html += '<a class="wa-link" href="' + esc(par[0]) + '" target="_blank" rel="noopener noreferrer">' + esc(par[0]) + "</a>";
pendiente = par[1];
} else {
pendiente = p;
}
} else {
pendiente += p;
}
}
if (pendiente) html += formatoTexto(pendiente);
return html;
}
/* ------------------------------ ticks y meta ------------------------------ */
/* ✓ enviado / ✓✓ entregado (color de la hora) / ✓✓ leído (azul iOS) */
var TRAZO_TICK = '<path d="M1 5.6 4.1 8.6 10.4 1.6" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/>';
WAM.tick = function (estadoTick) {
var uno = '<svg class="tick" width="12" height="11" viewBox="0 0 11 10" aria-hidden="true">' + TRAZO_TICK + "</svg>";
var dos = '<svg class="tick" width="17" height="11" viewBox="0 0 16 10" aria-hidden="true">' + TRAZO_TICK +
'<path d="M6.2 7.4 7.3 8.6 13.6 1.6" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/></svg>';
if (estadoTick === "read") return dos.replace('class="tick"', 'class="tick tick-read"');
if (estadoTick === "delivered") return dos;
return uno;
};
/* Hora (+ ticks solo en OUT) */
function metaHTML(m, extra) {
var mm = m || {};
var html = '<span class="meta' + (extra ? " " + extra : "") + '">' + esc(vacio(mm.hora) ? "" : mm.hora);
if (mm.dir === "out") html += WAM.tick(mm.estado || "sent");
return html + "</span>";
}
/* ------------------------------ piezas de burbujas ------------------------------ */
function insigniaPlay(px) {
var chico = px && px <= 40;
return '<span class="wa-play-badge' + (chico ? " wa-play-sm" : "") + '">' + svgPlay(chico ? 18 : 22) + "</span>";
}
function burbujaTexto(m) {
return '<div class="bubble"><div class="wa-texto">' + formatear(m.texto) + metaHTML(m) + "</div></div>";
}
/* image / video: media inset 4 pt con radio 13; caption debajo o hora sobre
la media. Un <video> no decodifica el placeholder SVG: sin src se pinta
como <img> (SPEC §5.10). */
function burbujaMedia(m, tipo) {
var tieneCaption = !vacio(m.caption);
var esVideo = tipo === "video";
var src = srcSeguro(m.src);
var media;
if (esVideo) {
var nodo = src
? '<video class="wa-media" src="' + esc(src) + '" muted playsinline preload="metadata"></video>'
: '<img class="wa-media" src="' + esc(placeholder("video")) + '" alt="" draggable="false">';
var dur = vacio(m.duracion) ? "0:15" : String(m.duracion);
media = '<div class="wa-media-wrap">' + nodo + insigniaPlay(50) +
'<span class="wa-video-dur">' + svgCamaraVideo() + esc(dur) + "</span></div>";
} else {
media = '<img class="wa-media" src="' + esc(src || placeholder(m.caption || "imagen")) + '" alt="" draggable="false">';
}
var html = '<div class="bubble wa-bubble-media' + (tieneCaption ? " wa-con-caption" : "") + '">' + media;
if (tieneCaption) {
html += '<div class="wa-caption">' + formatear(m.caption) + metaHTML(m) + "</div>";
} else {
html += (esVideo ? "" : '<div class="wa-gradiente"></div>') + metaHTML(m, "meta-en-media");
}
return html + "</div>";
}
/* Waveform determinista a partir del id del mensaje (barras de 2 px) */
function waveform(m) {
var n = 34, paso = 4, alto = 20;
var semilla = 0, id = String((m && m.id) || "wam");
for (var c = 0; c < id.length; c++) semilla = (semilla + id.charCodeAt(c) * (c + 7)) % 9973;
var barras = "";
for (var i = 0; i < n; i++) {
var h = 3 + Math.abs(Math.sin(semilla * 0.37 + i * 1.7) * Math.cos(i * 0.45)) * (alto - 4);
var y = (alto - h) / 2;
barras += '<rect x="' + (i * paso).toFixed(1) + '" y="' + y.toFixed(1) + '" width="2" height="' + h.toFixed(1) + '" rx="1"/>';
}
var w = (n * paso - 2).toFixed(1);
return '<svg class="audio-wave" width="' + w + '" height="' + alto + '" viewBox="0 0 ' + w + " " + alto + '" fill="currentColor" aria-hidden="true">' + barras + "</svg>";
}
/* Nota de voz iOS: ▶ gris sin círculo, punto verde + waveform, duración debajo,
avatar del emisor con micro verde. Audio no-voz: auriculares naranjas. */
function burbujaAudio(m) {
var voz = !!m.voz;
var dur = vacio(m.duracion) ? "0:00" : String(m.duracion);
var derecha;
if (voz) {
var c = estado().cuenta || {};
var av = m.dir === "out" ? avatarGenerico() : mediaSrc(c.avatar, c.nombre || "avatar");
derecha = '<span class="audio-avatar"><img src="' + esc(av) + '" alt="" draggable="false">' +
'<span class="audio-mic">' + svgMicrofono(18) + "</span></span>";
} else {
derecha = '<span class="audio-avatar"><span class="audio-musica">' + svgMusica(22) + "</span></span>";
}
return '<div class="bubble wa-audio"><div class="audio-line">' +
'<span class="audio-play">' + svgPlay(24) + "</span>" +
'<div class="audio-centro"><div class="audio-pista"><span class="audio-punto"></span>' + waveform(m) + "</div>" +
'<div class="audio-bajo"><span class="audio-dur">' + esc(dur) + "</span>" + metaHTML(m) + "</div></div>" +
derecha + "</div></div>";
}
/* Subtítulo iOS "tamaño • extensión" (243 KB • pdf); sin tamaño, solo "PDF" */
function panelDocumento(nombre, tamano) {
var ext = extDe(nombre);
var sub = vacio(tamano) ? ext.toUpperCase() : String(tamano) + " • " + ext;
return '<div class="doc-panel">' +
'<span class="doc-icono">' + docIcon(ext) + "</span>" +
'<span class="doc-datos"><span class="doc-nombre">' + esc(nombre) + "</span>" +
'<span class="doc-sub">' + esc(sub) + "</span></span>" +
'<span class="doc-descarga">' + svgDescarga() + "</span></div>";
}
function burbujaDocumento(m) {
var nombre = vacio(m.filename) ? "documento.pdf" : String(m.filename);
var html = '<div class="bubble wa-doc">' + panelDocumento(nombre, m.tamano);
if (!vacio(m.caption)) html += '<div class="wa-caption">' + formatear(m.caption) + metaHTML(m) + "</div>";
else html += '<div class="wa-pie">' + metaHTML(m) + "</div>";
return html + "</div>";
}
function stickerHTML(m) {
var src = mediaSrc(m.src, "sticker");
return '<div class="sticker-box">' +
'<img class="sticker-img" src="' + esc(src) + '" alt="Sticker" draggable="false">' +
'<span class="sticker-hora">' + metaHTML(m) + "</span></div>";
}
function burbujaUbicacion(m) {
var tieneDatos = !vacio(m.nombre) || !vacio(m.direccion);
var html = '<div class="bubble wa-loc"><img class="loc-mapa" src="' + svgMapa() + '" alt="Ubicación" draggable="false">';
if (tieneDatos) {
html += '<div class="loc-datos">' +
(vacio(m.nombre) ? "" : '<div class="loc-nombre">' + esc(m.nombre) + "</div>") +
(vacio(m.direccion) ? "" : '<div class="loc-dir">' + esc(m.direccion) + "</div>") +
'<div class="wa-pie">' + metaHTML(m) + "</div></div>";
} else {
html += metaHTML(m, "meta-en-media");
}
return html + "</div>";
}
/* Contacto iOS: avatar genérico, nombre + chevron, hora y "Mensaje | Guardar contacto".
Teléfono/email/empresa quedan en el modelo (se ven al abrir el perfil). */
function burbujaContacto(m) {
var nombre = vacio(m.nombre) ? "Contacto" : String(m.nombre);
return '<div class="bubble wa-contact">' +
'<div class="contact-cab">' +
'<span class="contact-avatar">' + svgSilueta() + "</span>" +
'<span class="contact-nombre">' + esc(nombre) + "</span>" +
'<span class="contact-chev">' + svgChevronDer(15) + "</span>" +
"</div>" +
'<div class="wa-pie">' + metaHTML(m) + "</div>" +
'<div class="contact-acciones"><span class="contact-accion">Mensaje</span>' +
'<span class="contact-accion">Guardar contacto</span></div>' +
"</div>";
}
/* Un botón de acción: con url segura → <a>; url no permitida → inerte;
sin url → <button> 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 = '<span class="btn-label">' + esc(etiqueta) + "</span>";
if (url) {
return '<a class="qr-btn"' + h + ' href="' + esc(url) + '" target="_blank" rel="noopener noreferrer" data-btn-url="' + j + '">' +
icono(tipoIconoUrl(url)) + lbl + "</a>";
}
if (!vacio(b.url) || b.urlBloqueada) {
return '<span class="qr-btn qr-btn-inactiva"' + h + ' aria-disabled="true">' + icono("url") + lbl + "</span>";
}
return '<button type="button" class="qr-btn"' + h + ' data-flujo="' + i + '" data-btn="' + j + '">' +
icono("responder") + lbl + "</button>";
}
/* 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 += '<button type="button" class="qr-btn" data-ver-todas="' + i + '">' + icono("lista") +
'<span class="btn-label">Ver todas las opciones</span></button>';
}
html += botonAccion(bs[j] || {}, i, j, plegar && j >= 2);
}
return '<div class="qr-btns">' + html + "</div>";
}
/* 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 '<img class="tpl-media" src="' + esc(src) + '" alt="" draggable="false">';
}
if (ht === "video") {
var srcv = srcSeguro(m.headerMedia);
var nodo = srcv
? '<video class="tpl-media" src="' + esc(srcv) + '" muted playsinline preload="metadata"></video>'
: '<img class="tpl-media" src="' + esc(placeholder("video")) + '" alt="" draggable="false">';
return '<div class="tpl-media-wrap">' + nodo + insigniaPlay(40) + "</div>";
}
if (ht === "documento") {
var nom = vacio(m.headerTexto) ? "documento.pdf" : String(m.headerTexto);
return '<div class="tpl-header-doc">' + panelDocumento(nom, m.headerTamano) + "</div>";
}
if (ht === "texto") {
return vacio(m.headerTexto) ? "" : '<div class="tpl-header-txt">' + esc(m.headerTexto) + "</div>";
}
return "";
}
/* Pie común: footer gris + hora/ticks */
function pieBurbuja(m, footer) {
return '<div class="wa-pie">' + (vacio(footer) ? "" : '<span class="template-footer">' + formatear(footer) + "</span>") + metaHTML(m) + "</div>";
}
function cuerpoTpl(texto) {
return vacio(texto) ? "" : '<div class="tpl-body">' + formatear(texto) + "</div>";
}
/* 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 '<div class="bubble tpl">' + headerHTML(m) + cuerpoTpl(m.body) +
pieBurbuja(m, m.footer) + tarjetaBotones(m.botones, i) + "</div>";
}
/* 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) ? "" : '<div class="tpl-header-txt">' + esc(m.header) + "</div>";
var etiqueta = vacio(m.boton) ? "Ver opciones" : String(m.boton);
return '<div class="bubble tpl">' + cabecera + cuerpoTpl(m.body) + pieBurbuja(m, m.footer) +
'<div class="qr-btns"><button type="button" class="qr-btn" data-flujo="' + i + '">' + icono("lista") +
'<span class="btn-label">' + esc(etiqueta) + "</span></button></div></div>";
}
/* 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) ? "" :
'<img class="tpl-media" src="' + esc(mediaSrc(m.headerMedia, "imagen")) + '" alt="" draggable="false">';
var etiqueta = vacio(m.textoBoton) ? "Abrir enlace" : String(m.textoBoton);
var url = urlSegura(m.url);
var lbl = '<span class="btn-label">' + esc(etiqueta) + "</span>";
var boton = url
? '<a class="qr-btn" href="' + esc(url) + '" target="_blank" rel="noopener noreferrer">' + icono(tipoIconoUrl(url)) + lbl + "</a>"
: '<span class="qr-btn qr-btn-inactiva" aria-disabled="true">' + icono("url") + lbl + "</span>";
return '<div class="bubble tpl">' + media + cuerpoTpl(m.body) + pieBurbuja(m, m.footer) +
'<div class="qr-btns">' + boton + "</div></div>";
}
/* carousel: burbuja de body + tarjetas horizontales (sin puntos, sin cola) */
function carouselHTML(m, i) {
var cards = Array.isArray(m.cards) ? m.cards : [];
var html = '<div class="wa-stack wa-stack-full">';
if (!vacio(m.body)) {
html += '<div class="bubble"><div class="wa-texto">' + formatear(m.body) + metaHTML(m) + "</div></div>";
}
html += '<div class="carousel">';
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 = '<span class="btn-label">' + esc(vacio(c.textoBoton) ? "Ver más" : c.textoBoton) + "</span>";
var url = urlSegura(c.url);
var boton;
if (url) boton = '<a class="qr-btn" href="' + esc(url) + '" target="_blank" rel="noopener noreferrer">' + icono(tipoIconoUrl(url)) + lbl + "</a>";
else if (!vacio(c.url) || c.urlBloqueada) boton = '<span class="qr-btn qr-btn-inactiva" aria-disabled="true">' + icono("url") + lbl + "</span>";
else boton = '<button type="button" class="qr-btn" data-flujo="' + i + '" data-card="' + j + '">' + icono("responder") + lbl + "</button>";
html += '<div class="card">' +
'<img class="card-media" src="' + esc(media) + '" alt="" draggable="false">' +
(vacio(c.texto) ? "" : '<div class="card-body">' + formatear(c.texto) + "</div>") +
'<div class="qr-btns">' + boton + "</div></div>";
}
return html + "</div></div>";
}
/* 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 = '<span class="wa-fecha">Hoy</span>';
var aviso = document.createElement("li");
aviso.className = "wa-sistema";
aviso.innerHTML = '<div class="wa-aviso">' + svgCandado() +
"Este negocio usa un servicio seguro de Meta para gestionar este chat. <b>Más información</b></div>";
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 =
'<span class="wa-back">' + svgChevronAtras() + "</span>" +
'<img id="chat-avatar" class="wa-avatar" src="' + esc(avatar) + '" alt="" draggable="false">' +
'<div class="wa-header-tit">' +
'<div class="header-title"><span id="chat-nombre">' + esc(nombre) + "</span>" +
'<span id="chat-verificado" class="wa-verificado" title="Cuenta verificada"' + (c.verificado ? "" : " hidden") + ">" + svgVerificado() + "</span></div>" +
'<div class="header-sub' + (ESCRIBIENDO ? " wa-sub-verde" : "") + '" id="chat-subtitulo">' + esc(textoSubtitulo()) + "</div>" +
"</div>" +
'<span class="wa-hicon">' + svgVideoLlamada() + "</span>" +
'<span class="wa-hicon">' + svgLlamada(24) + "</span>";
};
/* 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 = '<div class="bubble wa-bubble-escribiendo">' +
'<span class="wam-dot"></span><span class="wam-dot"></span><span class="wam-dot"></span></div>';
/* 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 = '<div class="sheet-fondo" id="sheet-fondo"></div>' +
'<div class="sheet-panel" role="dialog" aria-modal="true" aria-labelledby="sheet-titulo">' +
'<div class="sheet-cabecera"><span class="sheet-titulo" id="sheet-titulo">' + esc(titulo) + "</span>" +
'<button type="button" class="sheet-cerrar" aria-label="Cerrar">' + svgX() + "</button></div>" +
'<div class="sheet-opciones" id="sheet-opciones">' + contenido + "</div>" +
(pie ? '<div class="sheet-pie">' + esc(pie) + "</div>" : "") + "</div>";
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 += '<div class="sheet-seccion">' + esc(sec.titulo) + "</div>";
html += '<div class="sheet-grupo">';
for (f = 0; f < filas.length && total < 10; f++) {
var fila = filas[f] || {};
total++;
html += '<button type="button" class="sheet-fila" data-flujo="' + flujo + '" data-sec="' + s + '" data-fila="' + f + '">' +
'<span class="sheet-fila-txt"><span class="sheet-fila-titulo">' + esc(vacio(fila.titulo) ? "Opción" : fila.titulo) + "</span>" +
(vacio(fila.descripcion) ? "" : '<span class="sheet-fila-desc">' + esc(fila.descripcion) + "</span>") +
"</span></button>";
}
html += "</div>";
}
if (!total) html = '<div class="sheet-vacio">Sin opciones</div>';
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 = '<span class="sheet-fila-txt"><span class="sheet-fila-titulo">' +
esc(vacio(b.texto) ? "Botón " + (j + 1) : b.texto) + "</span></span>";
var url = urlSegura(b.url);
if (url) {
enlaces += '<a class="sheet-fila sheet-fila-btn" href="' + esc(url) + '" target="_blank" rel="noopener noreferrer">' +
icono(tipoIconoUrl(url)) + etiqueta + "</a>";
} else if (!vacio(b.url) || b.urlBloqueada) {
/* Igual que botonAccion: URL no permitida o bloqueada → fila inerte */
enlaces += '<span class="sheet-fila sheet-fila-btn qr-btn-inactiva" aria-disabled="true">' +
icono("url") + etiqueta + "</span>";
} else {
var usado = li && li.querySelector('[data-btn="' + j + '"].usado');
respuestas += '<button type="button" class="sheet-fila sheet-fila-btn' + (usado ? " usado" : "") + '" data-opcion="' + j + '"' +
(usado ? ' aria-disabled="true"' : "") + ">" + icono("responder") + etiqueta + "</button>";
}
}
var html = (enlaces ? '<div class="sheet-grupo">' + enlaces + "</div>" : "") +
(respuestas ? '<div class="sheet-grupo">' + respuestas + "</div>" : "");
var hojaOp = montarHoja("Todas las opciones", html || '<div class="sheet-vacio">Sin opciones</div>', "", 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(); } }
};
})();