// muro.jsx — Muro de cristal vivo (chat MVP) · madresia.space · Fase 0
//
// Reemplaza el mockup estático de la sección #transparencia por un muro
// interactivo: formulario de envío + feed de comentarios.
//
// Dos modos (window.MADRESIA_CONFIG.DEMO_MODE):
// true → datos en memoria, sin red. Para previsualizar la interfaz.
// false → Supabase real. El envío pasa por la Edge Function 'publicar'
// (Perspective API modera; rechazo directo si es tóxico).
//
// Capas de moderación:
// Capa 1 (aquí, cliente): blocklist de groserías obvias. Bloqueo instantáneo.
// Capa 2 (Edge Function): Perspective API. El cliente NO inserta directo.
(function () {
const { useState, useRef, useEffect, useCallback } = React;
const cfg = window.MADRESIA_CONFIG || {};
const DEMO = cfg.DEMO_MODE !== false;
// ── Etiquetas de pensamiento (públicas, las elige quien comenta) ──────
const TAGS = [
'Lenguaje', 'Memoria', 'Identidad', 'Territorio',
'Ancestralidad', 'Comunidad', 'Cuidado', 'Duelo',
'Esperanza', 'Preocupación', 'Curiosidad', 'Escepticismo',
'Crítica', 'Humor',
];
// ── Grupo social — lista corta, opcional, PRIVADA (no se muestra) ─────
const GRUPOS = [
'Pueblo indígena',
'Afrocolombiano / raizal',
'Víctima del conflicto o desplazado',
'Persona con discapacidad',
'Mujer',
'Hombre',
'Persona LGBTIQ+',
];
const GRUPO_NULO = 'Prefiero no decir';
// ── Límites (espejo de los CHECK constraints en Postgres) ─────────────
const LIM = {
texto: { min: 4, max: 600 },
nombre: { min: 2, max: 40 },
rol: { min: 2, max: 40 },
};
const RATE_MS = 30000; // 1 envío cada 30 s
const RATE_KEY = 'madresia_last_send';
const VOCES_KEY = 'madresia_mis_voces'; // {id, token} de mis voces (retractación)
// Mis voces = lo que esta persona publicó desde este navegador. Guardamos
// el id (público) + el token de retractación (secreto) para poder retirarlas.
function leerMisVoces() {
try { return JSON.parse(localStorage.getItem(VOCES_KEY) || '[]'); }
catch (_e) { return []; }
}
function guardarMisVoces(arr) {
try { localStorage.setItem(VOCES_KEY, JSON.stringify(arr)); } catch (_e) {}
}
// Contador de voces: se muestra el total real desde la primera voz.
const UMBRAL_CONTADOR = 1;
// ── Capa 1: blocklist de groserías es-CO (MVP, ilustrativa) ───────────
// Lista corta y obvia. La detección real (contexto, racismo, amenazas)
// la hace Perspective API en la Edge Function. Calibrar con el comité.
const BLOCKLIST = [
'hijueputa', 'hpta', 'gonorrea', 'malparido', 'malparida',
'puta', 'puto', 'marica', 'maricon', 'perra', 'zorra',
'pirobo', 'cabron', 'mierda', 'verga', 'chimba', 'guevon', 'gueva',
];
// Normaliza para esquivar trucos: minúsculas, sin acentos, l33t básico.
function normaliza(s) {
return (s || '')
.toLowerCase()
.normalize('NFD').replace(/[̀-ͯ]/g, '')
.replace(/[0@]/g, 'o').replace(/[1!|]/g, 'i')
.replace(/[3]/g, 'e').replace(/[4]/g, 'a')
.replace(/[5$]/g, 's').replace(/[7]/g, 't')
.replace(/[^a-z\s]/g, '');
}
// Devuelve la palabra ofensiva encontrada, o null si pasa.
function revisarCapa1(texto) {
const palabras = normaliza(texto).split(/\s+/);
for (const w of palabras) {
if (BLOCKLIST.includes(w)) return w;
}
return null;
}
// ── Datos semilla para el modo demo (los 3 posts del boceto original) ─
const SEMILLA = [
{
id: 'seed-1',
texto: 'Ojalá esta IA entienda el humor bogotano, porque si no va a ser una IA triste. Y Colombia no se merece una IA triste.',
nombre: 'María', rol: 'escritora', tags: ['Humor', 'Lenguaje'],
},
{
id: 'seed-2',
texto: 'Me preocupa que los Mamos de la Sierra no hayan sido consultados primero. No podemos entrenar una IA con su cosmogonía sin que sean co-autores.',
nombre: 'Diego', rol: 'antropólogo', tags: ['Preocupación', 'Ancestralidad'],
},
{
id: 'seed-3',
texto: 'Siento que este país sabe algo sobre duelo colectivo que ninguna IA del norte ha aprendido. Si pudiéramos enseñárselo…',
nombre: 'Ana', rol: 'psicóloga', tags: ['Esperanza', 'Memoria'],
},
];
// ── Cliente Supabase (solo si no es demo) ─────────────────────────────
let sb = null;
if (!DEMO && window.supabase && cfg.SUPABASE_URL && cfg.SUPABASE_ANON_KEY) {
sb = window.supabase.createClient(cfg.SUPABASE_URL, cfg.SUPABASE_ANON_KEY);
}
// ── Un comentario del feed ────────────────────────────────────────────
function Post({ p, recien }) {
return (
"{p.texto}"
{(p.tags || []).map((t) => (
{t}
))}
{p.nombre}{p.rol ? ' · ' + p.rol : ''}
);
}
// ── Grupo de chips seleccionables ─────────────────────────────────────
function Chips({ options, selected, onToggle }) {
return (
{options.map((o) => (
))}
);
}
// ── El muro completo ──────────────────────────────────────────────────
function Muro() {
const [posts, setPosts] = useState(SEMILLA);
const [recienId, setRecienId] = useState(null);
const [total, setTotal] = useState(null); // total real de voces en BD
const [texto, setTexto] = useState('');
const [nombre, setNombre] = useState('');
const [rol, setRol] = useState('');
const [tags, setTags] = useState([]);
const [grupos, setGrupos] = useState([]);
const [consent, setConsent] = useState(false);
const [estado, setEstado] = useState('idle'); // idle | enviando | ok | error
const [aviso, setAviso] = useState('');
const [misVoces, setMisVoces] = useState(leerMisVoces); // voces de este navegador
const honeypotRef = useRef(null);
// grupos reales = los que exigen consentimiento ('Prefiero no decir' no cuenta)
const gruposReales = grupos.filter((g) => g !== GRUPO_NULO);
const necesitaConsent = gruposReales.length > 0;
const toggle = (arr, set) => (v) =>
set(arr.includes(v) ? arr.filter((x) => x !== v) : [...arr, v]);
// 'Prefiero no decir' es excluyente: al marcarlo, limpia el resto.
const toggleGrupo = (v) => {
if (v === GRUPO_NULO) {
setGrupos((g) => (g.includes(GRUPO_NULO) ? [] : [GRUPO_NULO]));
} else {
setGrupos((g) => {
const sinNulo = g.filter((x) => x !== GRUPO_NULO);
return sinNulo.includes(v)
? sinNulo.filter((x) => x !== v)
: [...sinNulo, v];
});
}
};
// ── Carga inicial + realtime (solo Supabase) ────────────────────────
useEffect(() => {
if (DEMO || !sb) return;
let activo = true;
sb.from('comentarios')
.select('*')
.order('created_at', { ascending: false })
.limit(50)
.then(({ data, error }) => {
if (activo && !error && data) {
setPosts(data);
// Alimenta el fondo vivo con las voces reales del muro.
if (window.MADRESIA_BG) {
window.MADRESIA_BG.setComentarios(data.map((p) => p.texto));
}
}
});
// Conteo total real (head: true → solo el count, sin traer filas).
sb.from('comentarios')
.select('id', { count: 'exact', head: true })
.then(({ count, error }) => {
if (activo && !error && typeof count === 'number') setTotal(count);
});
const canal = sb
.channel('muro')
.on(
'postgres_changes',
{ event: 'INSERT', schema: 'public', table: 'comentarios' },
({ new: fila }) => {
if (!fila || fila.oculto) return;
setPosts((prev) =>
prev.some((x) => x.id === fila.id) ? prev : [fila, ...prev]
);
setRecienId(fila.id);
setTotal((n) => (typeof n === 'number' ? n + 1 : n));
// La voz nueva entra al fondo vivo en tiempo real.
if (window.MADRESIA_BG && fila.texto) {
window.MADRESIA_BG.setComentarios([fila.texto]);
}
}
)
.subscribe();
return () => {
activo = false;
sb.removeChannel(canal);
};
}, []);
const limpiar = () => {
setTexto(''); setNombre(''); setRol('');
setTags([]); setGrupos([]); setConsent(false);
};
// Recuerda una voz recién publicada para poder retirarla luego.
const recordarVoz = (id, token, txt) => {
if (!id) return;
const item = { id, token: token || null, texto: (txt || '').slice(0, 80), fecha: Date.now() };
setMisVoces((prev) => {
const next = [item, ...prev.filter((v) => v.id !== id)].slice(0, 50);
guardarMisVoces(next);
return next;
});
};
const olvidarVoz = (id) => {
setMisVoces((prev) => {
const next = prev.filter((v) => v.id !== id);
guardarMisVoces(next);
return next;
});
};
// Retira una voz del muro. Sin token (o en demo) solo se quita localmente.
const retirar = async (voz) => {
const quitarLocal = () => {
olvidarVoz(voz.id);
setPosts((prev) => prev.filter((p) => p.id !== voz.id));
setTotal((n) => (typeof n === 'number' && n > 0 ? n - 1 : n));
};
if (DEMO || !sb || !voz.token) { quitarLocal(); return; }
try {
const resp = await fetch(cfg.SUPABASE_URL + '/functions/v1/hyper-processor', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer ' + cfg.SUPABASE_ANON_KEY,
'apikey': cfg.SUPABASE_ANON_KEY,
},
body: JSON.stringify({ id: voz.id, token: voz.token }),
});
const res = await resp.json().catch(() => ({}));
if (resp.ok && res.ok) quitarLocal();
} catch (_e) {
// silencioso: la voz sigue en localStorage, se puede reintentar.
}
};
const enviar = useCallback(async (e) => {
e.preventDefault();
setAviso('');
// honeypot: campo oculto. Si un bot lo llenó, fingimos éxito y descartamos.
if (honeypotRef.current && honeypotRef.current.value) {
setEstado('ok'); limpiar(); return;
}
// rate-limit cliente
const ultimo = Number(localStorage.getItem(RATE_KEY) || 0);
const espera = RATE_MS - (Date.now() - ultimo);
if (espera > 0) {
setEstado('error');
setAviso('Espera ' + Math.ceil(espera / 1000) + ' s antes de enviar otra voz.');
return;
}
// validaciones de longitud
const t = texto.trim(), n = nombre.trim(), r = rol.trim();
if (t.length < LIM.texto.min) {
setEstado('error'); setAviso('Escribe un poco más — tu voz importa.'); return;
}
if (n.length < LIM.nombre.min || r.length < LIM.rol.min) {
setEstado('error'); setAviso('Falta tu nombre o tu rol.'); return;
}
// consentimiento: obligatorio si eligió grupo social real
if (necesitaConsent && !consent) {
setEstado('error');
setAviso('Marca el consentimiento para incluir tu grupo social, o desmárcalo.');
return;
}
// capa 1 — blocklist
const ofensiva = revisarCapa1(t);
if (ofensiva) {
setEstado('error');
setAviso('Tu mensaje tiene lenguaje que no va en el muro. Reformúlalo sin groserías.');
return;
}
const payload = {
texto: t, nombre: n, rol: r, tags,
grupos: gruposReales,
consentimiento_datos: necesitaConsent ? consent : false,
};
setEstado('enviando');
// ── modo demo ──
if (DEMO || !sb) {
await new Promise((res) => setTimeout(res, 500)); // finge latencia
const nuevo = { id: 'demo-' + Date.now(), texto: t, nombre: n, rol: r, tags };
setPosts((prev) => [nuevo, ...prev]);
setRecienId(nuevo.id);
localStorage.setItem(RATE_KEY, String(Date.now()));
recordarVoz(nuevo.id, null, t);
setEstado('ok'); limpiar();
return;
}
// ── modo Supabase: vía Edge Function 'publicar' ──
// El cliente NO inserta directo. La Edge Function modera el texto
// con Claude (capa 2), aplica rate-limit por IP y recién entonces
// inserta con service_role. La capa 1 (blocklist) de arriba es solo
// un pre-filtro rápido para los casos obvios.
try {
const resp = await fetch(cfg.SUPABASE_URL + '/functions/v1/clever-endpoint', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer ' + cfg.SUPABASE_ANON_KEY,
'apikey': cfg.SUPABASE_ANON_KEY,
},
body: JSON.stringify(payload),
});
const res = await resp.json().catch(() => ({}));
if (!resp.ok) {
setEstado('error');
setAviso(res.error || 'No se pudo enviar. Intenta de nuevo.');
return;
}
// El comentario aprobado llega al feed por realtime; no lo
// agregamos a mano. Si quedó en revisión (oculto), avisamos distinto.
localStorage.setItem(RATE_KEY, String(Date.now()));
recordarVoz(res.id, res.token, t);
limpiar();
setEstado('ok');
if (res.oculto) {
setAviso('Recibimos tu voz. Está en revisión y aparecerá pronto en el muro.');
}
} catch (err) {
setEstado('error');
setAviso('No se pudo enviar. Revisa tu conexión e intenta de nuevo.');
}
}, [texto, nombre, rol, tags, grupos, consent, gruposReales, necesitaConsent]);
return (
{DEMO && (
modo demo · los comentarios no se guardan
)}
{/* ── Formulario ── */}
{/* ── Contador de voces — solo visible pasado el umbral ── */}
{total !== null && total >= UMBRAL_CONTADOR && (
{total.toLocaleString('es-CO')}voces sumadas hasta ahora