- render.js: botones y cards con urlBloqueada se pintan inertes. - interactivity.js: sinFormato con la misma regla de marcadores que render.js; cita de documento con icono y nombre. - Zoom manual (Ajustar / 100 % / 125 %) en #barra-movil, persistido en localStorage. - Contador «Paso N/M» con estilo en iphone.css en lugar de estilos en línea. - store/editor: campos opcionales tamano y headerTamano; editar la URL retira urlBloqueada; urlValida acepta URLs con forma de dominio; importar avisa de lo recortado. - README y SPEC actualizados. Co-Authored-By: Claude Opus 5.5 <[email protected]>
896 lines
43 KiB
JavaScript
896 lines
43 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 { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[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) */
|
|
|
|
/* 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) {
|
|
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);
|
|
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)) {
|
|
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");
|
|
if (hoja && hoja.parentNode) hoja.parentNode.removeChild(hoja);
|
|
HOJA_ID = "";
|
|
};
|
|
})();
|