NostrComments

Comment freely on every website — without censorship

Tendrás que instalar una extensión para tu navegador como Tampermonkey, Greasemonkey o Violentmonkey si quieres utilizar este script.

You will need to install an extension such as Tampermonkey to install this script.

Tendrás que instalar una extensión como Tampermonkey o Violentmonkey para instalar este script.

Necesitarás instalar una extensión como Tampermonkey o Userscripts para instalar este script.

Tendrás que instalar una extensión como Tampermonkey antes de poder instalar este script.

Necesitarás instalar una extensión para administrar scripts de usuario si quieres instalar este script.

(Ya tengo un administrador de scripts de usuario, déjame instalarlo)

Tendrás que instalar una extensión como Stylus antes de poder instalar este script.

Tendrás que instalar una extensión como Stylus antes de poder instalar este script.

Tendrás que instalar una extensión como Stylus antes de poder instalar este script.

Para poder instalar esto tendrás que instalar primero una extensión de estilos de usuario.

Para poder instalar esto tendrás que instalar primero una extensión de estilos de usuario.

Para poder instalar esto tendrás que instalar primero una extensión de estilos de usuario.

(Ya tengo un administrador de estilos de usuario, déjame instalarlo)

// ==UserScript==
// @name         NostrComments
// @namespace    https://github.com/briskness-byte/NostrComments
// @version      20.4
// @description  Comment freely on every website — without censorship
// @author       Built on Nostr
// @license      MIT
// @match        *://*/*
// @grant        none
// @run-at       document-end
// ==/UserScript==

