NostrComments

Comment freely on every website — without censorship

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

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

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

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

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

You will need to install a user script manager extension to install this script.

(У мене вже є менеджер скриптів, дайте мені встановити його!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

// ==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();
    }
})();