Comment freely on every website — without censorship
// ==UserScript==
// @name NostrComments
// @namespace https://github.com/briskness-byte/NostrComments
// @version 22.23
// @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';
Object.assign(badge.style, {position:'absolute',top:'-6px',right:'-6px',background:'#e53935',color:'white',borderRadius:'12px',fontSize:'12px',fontWeight:'bold',padding:'2px 6px',minWidth:'20px',textAlign:'center',display:'none',fontFamily:'system-ui,sans-serif',lineHeight:'1.5',pointerEvents:'none'});
btn.appendChild(badge);
const nBadge = document.createElement('span');
nBadge.id = 'nc-nbadge';
Object.assign(nBadge.style, {position:'absolute',top:'-6px',left:'-6px',background:'#f59e0b',color:'white',borderRadius:'12px',fontSize:'12px',fontWeight:'bold',padding:'2px 6px',minWidth:'20px',textAlign:'center',display:'none',fontFamily:'system-ui,sans-serif',lineHeight:'1.5',pointerEvents:'none'});
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.style.display = unreadReplies > 0 ? 'block' : 'none';
}
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.style.display = count > 0 ? 'block' : 'none';
}
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 => {
try {
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();
};
} catch(e) {}
});
render();
}
})();