// 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 ── */}
tu voz — madresia@fase-0