(() => {
    'use strict';

    if (!document.body) { document.addEventListener('DOMContentLoaded', init); return; }
    init();

    function init() {
        // Floating button
        const btn = document.createElement('div');
        btn.appendChild(new DOMParser().parseFromString('<svg xmlns="http://www.w3.org/2000/svg" width="36" height="36" viewBox="0 0 24 24" fill="white"><path d="M20 2H4c-1.1 0-2 .9-2 2v14l4-4h14c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2z"/><circle cx="7.5" cy="9.5" r="1.5"/><circle cx="12" cy="9.5" r="1.5"/><circle cx="16.5" cy="9.5" r="1.5"/></svg>', 'image/svg+xml').documentElement);
        Object.assign(btn.style, {
            position:'fixed', right:'18px', bottom:'18px', width:'68px', height:'68px',
            background:'linear-gradient(135deg,#1d9bf0,#0d8bf0)', borderRadius:'50%',
                      display:'flex', alignItems:'center', justifyContent:'center', cursor:'pointer',
                      zIndex:'2147483647', boxShadow:'0 12px 35px rgba(29,155,240,0.6)',
                      transition:'transform .25s ease', userSelect:'none'
        });
        btn.onmouseenter = () => btn.style.transform = 'scale(1.12)';
        btn.onmouseleave = () => btn.style.transform = 'scale(1)';
        document.body.appendChild(btn);

        // Shadow DOM modal
        const host = document.createElement('div');
        document.body.appendChild(host);
        const s = host.attachShadow({mode:'open'});

        const styleEl = document.createElement('style');
        styleEl.textContent = `
        #m{display:none;position:fixed;inset:0;background:rgba(0,0,0,0.94);z-index:2147483647;place-items:center;font-family:system-ui,sans-serif}
        #p{background:#fff;width:95%;max-width:860px;max-height:94vh;overflow-y:auto;border-radius:20px;padding:24px;position:relative;box-shadow:0 20px 60px rgba(0,0,0,0.6)}
        #c{position:absolute;top:12px;right:16px;width:50px;height:50px;font-size:40px;background:none;border:none;cursor:pointer;color:#555;display:flex;align-items:center;justify-content:center}
        h2{color:#1d9bf0;margin:0 0 24px;text-align:center;font-size:28px;font-weight:600}
        #controls{display:flex;flex-direction:column;gap:16px;margin:20px 0}
        #connect,#send,#loadMore{padding:16px 20px;border-radius:14px;font-size:18px;font-weight:600;cursor:pointer}
        #connect,#send{background:#1d9bf0;color:white;border:none}
        #loadMore{background:#0d8bf0;color:white;border:none;display:none}
        input,select{padding:14px 16px;border:1px solid #ddd;border-radius:14px;font-size:17px}
        #input-wrapper{position:relative;margin:24px 0}
        #input{width:100%;min-height:120px;padding:18px 18px 50px;border:2px solid #e2e8f0;border-radius:16px;font-size:17px;background:#fafbfc;resize:none}
        #send{position:absolute;bottom:12px;right:12px;padding:14px 32px;border-radius:12px}
        #list{max-height:48vh;overflow-y:auto;background:#f8f9fa;padding:20px;border-radius:16px;margin:20px 0}
        .c{background:white;padding:18px;margin:12px 0;border-radius:16px;border-left:6px solid #1d9bf0;box-shadow:0 3px 12px rgba(0,0,0,0.08)}
        .v{font-size:28px;background:none;border:none;cursor:pointer;padding:8px 12px;min-width:56px}
        .h{opacity:0.5;font-style:italic;cursor:pointer;padding:30px;background:#f0f0f0;border-radius:16px;text-align:center;font-size:18px}
        #msg{display:none;background:#1d9bf0;color:white;padding:12px 18px;border-radius:12px;text-align:center;font-size:16px;margin:0 0 12px}
        #donate{text-align:center;margin:20px 0 10px;font-size:14px;color:#666}
        #donate a{color:#f7931a;text-decoration:none;font-weight:600}
        #donate a:hover{text-decoration:underline}
        @media(min-width:768px){#controls{flex-direction:row;align-items:center}#search{width:260px}}
        `;
        s.appendChild(styleEl);
        const _tpl = new DOMParser().parseFromString(`<html><body>
        <div id="m">
        <div id="p">
        <button id="c">\u00d7</button>
        <h2>NostrComments</h2>
        <div id="controls">
        <button id="connect">Connect Nostr</button>
        <span id="status" style="color:#c62828;font-weight:bold">Not connected</span>
        <input id="search" placeholder="Search comments\u2026">
        <select id="sort">
        <option value="newest">Newest first</option>
        <option value="oldest">Oldest first</option>
        <option value="upvotes">Most upvotes</option>
        </select>
        </div>
        <div id="list"></div>
        <button id="loadMore">Load more</button>
        <div id="msg"></div>
        <div id="input-wrapper">
        <textarea id="input" placeholder="Write your comment\u2026"></textarea>
        <button id="send">Post</button>
        </div>
        <div id="donate">
        Enjoying NostrComments? <a href="bitcoin:198yNVWJz2H8PwmNsX72URVVV9pRbxMb18" target="_blank">Send a tip (Bitcoin/Lightning)</a>
        </div>
        </div>
        </div>
        </body></html>`, 'text/html');
        s.appendChild(document.adoptNode(_tpl.getElementById('m')));

        const modal = s.getElementById('m');
        const list = s.getElementById('list');
        const input = s.getElementById('input');
        const send = s.getElementById('send');
        const connectBtn = s.getElementById('connect');
        const status = s.getElementById('status');
        const search = s.getElementById('search');
        const sort = s.getElementById('sort');
        const loadMore = s.getElementById('loadMore');
        const msg = s.getElementById('msg');

        function showMsg(text) {
            msg.textContent = text;
            msg.style.display = 'block';
            setTimeout(() => msg.style.display = 'none', 2500);
        }

        btn.onclick = () => modal.style.display = 'grid';
        s.getElementById('c').onclick = () => modal.style.display = 'none';

        const pageUrl = location.origin + location.pathname;
        const subId = 'nc' + Math.random().toString(36).slice(2, 8);

        let myPub = null;
        const comments = [];
        const scores = new Map();
        let q = '';
        let pageSize = 20;

        async function connect() {
            if (!window.nostr) return status.textContent = "Install Alby/nos2x";
            try { myPub = await window.nostr.getPublicKey();
                status.textContent = `Connected …${myPub.slice(-8)}`;
                status.style.color = "#2e7d32";
                connectBtn.disabled = true;
            } catch(e) {}
        }
        connectBtn.onclick = connect;
        const connectTimer = setInterval(() => { if (!myPub) connect(); else clearInterval(connectTimer); }, 5000);

        function makeItem(ev, sc, hidden) {
            const div = document.createElement('div');
            div.className = 'c' + (hidden ? ' h' : '');
            if (hidden) {
                div.textContent = `Hidden (${sc.down} downvotes) \u2014 tap to show`;
                div.onclick = () => { div.classList.remove('h'); div.onclick = null; };
                return div;
            }
            const meta = document.createElement('small');
            meta.style.color = '#666';
            meta.textContent = `${new Date(ev.created_at*1000).toLocaleString()} \u00b7 \u2026${ev.pubkey.slice(-8)}`;
            const body = document.createElement('div');
            body.style.cssText = 'margin:12px 0;font-size:17px;line-height:1.6';
            ev.content.split('\n').forEach((line, i) => {
                if (i > 0) body.appendChild(document.createElement('br'));
                body.appendChild(document.createTextNode(line));
            });
            const actions = document.createElement('div');
            actions.style.marginTop = '12px';
            [['1','\u2191',sc.up],['-1','\u2193',sc.down]].forEach(([val, arrow, count]) => {
                const b = document.createElement('button');
                b.className = 'v'; b.dataset.id = ev.id; b.dataset.val = val;
                b.textContent = `${arrow} ${count}`;
                actions.appendChild(b);
            });
            div.append(meta, document.createElement('br'), body, actions);
            return div;
        }

        function render() {
            let shown = comments.filter(c => c.content.toLowerCase().includes(q));
            if (sort.value === 'oldest') shown.sort((a,b) => a.created_at - b.created_at);
            if (sort.value === 'newest') shown.sort((a,b) => b.created_at - a.created_at);
            if (sort.value === 'upvotes') shown.sort((a,b) => (scores.get(b.id)?.up||0) - (scores.get(a.id)?.up||0));

            const total = Array.from(scores.values()).reduce((a,s)=>a+s.up+s.down,0);
            const hide = Math.max(5, Math.round(0.1 * total));

            list.replaceChildren();
            const page = shown.slice(0, pageSize);
            if (page.length === 0) {
                const empty = document.createElement('i');
                empty.style.cssText = 'color:#888;font-size:18px';
                empty.textContent = 'No comments yet \u2013 be the first!';
                list.appendChild(empty);
            } else {
                page.forEach(ev => {
                    const sc = scores.get(ev.id) || {up:0,down:0};
                    list.appendChild(makeItem(ev, sc, sc.down >= hide));
                });
            }

            loadMore.style.display = shown.length > pageSize ? 'block' : 'none';
        }

        search.addEventListener('input', () => { q = search.value.toLowerCase(); pageSize = 20; render(); });
        sort.onchange = () => { pageSize = 20; render(); };
        loadMore.onclick = () => { pageSize += 20; render(); };

        s.addEventListener('click', e => {
            if (e.target.classList.contains('v')) {
                const id = e.target.dataset.id;
                const val = Number(e.target.dataset.val);
                if (!myPub) return showMsg("Connect first!");
                vote(id, val);
            }
        });

        async function vote(id, val) {
            const comment = comments.find(c=>c.id===id);
            if (!comment) return;
            const s = scores.get(id) || {up:0,down:0};
            const ev = {kind:7,created_at:Math.floor(Date.now()/1000),tags:[["e",id],["p",comment.pubkey]],content:val===1?'+':'-',pubkey:myPub};
            try {
                const signed = await window.nostr.signEvent(ev);
                ["wss://nos.lol","wss://relay.damus.io"].forEach(r=>{
                    try{const ws=new WebSocket(r);ws.onopen=()=>ws.send(JSON.stringify(["EVENT",signed]));}catch(e){}
                });
                if (s.my===1) s.up--; if (s.my===-1) s.down--;
                if (val===1) s.up++; if (val===-1) s.down++;
                s.my = val;
                scores.set(id, s);
                render();
            } catch(e) {
                showMsg("Failed to sign — try again.");
            }
        }

        send.onclick = async () => {
            if (!myPub) return showMsg("Connect first!");
            const text = input.value.trim();
            if (!text) return;
            send.disabled = true;
            const ev = {kind:1,created_at:Math.floor(Date.now()/1000),tags:[["r",pageUrl]],content:text,pubkey:myPub};
            try {
                const signed = await window.nostr.signEvent(ev);
                ["wss://nos.lol","wss://relay.damus.io"].forEach(r=>{
                    try{const ws=new WebSocket(r);ws.onopen=()=>ws.send(JSON.stringify(["EVENT",signed]));}catch(e){}
                });
                comments.unshift(signed);
                input.value = '';
                render();
            } catch(e) {
                showMsg("Failed to sign — try again.");
            } finally {
                send.disabled = false;
            }
        };

        // Load everything
        ["wss://nos.lol","wss://relay.damus.io"].forEach(r => {
            const ws = new WebSocket(r);
            ws.onopen = () => ws.send(JSON.stringify(["REQ",subId,{kinds:[1,7],"#r":[pageUrl],limit:500}]));
            ws.onmessage = m => {
                let parsed;
                try { parsed = JSON.parse(m.data); } catch(e) { return; }
                const [type] = parsed;
                if (type === 'OK') {
                    const [, , ok, info] = parsed;
                    if (!ok) showMsg(`Relay rejected: ${info || 'unknown reason'}`);
                    return;
                }
                if (type !== 'EVENT') return;
                const ev = parsed[2];
                if (!ev) return;
                if (ev.kind===1 && ev.tags?.some(t=>t[0]==="r"&&t[1]===pageUrl)) {
                    if (!comments.find(c=>c.id===ev.id)) comments.push(ev);
                }
                if (ev.kind===7) {
                    const e = ev.tags?.find(t=>t[0]==="e")?.[1];
                    if (e) {
                        const s = scores.get(e) || {up:0,down:0};
                        const val = ev.content === '+' || ev.content === '' ? 1 : ev.content === '-' ? -1 : 0;
                        if (val) { if (val===1) s.up++; if (val===-1) s.down++; scores.set(e, s); }
                    }
                }
                render();
            };
        });

        render();
    }
})();