NostrComments

Comment freely on every website — without censorship

Чтобы установить этот скрипт, вы сначала должны установить расширение браузера, например Tampermonkey, Greasemonkey или Violentmonkey.

Для установки этого скрипта вам необходимо установить расширение, такое как Tampermonkey.

Чтобы установить этот скрипт, вы сначала должны установить расширение браузера, например Tampermonkey или Violentmonkey.

Чтобы установить этот скрипт, вы сначала должны установить расширение браузера, например Tampermonkey или Userscripts.

Чтобы установить этот скрипт, сначала вы должны установить расширение браузера, например Tampermonkey.

Чтобы установить этот скрипт, вы должны установить расширение — менеджер скриптов.

(у меня уже есть менеджер скриптов, дайте мне установить скрипт!)

Чтобы установить этот стиль, сначала вы должны установить расширение браузера, например Stylus.

Чтобы установить этот стиль, сначала вы должны установить расширение браузера, например Stylus.

Чтобы установить этот стиль, сначала вы должны установить расширение браузера, например Stylus.

Чтобы установить этот стиль, сначала вы должны установить расширение — менеджер стилей.

Чтобы установить этот стиль, сначала вы должны установить расширение — менеджер стилей.

Чтобы установить этот стиль, сначала вы должны установить расширение — менеджер стилей.

(у меня уже есть менеджер стилей, дайте мне установить скрипт!)

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