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