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      22.20
// @description  Comment freely on every website — without censorship
// @author       Built on Nostr
// @license      MIT
// @match        *://*/*
// @grant        GM_getValue
// @grant        GM_setValue
// @grant        GM_deleteValue
// @run-at       document-end
// ==/UserScript==

(async () => {
    'use strict';

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

    async function init() {
        // Load all persistent storage up front (GM_getValue is cross-origin, unlike localStorage)
        const _st = {
            privkey:  await GM_getValue('nostrcomments_privkey',  null),
            relays:   await GM_getValue('nostrcomments_relays',   null),
            muted:    await GM_getValue('nostrcomments_muted',    null),
            disabled: await GM_getValue('nostrcomments_disabled', null),
        };
        if (Array.isArray(_st.disabled) && _st.disabled.includes(location.origin)) {
            const _reBtn = document.createElement('div');
            Object.assign(_reBtn.style, {position:'fixed',right:'18px',bottom:'18px',width:'28px',height:'28px',background:'rgba(100,100,100,0.35)',borderRadius:'50%',cursor:'pointer',zIndex:'2147483647',display:'flex',alignItems:'center',justifyContent:'center',fontSize:'14px',opacity:'0.4',userSelect:'none',transition:'opacity .2s'});
            _reBtn.title = 'NostrComments is disabled on this site — click to re-enable';
            _reBtn.textContent = '💬';
            _reBtn.onmouseenter = () => _reBtn.style.opacity = '1';
            _reBtn.onmouseleave = () => _reBtn.style.opacity = '0.4';
            _reBtn.onclick = async () => {
                const arr = ((await GM_getValue('nostrcomments_disabled', null)) || []).filter(o => o !== location.origin);
                await GM_setValue('nostrcomments_disabled', arr);
                _reBtn.remove();
                init();
            };
            document.documentElement.appendChild(_reBtn);
            return;
        }

        // Minimal secp256k1 + BIP-340 Schnorr for local keypair generation
        const _secp = (() => {
            const P=0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEFFFFFC2Fn;
            const N=0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141n;
            const G=[0x79BE667EF9DCBBAC55A06295CE870B07029BFCDB2DCE28D959F2815B16F81798n,
                     0x483ADA7726A3C4655DA4FBFC0E1108A8FD17B448A68554199C47D08FFB10D4B8n];
            const m=(a,b=P)=>((a%b)+b)%b;
            const inv=a=>{a=m(a);let old_r=a,r=P,old_s=1n,s=0n;while(r!==0n){const q=old_r/r;[old_r,r]=[r,old_r-q*r];[old_s,s]=[s,old_s-q*s];}return m(old_s);};
            const pa=(A,B)=>{
                if(!A)return B;if(!B)return A;
                const[ax,ay]=A,[bx,by]=B;
                if(ax===bx){if(ay!==by)return null;const l=m(3n*ax*ax*inv(2n*ay));const x=m(l*l-2n*ax);return[x,m(l*(ax-x)-ay)];}
                const l=m((by-ay)*inv(bx-ax));const x=m(l*l-ax-bx);return[x,m(l*(ax-x)-ay)];
            };
            const pm=(k,Pt)=>{let R=null,Q=Pt;while(k>0n){if(k&1n)R=pa(R,Q);Q=pa(Q,Q);k>>=1n;}return R;};
            const h2b=h=>{const b=new Uint8Array(h.length/2);for(let i=0;i<h.length;i+=2)b[i/2]=parseInt(h.slice(i,i+2),16);return b;};
            const b2h=b=>Array.from(b).map(x=>x.toString(16).padStart(2,'0')).join('');
            const n2h=(n,l=32)=>n.toString(16).padStart(l*2,'0');
            const h2n=h=>BigInt('0x'+h);
            const sha=async(...a)=>{const c=new Uint8Array(a.reduce((s,b)=>s+b.length,0));let o=0;for(const b of a){c.set(b,o);o+=b.length;}return new Uint8Array(await crypto.subtle.digest('SHA-256',c));};
            const th=async(tag,...ms)=>{const t=await sha(new TextEncoder().encode(tag));return sha(t,t,...ms);};
            const mpow=(b,e,n)=>{let r=1n;b=((b%n)+n)%n;while(e>0n){if(e&1n)r=r*b%n;b=b*b%n;e>>=1n;}return r;};
            const liftX=x=>{const y2=m(x*x*x+7n);const y=mpow(y2,(P+1n)/4n,P);return m(y*y)===y2?[x,y%2n===0n?y:P-y]:null;};
            const verify=async(pubHex,msgBytes,sigHex)=>{try{const r=h2n(sigHex.slice(0,64)),s=h2n(sigHex.slice(64));if(r>=P||s>=N)return false;const Pt=liftX(h2n(pubHex));if(!Pt)return false;const e=m(h2n(b2h(await th('BIP0340/challenge',h2b(n2h(r)),h2b(pubHex),msgBytes))),N);const R=pa(pm(s,G),pm(N-e,Pt));return!!R&&R[1]%2n===0n&&R[0]===r;}catch(_){return false;}};
            const pubKey=priv=>n2h(pm(h2n(priv),G)[0]);
            const sign=async(priv,msg)=>{
                const privBig=h2n(priv),Pt=pm(privBig,G);
                const pk=Pt[1]%2n!==0n?m(N-privBig,N):privBig;
                const pub=h2b(n2h(Pt[0]));
                const aux=crypto.getRandomValues(new Uint8Array(32));
                const t=pk^h2n(b2h(await th('BIP0340/aux',aux)));
                const rand=await th('BIP0340/nonce',h2b(n2h(t)),pub,msg);
                let k=m(h2n(b2h(rand)),N);if(k===0n)throw new Error('bad nonce');
                const R=pm(k,G);if(R[1]%2n!==0n)k=N-k;
                const rx=h2b(n2h(R[0]));
                const e=m(h2n(b2h(await th('BIP0340/challenge',rx,pub,msg))),N);
                return b2h(rx)+n2h(m(k+pk*e,N));
            };
            return{pubKey,sign,b2h,verify};
        })();

        async function makeLocalWallet(privHex) {
            const pubHex=_secp.pubKey(privHex);
            const enc=new TextEncoder();
            return {
                getPublicKey:()=>Promise.resolve(pubHex),
                signEvent:async ev=>{
                    const serial=JSON.stringify([0,pubHex,ev.created_at,ev.kind,ev.tags,ev.content]);
                    const idBytes=new Uint8Array(await crypto.subtle.digest('SHA-256',enc.encode(serial)));
                    const id=_secp.b2h(idBytes);
                    const sig=await _secp.sign(privHex,idBytes);
                    return{...ev,id,sig,pubkey:pubHex};
                }
            };
        }

        function toBech32(hrp, hex) {
            const CS = 'qpzry9x8gf2tvdw0s3jn54khce6mua7l';
            const GEN = [0x3b6a57b2,0x26508e6d,0x1ea119fa,0x3d4233dd,0x2a1462b3];
            const pm = v => { let c=1; for (const d of v){const t=c>>25;c=(c&0x1ffffff)<<5^d;for(let i=0;i<5;i++)if((t>>i)&1)c^=GEN[i];} return c; };
            const ex = h => [...h].map(c=>c.charCodeAt(0)>>5).concat(0,...[...h].map(c=>c.charCodeAt(0)&31));
            const bytes = hex.match(/.{2}/g).map(b=>parseInt(b,16));
            const w=[]; let acc=0,bits=0;
            for(const b of bytes){acc=(acc<<8)|b;bits+=8;while(bits>=5){bits-=5;w.push((acc>>bits)&31);}}
            if(bits)w.push((acc<<(5-bits))&31);
            const chk=pm([...ex(hrp),...w,0,0,0,0,0,0])^1;
            return hrp+'1'+[...w,...Array.from({length:6},(_,i)=>(chk>>(5*(5-i)))&31)].map(d=>CS[d]).join('');
        }
        const toNpub = hex => toBech32('npub', hex);
        const toNote = hex => toBech32('note', hex);

        function timeAgo(ts) {
            const s = Math.floor(Date.now()/1000) - ts;
            if (s < 60) return 'just now';
            if (s < 3600) return `${Math.floor(s/60)}m ago`;
            if (s < 86400) return `${Math.floor(s/3600)}h ago`;
            if (s < 86400*30) return `${Math.floor(s/86400)}d ago`;
            return new Date(ts*1000).toLocaleDateString();
        }

        // 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"><path fill-rule="evenodd" fill="white" d="M4 2h16a2 2 0 0 1 2 2v11a2 2 0 0 1-2 2H7l-5 5V4a2 2 0 0 1 2-2z M6.6 9.5a1.4 1.4 0 1 0 2.8 0a1.4 1.4 0 1 0-2.8 0z M10.6 9.5a1.4 1.4 0 1 0 2.8 0a1.4 1.4 0 1 0-2.8 0z M14.6 9.5a1.4 1.4 0 1 0 2.8 0a1.4 1.4 0 1 0-2.8 0z"/></svg>', 'image/svg+xml').documentElement);
        btn.id = 'nc-btn';
        btn.onmouseenter = () => btn.classList.add('nc-hover');
        btn.onmouseleave = () => btn.classList.remove('nc-hover');
        const badge = document.createElement('span');
        badge.id = 'nc-badge';
        btn.appendChild(badge);
        const nBadge = document.createElement('span');
        nBadge.id = 'nc-nbadge';
        btn.appendChild(nBadge);
        document.documentElement.appendChild(btn);
        // Button/badge styles via adoptedStyleSheets
        try {
            const _ds = new CSSStyleSheet();
            _ds.replaceSync('#nc-btn{position:fixed;right:18px;bottom:18px;width:68px;height:68px;background:linear-gradient(135deg,#1d9bf0,#0d8bf0);border-radius:50%;display:flex;align-items:center;justify-content:center;cursor:pointer;z-index:2147483647;box-shadow:0 12px 35px rgba(29,155,240,0.6);transition:transform .25s ease;user-select:none}#nc-btn.nc-hover{transform:scale(1.12)}#nc-badge,#nc-nbadge{position:absolute;border-radius:12px;font-size:12px;font-weight:bold;padding:2px 6px;min-width:20px;text-align:center;display:none;font-family:system-ui,sans-serif;line-height:1.5;pointer-events:none}#nc-badge{top:-6px;right:-6px;background:#e53935;color:white}#nc-nbadge{top:-6px;left:-6px;background:#f59e0b;color:white}#nc-badge.nc-on,#nc-nbadge.nc-on{display:block}');
            document.adoptedStyleSheets = [...document.adoptedStyleSheets, _ds];
        } catch(e) {
            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'});
        }

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

        const _ss = new CSSStyleSheet();
        _ss.replaceSync(`
        #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;overflow:hidden}
        #p{background:#fff;width:95%;max-width:740px;max-height:92vh;overflow-y:auto;overflow-x:hidden;border-radius:16px;padding:16px 18px;position:relative;box-shadow:0 20px 60px rgba(0,0,0,0.6);color:#222}
        #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}
        #gear-btn{position:absolute;top:16px;left:16px;width:auto;height:40px;font-size:14px;padding:0 12px;background:none;border:none;cursor:pointer;color:#999;display:flex;align-items:center;justify-content:center;border-radius:8px;gap:6px;transition:background .15s,color .15s}
        #gear-btn:hover{background:#f0f0f0;color:#555}
        #gear-btn.active{background:#e8f0fe;color:#1d9bf0}
        #theme-btn{position:absolute;top:15px;right:68px;width:36px;height:36px;font-size:20px;background:none;border:none;cursor:pointer;display:flex;align-items:center;justify-content:center;border-radius:8px;opacity:0.6;color:#555}
        #theme-btn:hover{background:#f0f0f0;opacity:1}
        h2{color:#1d9bf0;margin:0 0 12px;text-align:center;font-size:22px;font-weight:600}
        #settings{display:none;background:#f8f9fa;border-radius:14px;padding:18px;margin:0 0 20px}
        #settings-header{display:flex;align-items:center;justify-content:space-between;margin-bottom:2px}
        #settings-close{background:none;border:none;cursor:pointer;font-size:22px;color:#bbb;padding:0;line-height:1;transition:color .15s}
        #settings-close:hover{color:#555}
        #settings strong{font-size:16px;color:#333}
        #relay-list{margin:10px 0}
        .relay-item{display:flex;align-items:center;justify-content:space-between;padding:8px 12px;background:white;border-radius:8px;margin:5px 0;font-size:14px;color:#444;border:1px solid #eee}
        .relay-remove{background:none;border:none;color:#c62828;cursor:pointer;font-size:18px;padding:0 4px;line-height:1}
        #relay-add{display:flex;gap:8px;margin-top:10px}
        #relay-input{flex:1;padding:10px 14px;border:1px solid #ddd;border-radius:10px;font-size:14px}
        #relay-add-btn{padding:10px 16px;background:#1d9bf0;color:white;border:none;border-radius:10px;cursor:pointer;font-weight:600;font-size:14px}
        #controls{display:flex;flex-direction:column;gap:8px;margin:10px 0}
        #connect,#send,#loadMore{padding:12px 16px;border-radius:12px;font-size:16px;font-weight:600;cursor:pointer}
        #connect,#send{background:#1d9bf0;color:white;border:none}
        #loadMore{background:#0d8bf0;color:white;border:none;display:none}
        input,select,textarea{padding:10px 14px;border:1px solid #ddd;border-radius:12px;font-size:15px}
        #input-wrapper{position:relative;margin:12px 0}
        #input{width:100%;min-height:88px;padding:14px 14px 46px;border:2px solid #e2e8f0;border-radius:14px;font-size:15px;background:#fafbfc;resize:none;box-sizing:border-box}
        #send{position:absolute;bottom:10px;right:10px;padding:10px 24px;border-radius:10px}
        #list{max-height:40vh;overflow-y:auto;background:#f8f9fa;padding:12px;border-radius:14px;margin:10px 0}
        .c{background:white;padding:14px;margin:8px 0;border-radius:12px;border-left:5px solid #1d9bf0;box-shadow:0 2px 8px rgba(0,0,0,0.07);color:#222;word-break:break-word}
        .c.reply{margin-left:28px;border-left:4px solid #90caf9}
        .v{font-size:13px;background:#eef2f7;border:1px solid #d0d9e8;cursor:pointer;padding:4px 10px;border-radius:20px;color:#444;font-weight:700;min-width:0}
        .v:hover{background:#dbeafe;border-color:#93c5fd;color:#1d9bf0}
        .reply-btn{font-size:14px;background:none;border:none;cursor:pointer;padding:6px 10px;color:#1d9bf0;font-weight:600}
        .h{opacity:0.5;font-style:italic;cursor:pointer;padding:30px;background:#f0f0f0;border-radius:16px;text-align:center;font-size:18px}
        #reply-indicator{display:none;background:#e8f4fd;border-radius:10px;padding:10px 14px;margin:8px 0;font-size:14px;color:#1d9bf0;align-items:center;justify-content:space-between}
        #reply-cancel{background:none;border:none;color:#1d9bf0;cursor:pointer;font-weight:600;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}
        #onboard{display:none;background:#f0f7ff;border-radius:14px;padding:16px 18px;margin:0 0 12px}
        .ob-title{font-size:17px;font-weight:700;color:#1d9bf0;margin:0 0 8px}
        .ob-pitch{font-size:13px;line-height:1.6;color:#555;margin:0 0 12px}
        .ob-primary{display:block;width:100%;padding:13px;background:linear-gradient(135deg,#1d9bf0,#0d8bf0);color:white;border:none;border-radius:10px;cursor:pointer;font-size:15px;font-weight:700;margin-bottom:10px;letter-spacing:0.01em}
        .ob-primary:hover{opacity:0.92}
        .ob-or{text-align:center;font-size:13px;color:#999;margin:0 0 10px}
        .ob-wallets{display:flex;gap:8px}
        .ob-wallet{flex:1;padding:10px;text-align:center;border:2px solid #1d9bf0;border-radius:10px;color:#1d9bf0;font-weight:600;font-size:14px;text-decoration:none}
        .ob-wallet:hover{background:#e8f4fd}
        #donate{text-align:center;margin:10px 0 4px;font-size:13px;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}}
        .c a{color:#1d9bf0;text-decoration:none}
        .c a:hover{text-decoration:underline}
        .c code{background:#f0f4f8;padding:2px 6px;border-radius:4px;font-family:monospace;font-size:15px;color:#d63384}
        .zap-btn{font-size:20px;background:none;border:none;cursor:pointer;padding:6px 10px;color:#f59e0b}
        .zap-btn:hover{color:#d97706}
        .mute-btn{font-size:13px;background:none;border:none;cursor:pointer;padding:6px 10px;color:#bbb}
        .mute-btn:hover{color:#c62828}
        .copy-btn{font-size:16px;background:none;border:none;cursor:pointer;padding:6px 10px;color:#bbb}
        .copy-btn:hover{color:#555}
        .c.own{border-left-color:#2e7d32}
        #muted-section{display:none;margin-top:14px}
        .muted-item{display:flex;align-items:center;justify-content:space-between;padding:8px 12px;background:white;border-radius:8px;margin:5px 0;font-size:14px;color:#444;border:1px solid #eee}
        .unmute-btn{background:none;border:none;color:#1d9bf0;cursor:pointer;font-size:13px;font-weight:600}
        #notif-banner{display:none;background:#f59e0b;color:white;border-radius:12px;padding:10px 16px;margin:0 0 12px;font-size:15px;font-weight:600;text-align:center}
        .avatar{width:38px;height:38px;border-radius:50%;object-fit:cover;flex-shrink:0;background:#e8f4fd}
        .ts{color:#888}
        #m.dark-mode #p{background:#18181b;color:#e4e4e7}
        #m.dark-mode h2{color:#93c5fd}
        #m.dark-mode #list{background:#09090b}
        #m.dark-mode .c{background:#27272a;box-shadow:none;color:#e4e4e7;border-left-color:#3b82f6}
        #m.dark-mode .c.own{border-left-color:#4ade80}
        #m.dark-mode .c.reply{border-left-color:#60a5fa}
        #m.dark-mode #settings{background:#111113}
        #m.dark-mode #settings strong{color:#e4e4e7}
        #m.dark-mode .relay-item,#m.dark-mode .muted-item{background:#27272a;border-color:#3f3f46;color:#e4e4e7}
        #m.dark-mode input,#m.dark-mode select,#m.dark-mode textarea{background:#27272a;border-color:#3f3f46;color:#e4e4e7}
        #m.dark-mode #input{background:#1c1c1f;border-color:#3f3f46;color:#e4e4e7}
        #m.dark-mode #onboard{background:#1a2540}
        #m.dark-mode .ob-title{color:#60a5fa}
        #m.dark-mode .ob-pitch{color:#c4c4ce}
        #m.dark-mode .ob-or{color:#71717a}
        #m.dark-mode .ob-wallet{border-color:#3b82f6;color:#93c5fd;background:#111827}
        #m.dark-mode .ob-wallet:hover{background:#1e3a5f}
        #m.dark-mode .h{background:#27272a;color:#71717a}
        #m.dark-mode #reply-indicator{background:#1c2d40;color:#93c5fd}
        #m.dark-mode #donate{color:#71717a}
        #m.dark-mode .c code{background:#3f3f46;color:#f9a8d4}
        #m.dark-mode #privkey-display{background:#27272a;border-color:#3f3f46;color:#a1a1aa}
        #m.dark-mode #gear-btn{color:#71717a}
        #m.dark-mode #gear-btn:hover{background:#27272a;color:#e4e4e7}
        #m.dark-mode #gear-btn.active{background:#1a2535;color:#60a5fa}
        #m.dark-mode #settings-close{color:#52525b}
        #m.dark-mode #settings-close:hover{color:#e4e4e7}
        #m.dark-mode #theme-btn{color:#f59e0b}
        #m.dark-mode #theme-btn:hover{background:#27272a;opacity:1}
        #m.dark-mode #c{color:#a1a1aa}
        #m.dark-mode .avatar{background:#27272a}
        #m.dark-mode .ts{color:#a1a1aa}
        #m.dark-mode .copy-btn{color:#71717a}
        #m.dark-mode .copy-btn:hover{color:#e4e4e7}
        #m.dark-mode .mute-btn:hover{color:#f87171}
        #m.dark-mode .v{background:#2a2a35;border-color:#4a4a5a;color:#c4c4ce}
        #m.dark-mode .v:hover{background:#1e3a5f;border-color:#3b82f6;color:#60a5fa}
        .nc-header{display:flex;align-items:center;gap:10px;flex-wrap:wrap}
        .nc-plink{display:flex;align-items:center;gap:8px;text-decoration:none}
        .nc-name{font-weight:600;color:#1d9bf0}
        .nc-body{margin:12px 0;font-size:17px;line-height:1.6}
        .nc-actions{margin-top:12px}
        .nc-img{max-width:100%;border-radius:10px;margin:8px 0;display:block;cursor:pointer}
        .nc-vid{max-width:100%;border-radius:10px;margin:8px 0;display:block}
        .nc-empty{color:#888;font-size:18px}
        #m.dark-mode .nc-name{color:#93c5fd}
        `);
        s.adoptedStyleSheets = [_ss];

        const _tpl = new DOMParser().parseFromString(`<html><body>
        <div id="m"><div id="p">
        <button id="gear-btn">⚙ Settings</button>
        <button id="theme-btn">☽</button>
        <button id="c">×</button>
        <h2>NostrComments</h2>
        <div id="settings">
        <div id="settings-header"><strong>Settings</strong><button id="settings-close" title="Close settings">×</button></div>
        <div id="relay-list"></div>
        <div id="relay-add">
        <input id="relay-input" placeholder="wss://relay.example.com">
        <button id="relay-add-btn">Add</button>
        </div>
        <div id="keypair-section" style="display:none">
        <hr style="margin:14px 0;border:none;border-top:1px solid #eee">
        <strong style="font-size:15px;color:#333">Your generated keypair</strong>
        <p style="font-size:13px;color:#c62828;margin:6px 0">⚠ Stored in this browser only — copy your private key to back it up.</p>
        <div style="display:flex;gap:8px;margin-top:8px">
        <input id="privkey-display" readonly style="flex:1;font-size:12px;font-family:monospace;padding:8px;border:1px solid #ddd;border-radius:8px">
        <button id="privkey-copy" style="padding:8px 14px;background:#1d9bf0;color:white;border:none;border-radius:8px;cursor:pointer;font-weight:600">Copy</button>
        </div>
        <div style="display:flex;gap:8px;margin-top:8px">
        <button id="privkey-rotate" style="flex:1;padding:8px 14px;background:none;border:1px solid #d97706;color:#d97706;border-radius:8px;cursor:pointer;font-size:13px">Rotate key</button>
        <button id="privkey-delete" style="flex:1;padding:8px 14px;background:none;border:1px solid #c62828;color:#c62828;border-radius:8px;cursor:pointer;font-size:13px">Delete keypair</button>
        </div>
        </div>
        <div id="muted-section">
        <hr style="margin:14px 0;border:none;border-top:1px solid #eee">
        <strong style="font-size:15px;color:#333">Muted users</strong>
        <div id="muted-list"></div>
        </div>
        <div style="margin-top:14px">
        <hr style="margin:0 0 12px;border:none;border-top:1px solid #eee">
        <strong style="font-size:15px;color:#333">This site</strong>
        <p style="font-size:13px;color:#666;margin:6px 0 10px">Hide the NostrComments button on <span id="site-origin"></span>.</p>
        <button id="site-disable-btn" style="padding:8px 14px;background:none;border:1px solid #e53935;color:#e53935;border-radius:8px;cursor:pointer;font-size:13px">Disable on this site</button>
        </div>
        </div>
        <div id="notif-banner"></div>
        <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…">
        <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="onboard"></div>
        <div id="list"></div>
        <button id="loadMore">Load more</button>
        <div id="msg"></div>
        <div id="reply-indicator"><span id="reply-to-label"></span><button id="reply-cancel">×</button></div>
        <div id="input-wrapper">
        <textarea id="input" placeholder="Write your comment…"></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');
        const onboard = s.getElementById('onboard');
        const gearBtn = s.getElementById('gear-btn');
        const settings = s.getElementById('settings');
        const settingsClose = s.getElementById('settings-close');
        const relayListEl = s.getElementById('relay-list');
        const relayInput = s.getElementById('relay-input');
        const relayAddBtn = s.getElementById('relay-add-btn');
        const replyIndicator = s.getElementById('reply-indicator');
        const replyToLabel = s.getElementById('reply-to-label');
        const replyCancel = s.getElementById('reply-cancel');
        const keypairSection = s.getElementById('keypair-section');
        const privkeyDisplay = s.getElementById('privkey-display');
        const privkeyCopy = s.getElementById('privkey-copy');
        const privkeyRotate = s.getElementById('privkey-rotate');
        const privkeyDelete = s.getElementById('privkey-delete');
        const notifBanner = s.getElementById('notif-banner');
        const themeBtn = s.getElementById('theme-btn');
        const siteDisableBtn = s.getElementById('site-disable-btn');
        s.getElementById('site-origin').textContent = location.hostname;

        function applyTheme() {
            const pref = localStorage.getItem('nostrcomments_theme');
            const sysDark = window.matchMedia('(prefers-color-scheme:dark)').matches;
            const on = pref === 'dark' || (pref !== 'light' && sysDark);
            modal.classList.toggle('dark-mode', on);
            themeBtn.textContent = on ? '☀' : '☽';
        }
        applyTheme();
        window.matchMedia('(prefers-color-scheme:dark)').addEventListener('change', applyTheme);
        themeBtn.onclick = () => {
            localStorage.setItem('nostrcomments_theme', modal.classList.contains('dark-mode') ? 'light' : 'dark');
            applyTheme();
        };

        privkeyCopy.onclick = () => { navigator.clipboard.writeText(privkeyDisplay.value); showMsg('Private key copied'); };
        privkeyRotate.onclick = async () => {
            if (!confirm('Generate a new key?\n\nYour current Nostr identity will be lost — save your private key first if you want to keep it.')) return;
            privkeyRotate.disabled = true;
            try {
                const priv = _secp.b2h(crypto.getRandomValues(new Uint8Array(32)));
                await GM_setValue('nostrcomments_privkey', priv);
                localWallet = await makeLocalWallet(priv);
                myPub = await localWallet.getPublicKey();
                privkeyDisplay.value = priv;
                status.textContent = `Connected …${myPub.slice(-8)}`;
                render();
                showMsg('New key generated — copy it now to back it up!');
            } catch(e) { showMsg('Key rotation failed — try again'); }
            finally { privkeyRotate.disabled = false; }
        };
        privkeyDelete.onclick = () => {
            if (!confirm('Delete your generated keypair? You will lose your Nostr identity unless you backed up the key.')) return;
            GM_deleteValue('nostrcomments_privkey');
            localWallet = null; myPub = null;
            keypairSection.style.display = 'none';
            status.textContent = 'Not connected'; status.style.color = '#c62828';
            connectBtn.disabled = false;
            closeSettings();
            showMsg('Keypair deleted');
        };

        siteDisableBtn.onclick = async () => {
            const arr = (await GM_getValue('nostrcomments_disabled', null)) || [];
            if (!arr.includes(location.origin)) arr.push(location.origin);
            await GM_setValue('nostrcomments_disabled', arr);
            modal.style.display = 'none';
            btn.style.display = 'none';
            host.remove();
        };

        // Onboard banner
        (() => {
            const headline = document.createElement('div');
            headline.className = 'ob-title';
            headline.textContent = '🌐 Comment freely. No one can silence you.';

            const pitch = document.createElement('p');
            pitch.className = 'ob-pitch';
            pitch.textContent = 'Your comments live on the Nostr network — a global web of relays nobody controls. No account. No email. No company can delete your posts. Your identity is a cryptographic key you own.';

            const genBtn = document.createElement('button');
            genBtn.className = 'ob-primary';
            genBtn.textContent = '🔑 Start commenting — generate your key';
            genBtn.onclick = async () => {
                genBtn.disabled = true;
                try {
                    const priv = _secp.b2h(crypto.getRandomValues(new Uint8Array(32)));
                    await GM_setValue('nostrcomments_privkey', priv);
                    localWallet = await makeLocalWallet(priv);
                    await connect();
                    await openSettings();
                    keypairSection.style.display = 'block';
                    privkeyDisplay.value = priv;
                    showMsg('Key generated! Copy your private key below before closing.');
                } catch(e) {
                    showMsg('Key generation failed — try again');
                    genBtn.disabled = false;
                }
            };

            const orLine = document.createElement('div');
            orLine.className = 'ob-or';
            orLine.textContent = 'or connect an existing Nostr wallet';

            const wallets = document.createElement('div');
            wallets.className = 'ob-wallets';
            [['Alby', 'https://getalby.com'], ['nos2x', 'https://github.com/fiatjaf/nos2x']].forEach(([label, href]) => {
                const a = document.createElement('a');
                a.className = 'ob-wallet'; a.href = href; a.target = '_blank'; a.rel = 'noopener noreferrer';
                a.textContent = label;
                wallets.appendChild(a);
            });

            onboard.append(headline, pitch, genBtn, orLine, wallets);
        })();

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

        btn.onclick = () => {
            modal.style.display = 'grid';
            onboard.style.display = myPub ? 'none' : 'block';
            if (unreadReplies > 0) {
                notifBanner.textContent = `🔔 ${unreadReplies} new repl${unreadReplies === 1 ? 'y' : 'ies'} on your comments`;
                notifBanner.style.display = 'block';
                setTimeout(() => { notifBanner.style.display = 'none'; }, 5000);
            }
            unreadReplies = 0;
            updateNotifBadge();
        };
        s.getElementById('c').onclick = () => modal.style.display = 'none';

        // Relay config
        const DEFAULT_RELAYS = ['wss://nos.lol','wss://relay.damus.io','wss://relay.nostr.band','wss://nostr.wine','wss://relay.primal.net','wss://purplepages.es'];
        let RELAYS = (() => {
            try {
                const saved = _st.relays;
                return Array.isArray(saved) && saved.length ? saved : [...DEFAULT_RELAYS];
            } catch(e) { return [...DEFAULT_RELAYS]; }
        })();
        function saveRelays() { GM_setValue('nostrcomments_relays', RELAYS); }

        function renderRelayList() {
            relayListEl.replaceChildren();
            RELAYS.forEach(r => {
                const item = document.createElement('div');
                item.className = 'relay-item';
                const label = document.createElement('span');
                label.textContent = r;
                const removeBtn = document.createElement('button');
                removeBtn.className = 'relay-remove';
                removeBtn.textContent = '×';
                removeBtn.onclick = () => { RELAYS = RELAYS.filter(x => x !== r); saveRelays(); renderRelayList(); showMsg('Relay removed — reload page to apply'); };
                item.append(label, removeBtn);
                relayListEl.appendChild(item);
            });
        }

        function renderMutedList() {
            const mutedList = s.getElementById('muted-list');
            const mutedSection = s.getElementById('muted-section');
            if (mutedPubkeys.size === 0) { mutedSection.style.display = 'none'; return; }
            mutedSection.style.display = 'block';
            mutedList.replaceChildren();
            mutedPubkeys.forEach(pub => {
                const item = document.createElement('div');
                item.className = 'muted-item';
                const label = document.createElement('span');
                label.textContent = profiles.get(pub) || toNpub(pub).slice(0,12)+'…';
                const unmuteBtn = document.createElement('button');
                unmuteBtn.className = 'unmute-btn';
                unmuteBtn.textContent = 'Unmute';
                unmuteBtn.onclick = () => { mutedPubkeys.delete(pub); saveMuted(); renderMutedList(); render(); showMsg('User unmuted'); };
                item.append(label, unmuteBtn);
                mutedList.appendChild(item);
            });
        }

        const openSettings = async () => {
            settings.style.display = 'block';
            gearBtn.classList.add('active');
            renderRelayList();
            renderMutedList();
            if (localWallet) {
                keypairSection.style.display = 'block';
                privkeyDisplay.value = await GM_getValue('nostrcomments_privkey', '') || '';
            }
        };
        const closeSettings = () => {
            settings.style.display = 'none';
            gearBtn.classList.remove('active');
        };
        gearBtn.onclick = async () => {
            if (settings.style.display === 'block') closeSettings();
            else await openSettings();
        };
        settingsClose.onclick = closeSettings;

        relayInput.onkeydown = e => { if (e.key === 'Enter') relayAddBtn.onclick(); };
        relayAddBtn.onclick = () => {
            const url = relayInput.value.trim();
            if (!url.startsWith('wss://') || url.length < 10) return showMsg('Enter a valid wss:// URL');
            if (RELAYS.includes(url)) return showMsg('Relay already in list');
            RELAYS.push(url);
            saveRelays();
            relayInput.value = '';
            renderRelayList();
            showMsg('Relay saved — reload page to connect');
        };


        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();
        const votes = new Map(); // eventId -> Map(voterPubkey -> val), for score dedup
        const profiles = new Map();
        const avatars = new Map();
        const lud16s = new Map();
        let localWallet = null;
        let mutedPubkeys = new Set(_st.muted || []);
        function saveMuted() { GM_setValue('nostrcomments_muted', [...mutedPubkeys]); }
        let unreadReplies = 0;
        let q = '';
        let pageSize = 20;
        let replyTo = null;

        // Auto-load saved local wallet (must be after all let declarations to avoid TDZ)
        {
            const saved = _st.privkey;
            if (saved && !window.nostr) { localWallet = await makeLocalWallet(saved); connect(); }
        }

        // NIP-01 profile fetching
        function fetchProfiles(pubkeys) {
            const missing = [...new Set(pubkeys)].filter(p => !profiles.has(p));
            if (!missing.length) return;
            missing.forEach(p => profiles.set(p, null));
            RELAYS.slice(0, 2).forEach(r => {
                try {
                    const ws = new WebSocket(r);
                    const pid = 'p' + Math.random().toString(36).slice(2, 6);
                    const t = setTimeout(() => ws.close(), 8000);
                    ws.onopen = () => ws.send(JSON.stringify(["REQ", pid, {kinds:[0], authors: missing}]));
                    ws.onmessage = m => {
                        let parsed;
                        try { parsed = JSON.parse(m.data); } catch(e) { return; }
                        const [type,,ev] = parsed;
                        if (type === 'EOSE') { clearTimeout(t); ws.close(); scheduleRender(); return; }
                        if (type !== 'EVENT' || ev?.kind !== 0) return;
                        if (typeof ev.pubkey !== 'string' || !/^[0-9a-f]{64}$/i.test(ev.pubkey)) return;
                        try {
                            const p = JSON.parse(ev.content);
                            profiles.set(ev.pubkey, p.display_name || p.name || null);
                            if (p.lud16) lud16s.set(ev.pubkey, p.lud16);
                            if (p.picture) avatars.set(ev.pubkey, p.picture);
                        } catch(e) {}
                    };
                } catch(e) {}
            });
        }

        function getWallet() { return window.nostr || localWallet; }

        function updateNotifBadge() {
            nBadge.textContent = unreadReplies > 9 ? '9+' : String(unreadReplies);
            nBadge.classList.toggle('nc-on', unreadReplies > 0);
        }

        function startNotifSub() {
            if (!myPub) return;
            const since = Math.floor(Date.now() / 1000);
            const sid = 'ncn' + Math.random().toString(36).slice(2, 8);
            RELAYS.slice(0, 3).forEach(r => {
                try {
                    const ws = new WebSocket(r);
                    ws.onopen = () => ws.send(JSON.stringify(["REQ", sid, {kinds:[1], "#p":[myPub], since}]));
                    ws.onmessage = m => {
                        let parsed;
                        try { parsed = JSON.parse(m.data); } catch(e) { return; }
                        if (parsed[0] !== 'EVENT' || parsed[2]?.kind !== 1) return;
                        const ev = parsed[2];
                        if (ev.pubkey === myPub) return;
                        if (typeof ev.pubkey !== 'string' || !/^[0-9a-f]{64}$/i.test(ev.pubkey)) return;
                        unreadReplies++;
                        updateNotifBadge();
                    };
                    ws.onerror = () => ws.close();
                } catch(e) {}
            });
        }

        let connecting = false;
        async function connect() {
            if (connecting) return;
            connecting = true;
            const wallet = getWallet();
            if (!wallet) { connecting = false; return status.textContent = "Install Alby/nos2x"; }
            try {
                myPub = await wallet.getPublicKey();
                status.textContent = `Connected …${myPub.slice(-8)}`;
                status.style.color = "#2e7d32";
                connectBtn.disabled = true;
                onboard.style.display = 'none';
                startNotifSub();
            } catch(e) {}
            connecting = false;
        }
        connectBtn.onclick = connect;
        const connectTimer = setInterval(() => {
            if (myPub) { clearInterval(connectTimer); return; }
            if (!window.nostr && !localWallet) { clearInterval(connectTimer); return; }
            connect();
        }, 5000);

        // Reply state
        function setReply(ev) {
            replyTo = ev;
            const name = profiles.get(ev.pubkey) || toNpub(ev.pubkey).slice(0,12)+'…';
            replyToLabel.textContent = `Replying to ${name}`;
            replyIndicator.style.display = 'flex';
            input.placeholder = 'Write your reply…';
            input.focus();
        }

        replyCancel.onclick = () => {
            replyTo = null;
            replyIndicator.style.display = 'none';
            input.placeholder = 'Write your comment…';
        };

        async function zap(ev) {
            const lud16 = lud16s.get(ev.pubkey);
            if (!lud16) return showMsg('This user has no Lightning address');
            const [name, domain] = lud16.split('@');
            const endpoint = `https://${domain}/.well-known/lnurlp/${name}`;
            let lnurlData;
            try { lnurlData = await fetch(endpoint).then(r => r.json()); } catch(e) { return showMsg('Could not reach Lightning address'); }
            if (lnurlData.status === 'ERROR') return showMsg(lnurlData.reason || 'Lightning error');
            const amount = 21000;
            if (amount < lnurlData.minSendable || amount > lnurlData.maxSendable) return showMsg('21 sats out of range for this user');
            let nostrParam = '';
            if (myPub && getWallet() && lnurlData.allowsNostr && lnurlData.nostrPubkey) {
                try {
                    const zapReq = {
                        kind: 9734,
                        created_at: Math.floor(Date.now() / 1000),
                        content: '',
                        tags: [['relays', ...RELAYS], ['amount', String(amount)], ['lnurl', endpoint], ['p', ev.pubkey], ['e', ev.id]],
                        pubkey: myPub
                    };
                    const signed = await getWallet().signEvent(zapReq);
                    nostrParam = '&nostr=' + encodeURIComponent(JSON.stringify(signed));
                } catch(e) {}
            }
            let invoiceData;
            try { invoiceData = await fetch(`${lnurlData.callback}?amount=${amount}${nostrParam}`).then(r => r.json()); } catch(e) { return showMsg('Could not get invoice'); }
            if (invoiceData.status === 'ERROR') return showMsg(invoiceData.reason || 'Invoice error');
            const pr = invoiceData.pr;
            if (window.webln) {
                try { await window.webln.enable(); await window.webln.sendPayment(pr); return showMsg('⚡ Zapped 21 sats!'); } catch(e) {}
            }
            try { await navigator.clipboard.writeText(pr); showMsg('Invoice copied — paste into your wallet'); }
            catch(e) { showMsg('No wallet found. Copy invoice: ' + pr.slice(0, 30) + '…'); }
        }

        function renderMarkdown(text) {
            const frag = document.createDocumentFragment();
            text.split('\n').forEach((line, i) => {
                if (i > 0) frag.appendChild(document.createElement('br'));
                const pat = /\*\*(.+?)\*\*|\*(.+?)\*|`([^`]+)`|\[([^\]]+)\]\((https?:\/\/[^)]+)\)|(https?:\/\/\S+)/g;
                let last = 0, m;
                while ((m = pat.exec(line)) !== null) {
                    if (m.index > last) frag.appendChild(document.createTextNode(line.slice(last, m.index)));
                    if (m[1] != null) { const el = document.createElement('strong'); el.textContent = m[1]; frag.appendChild(el); }
                    else if (m[2] != null) { const el = document.createElement('em'); el.textContent = m[2]; frag.appendChild(el); }
                    else if (m[3] != null) { const el = document.createElement('code'); el.textContent = m[3]; frag.appendChild(el); }
                    else if (m[4] != null) { const a = document.createElement('a'); a.href = m[5]; a.textContent = m[4]; a.target = '_blank'; a.rel = 'noopener noreferrer'; frag.appendChild(a); }
                    else if (m[6] != null) {
                        const url = m[6];
                        if (/\.(jpe?g|png|gif|webp|svg)(\?.*)?$/i.test(url)) {
                            const img = document.createElement('img');
                            img.src = url;
                            img.className = 'nc-img';
                            img.onclick = () => window.open(url, '_blank');
                            img.onerror = () => { const a = document.createElement('a'); a.href = url; a.textContent = url; a.target = '_blank'; a.rel = 'noopener noreferrer'; img.replaceWith(a); };
                            frag.appendChild(img);
                        } else if (/\.(mp4|mov|webm)(\?.*)?$/i.test(url)) {
                            const vid = document.createElement('video');
                            vid.src = url; vid.controls = true;
                            vid.className = 'nc-vid';
                            frag.appendChild(vid);
                        } else {
                            const a = document.createElement('a'); a.href = url; a.textContent = url; a.target = '_blank'; a.rel = 'noopener noreferrer'; frag.appendChild(a);
                        }
                    }
                    last = m.index + m[0].length;
                }
                if (last < line.length) frag.appendChild(document.createTextNode(line.slice(last)));
            });
            return frag;
        }

        function makeItem(ev, sc, hidden, depth) {
            const div = document.createElement('div');
            div.className = 'c' + (hidden ? ' h' : '') + (depth > 0 ? ' reply' : '') + (ev.pubkey === myPub ? ' own' : '');
            if (hidden) {
                div.textContent = `Hidden (${sc.down} downvotes) — tap to show`;
                div.onclick = () => { div.classList.remove('h'); div.onclick = null; };
                return div;
            }
            const name = profiles.get(ev.pubkey) || toNpub(ev.pubkey).slice(0,12)+'…';
            const header = document.createElement('div');
            header.className = 'nc-header';
            const profileLink = document.createElement('a');
            profileLink.href = `https://njump.me/${toNpub(ev.pubkey)}`;
            profileLink.target = '_blank'; profileLink.rel = 'noopener noreferrer';
            profileLink.className = 'nc-plink';
            const avatarUrl = avatars.get(ev.pubkey);
            if (avatarUrl) {
                const img = document.createElement('img');
                img.className = 'avatar'; img.src = avatarUrl; img.alt = name;
                img.onerror = () => img.remove();
                profileLink.appendChild(img);
            }
            const nameEl = document.createElement('span');
            nameEl.className = 'nc-name';
            nameEl.textContent = name;
            profileLink.appendChild(nameEl);
            header.appendChild(profileLink);
            const meta = document.createElement('small');
            meta.className = 'ts';
            meta.textContent = timeAgo(ev.created_at);
            header.appendChild(meta);
            const body = document.createElement('div');
            body.className = 'nc-body';
            body.appendChild(renderMarkdown(ev.content));
            const actions = document.createElement('div');
            actions.className = 'nc-actions';
            [['1','↑',sc.up],['-1','↓',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);
            });
            const replyBtn = document.createElement('button');
            replyBtn.className = 'reply-btn';
            replyBtn.textContent = '↩ Reply';
            replyBtn.onclick = () => setReply(ev);
            actions.appendChild(replyBtn);
            const zapBtn = document.createElement('button');
            zapBtn.className = 'zap-btn';
            zapBtn.textContent = '⚡';
            zapBtn.title = 'Zap 21 sats';
            zapBtn.onclick = () => zap(ev);
            actions.appendChild(zapBtn);
            const copyBtn = document.createElement('button');
            copyBtn.className = 'copy-btn';
            copyBtn.textContent = '🔗';
            copyBtn.title = 'Copy link to this comment';
            copyBtn.onclick = () => navigator.clipboard.writeText(`nostr:${toNote(ev.id)}`).then(() => showMsg('Link copied'));
            actions.appendChild(copyBtn);
            if (ev.pubkey !== myPub) {
                const muteBtn = document.createElement('button');
                muteBtn.className = 'mute-btn';
                muteBtn.textContent = '🚫 Mute';
                muteBtn.title = 'Hide all comments from this user';
                muteBtn.onclick = () => { mutedPubkeys.add(ev.pubkey); saveMuted(); render(); showMsg('User muted — unmute via ⚙ Settings'); };
                actions.appendChild(muteBtn);
            }
            div.append(header, body, actions);
            return div;
        }

        function render() {
            let shown = comments.filter(c => !mutedPubkeys.has(c.pubkey) && 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,sc)=>a+sc.up+sc.down,0);
            const hide = Math.max(5, Math.round(0.1 * total));

            const commentIds = new Set(comments.map(c => c.id));
            const getParentId = ev => ev.tags?.find(t => t[0]==='e' && commentIds.has(t[1]))?.[1];
            const topLevel = shown.filter(ev => !getParentId(ev));
            const replies = shown.filter(ev => !!getParentId(ev));

            const visited = new Set();
            function appendWithReplies(ev, depth) {
                if (visited.has(ev.id) || depth > 10) return;
                visited.add(ev.id);
                const sc = scores.get(ev.id) || {up:0,down:0};
                list.appendChild(makeItem(ev, sc, sc.down >= hide, depth));
                replies.filter(r => getParentId(r) === ev.id)
                       .forEach(r => appendWithReplies(r, depth + 1));
            }

            list.replaceChildren();
            const page = topLevel.slice(0, pageSize);
            if (page.length === 0) {
                const empty = document.createElement('i');
                empty.className = 'nc-empty';
                empty.textContent = 'No comments yet – be the first!';
                list.appendChild(empty);
            } else {
                page.forEach(ev => appendWithReplies(ev, 0));
            }
            loadMore.style.display = topLevel.length > pageSize ? 'block' : 'none';
            const count = comments.length;
            badge.textContent = count > 99 ? '99+' : String(count);
            badge.classList.toggle('nc-on', count > 0);
        }

        let renderTimer = null;
        function scheduleRender() {
            if (renderTimer) return;
            renderTimer = requestAnimationFrame(() => { renderTimer = null; render(); });
        }

        document.addEventListener('keydown', e => { if (e.key === 'Escape') modal.style.display = '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 sc = scores.get(id) || {up:0,down:0};
            if (sc.my === val) return;
            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 getWallet().signEvent(ev);
                RELAYS.forEach(r => {
                    try {
                        const ws = new WebSocket(r);
                        ws.onopen = () => ws.send(JSON.stringify(["EVENT", signed]));
                        ws.onmessage = m => { try { const d=JSON.parse(m.data); if(d[0]==='OK'&&!d[2]) showMsg(`Relay rejected: ${d[3]||'unknown'}`); } catch(e){} ws.close(); };
                        ws.onerror = () => ws.close();
                        setTimeout(() => { if (ws.readyState < 2) ws.close(); }, 8000);
                    } catch(e) {}

                });
                if (sc.my===1) sc.up--; if (sc.my===-1) sc.down--;
                if (val===1) sc.up++; if (val===-1) sc.down++;
                sc.my = val;
                scores.set(id, sc);
                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 tags = [["r", pageUrl]];
            if (replyTo) {
                tags.push(["e", replyTo.id, "", "reply"]);
                tags.push(["p", replyTo.pubkey]);
            }
            const ev = {kind:1, created_at:Math.floor(Date.now()/1000), tags, content:text, pubkey:myPub};
            try {
                const signed = await getWallet().signEvent(ev);
                RELAYS.forEach(r => {
                    try {
                        const ws = new WebSocket(r);
                        ws.onopen = () => ws.send(JSON.stringify(["EVENT", signed]));
                        ws.onmessage = m => { try { const d=JSON.parse(m.data); if(d[0]==='OK'&&!d[2]) showMsg(`Relay rejected: ${d[3]||'unknown'}`); } catch(e){} ws.close(); };
                        ws.onerror = () => ws.close();
                        setTimeout(() => { if (ws.readyState < 2) ws.close(); }, 8000);
                    } catch(e) {}

                });
                comments.unshift(signed);
                input.value = '';
                replyCancel.onclick();
                render();
            } catch(e) {
                showMsg("Failed to sign — try again.");
            } finally {
                send.disabled = false;
            }
        };

        // Load everything — wait for all relays to EOSE before fetching replies
        const _wsPool = [];
        let _eoseCount = 0, _repliesFetched = false;
        function _fetchReplies() {
            if (_repliesFetched) return;
            _repliesFetched = true;
            clearTimeout(_eoseTimer);
            const ids = comments.map(c => c.id);
            if (ids.length) _wsPool.forEach(w => { if (w.readyState === 1) w.send(JSON.stringify(["REQ", subId+'r', {kinds:[1], "#e": ids, limit:500}])); });
            fetchProfiles(comments.map(c => c.pubkey));
        }
        const _eoseTimer = setTimeout(_fetchReplies, 5000);
        RELAYS.forEach(r => {
            const ws = new WebSocket(r);
            _wsPool.push(ws);
            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 === 'EOSE') {
                    if (++_eoseCount >= _wsPool.length) _fetchReplies();
                    return;
                }
                if (type !== 'EVENT') return;
                const ev = parsed[2];
                if (!ev) return;
                if (typeof ev.pubkey !== 'string' || !/^[0-9a-f]{64}$/i.test(ev.pubkey)) return;
                if (typeof ev.id !== 'string' || !/^[0-9a-f]{64}$/i.test(ev.id)) return;
                if (ev.kind === 1) {
                    if (!comments.find(c => c.id === ev.id)) {
                        const commentIds = new Set(comments.map(c => c.id));
                        const hasPageTag = ev.tags?.some(t => t[0]==="r" && t[1]===pageUrl);
                        const isReply = ev.tags?.some(t => t[0]==="e" && commentIds.has(t[1]));
                        if (hasPageTag || isReply) {
                            comments.push(ev);
                            fetchProfiles([ev.pubkey]);
                        }
                    }
                }
                if (ev.kind === 7) {
                    const e = ev.tags?.find(t => t[0]==="e")?.[1];
                    if (e) {
                        const val = ev.content === '+' || ev.content === '' ? 1 : ev.content === '-' ? -1 : 0;
                        if (val) {
                            if (!votes.has(e)) votes.set(e, new Map());
                            votes.get(e).set(ev.pubkey, val);
                            let up = 0, down = 0;
                            votes.get(e).forEach(v => { if (v===1) up++; else down++; });
                            scores.set(e, {up, down, my: scores.get(e)?.my});
                        }
                    }
                }
                scheduleRender();
            };
        });

        render();
    }
})();