NostrComments

Comment freely on every website — without censorship

Bu betiği kurabilmeniz için Tampermonkey, Greasemonkey ya da Violentmonkey gibi bir kullanıcı betiği eklentisini kurmanız gerekmektedir.

Bu betiği yüklemek için Tampermonkey gibi bir uzantı yüklemeniz gerekir.

Bu betiği kurabilmeniz için Tampermonkey ya da Violentmonkey gibi bir kullanıcı betiği eklentisini kurmanız gerekmektedir.

Bu betiği kurabilmeniz için Tampermonkey ya da Userscripts gibi bir kullanıcı betiği eklentisini kurmanız gerekmektedir.

Bu betiği indirebilmeniz için ayrıca Tampermonkey gibi bir eklenti kurmanız gerekmektedir.

Bu komut dosyasını yüklemek için bir kullanıcı komut dosyası yöneticisi uzantısı yüklemeniz gerekecek.

(Zaten bir kullanıcı komut dosyası yöneticim var, kurmama izin verin!)

Bu stili yüklemek için Stylus gibi bir uzantı yüklemeniz gerekir.

Bu stili yüklemek için Stylus gibi bir uzantı kurmanız gerekir.

Bu stili yükleyebilmek için Stylus gibi bir uzantı yüklemeniz gerekir.

Bu stili yüklemek için bir kullanıcı stili yöneticisi uzantısı yüklemeniz gerekir.

Bu stili yüklemek için bir kullanıcı stili yöneticisi uzantısı kurmanız gerekir.

Bu stili yükleyebilmek için bir kullanıcı stili yöneticisi uzantısı yüklemeniz gerekir.

(Zateb bir user-style yöneticim var, yükleyeyim!)

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