Comment freely on every website — without censorship
// ==UserScript==
// @name NostrComments
// @namespace https://github.com/briskness-byte/NostrComments
// @version 20.4
// @description Comment freely on every website — without censorship
// @author Built on Nostr
// @license MIT
// @match *://*/*
// @grant none
// @run-at document-end
// ==/UserScript==
(() => {
'use strict';
if (!document.body) { document.addEventListener('DOMContentLoaded', init); return; }
init();
function init() {
// Floating button
const btn = document.createElement('div');
btn.appendChild(new DOMParser().parseFromString('<svg xmlns="http://www.w3.org/2000/svg" width="36" height="36" viewBox="0 0 24 24" fill="white"><path d="M20 2H4c-1.1 0-2 .9-2 2v14l4-4h14c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2z"/><circle cx="7.5" cy="9.5" r="1.5"/><circle cx="12" cy="9.5" r="1.5"/><circle cx="16.5" cy="9.5" r="1.5"/></svg>', 'image/svg+xml').documentElement);
Object.assign(btn.style, {
position:'fixed', right:'18px', bottom:'18px', width:'68px', height:'68px',
background:'linear-gradient(135deg,#1d9bf0,#0d8bf0)', borderRadius:'50%',
display:'flex', alignItems:'center', justifyContent:'center', cursor:'pointer',
zIndex:'2147483647', boxShadow:'0 12px 35px rgba(29,155,240,0.6)',
transition:'transform .25s ease', userSelect:'none'
});
btn.onmouseenter = () => btn.style.transform = 'scale(1.12)';
btn.onmouseleave = () => btn.style.transform = 'scale(1)';
document.body.appendChild(btn);
// Shadow DOM modal
const host = document.createElement('div');
document.body.appendChild(host);
const s = host.attachShadow({mode:'open'});
const styleEl = document.createElement('style');
styleEl.textContent = `
#m{display:none;position:fixed;inset:0;background:rgba(0,0,0,0.94);z-index:2147483647;place-items:center;font-family:system-ui,sans-serif}
#p{background:#fff;width:95%;max-width:860px;max-height:94vh;overflow-y:auto;border-radius:20px;padding:24px;position:relative;box-shadow:0 20px 60px rgba(0,0,0,0.6)}
#c{position:absolute;top:12px;right:16px;width:50px;height:50px;font-size:40px;background:none;border:none;cursor:pointer;color:#555;display:flex;align-items:center;justify-content:center}
h2{color:#1d9bf0;margin:0 0 24px;text-align:center;font-size:28px;font-weight:600}
#controls{display:flex;flex-direction:column;gap:16px;margin:20px 0}
#connect,#send,#loadMore{padding:16px 20px;border-radius:14px;font-size:18px;font-weight:600;cursor:pointer}
#connect,#send{background:#1d9bf0;color:white;border:none}
#loadMore{background:#0d8bf0;color:white;border:none;display:none}
input,select{padding:14px 16px;border:1px solid #ddd;border-radius:14px;font-size:17px}
#input-wrapper{position:relative;margin:24px 0}
#input{width:100%;min-height:120px;padding:18px 18px 50px;border:2px solid #e2e8f0;border-radius:16px;font-size:17px;background:#fafbfc;resize:none}
#send{position:absolute;bottom:12px;right:12px;padding:14px 32px;border-radius:12px}
#list{max-height:48vh;overflow-y:auto;background:#f8f9fa;padding:20px;border-radius:16px;margin:20px 0}
.c{background:white;padding:18px;margin:12px 0;border-radius:16px;border-left:6px solid #1d9bf0;box-shadow:0 3px 12px rgba(0,0,0,0.08)}
.v{font-size:28px;background:none;border:none;cursor:pointer;padding:8px 12px;min-width:56px}
.h{opacity:0.5;font-style:italic;cursor:pointer;padding:30px;background:#f0f0f0;border-radius:16px;text-align:center;font-size:18px}
#msg{display:none;background:#1d9bf0;color:white;padding:12px 18px;border-radius:12px;text-align:center;font-size:16px;margin:0 0 12px}
#donate{text-align:center;margin:20px 0 10px;font-size:14px;color:#666}
#donate a{color:#f7931a;text-decoration:none;font-weight:600}
#donate a:hover{text-decoration:underline}
@media(min-width:768px){#controls{flex-direction:row;align-items:center}#search{width:260px}}
`;
s.appendChild(styleEl);
const _tpl = new DOMParser().parseFromString(`<html><body>
<div id="m">
<div id="p">
<button id="c">\u00d7</button>
<h2>NostrComments</h2>
<div id="controls">
<button id="connect">Connect Nostr</button>
<span id="status" style="color:#c62828;font-weight:bold">Not connected</span>
<input id="search" placeholder="Search comments\u2026">
<select id="sort">
<option value="newest">Newest first</option>
<option value="oldest">Oldest first</option>
<option value="upvotes">Most upvotes</option>
</select>
</div>
<div id="list"></div>
<button id="loadMore">Load more</button>
<div id="msg"></div>
<div id="input-wrapper">
<textarea id="input" placeholder="Write your comment\u2026"></textarea>
<button id="send">Post</button>
</div>
<div id="donate">
Enjoying NostrComments? <a href="bitcoin:198yNVWJz2H8PwmNsX72URVVV9pRbxMb18" target="_blank">Send a tip (Bitcoin/Lightning)</a>
</div>
</div>
</div>
</body></html>`, 'text/html');
s.appendChild(document.adoptNode(_tpl.getElementById('m')));
const modal = s.getElementById('m');
const list = s.getElementById('list');
const input = s.getElementById('input');
const send = s.getElementById('send');
const connectBtn = s.getElementById('connect');
const status = s.getElementById('status');
const search = s.getElementById('search');
const sort = s.getElementById('sort');
const loadMore = s.getElementById('loadMore');
const msg = s.getElementById('msg');
function showMsg(text) {
msg.textContent = text;
msg.style.display = 'block';
setTimeout(() => msg.style.display = 'none', 2500);
}
btn.onclick = () => modal.style.display = 'grid';
s.getElementById('c').onclick = () => modal.style.display = 'none';
const pageUrl = location.origin + location.pathname;
const subId = 'nc' + Math.random().toString(36).slice(2, 8);
let myPub = null;
const comments = [];
const scores = new Map();
let q = '';
let pageSize = 20;
async function connect() {
if (!window.nostr) return status.textContent = "Install Alby/nos2x";
try { myPub = await window.nostr.getPublicKey();
status.textContent = `Connected …${myPub.slice(-8)}`;
status.style.color = "#2e7d32";
connectBtn.disabled = true;
} catch(e) {}
}
connectBtn.onclick = connect;
const connectTimer = setInterval(() => { if (!myPub) connect(); else clearInterval(connectTimer); }, 5000);
function makeItem(ev, sc, hidden) {
const div = document.createElement('div');
div.className = 'c' + (hidden ? ' h' : '');
if (hidden) {
div.textContent = `Hidden (${sc.down} downvotes) \u2014 tap to show`;
div.onclick = () => { div.classList.remove('h'); div.onclick = null; };
return div;
}
const meta = document.createElement('small');
meta.style.color = '#666';
meta.textContent = `${new Date(ev.created_at*1000).toLocaleString()} \u00b7 \u2026${ev.pubkey.slice(-8)}`;
const body = document.createElement('div');
body.style.cssText = 'margin:12px 0;font-size:17px;line-height:1.6';
ev.content.split('\n').forEach((line, i) => {
if (i > 0) body.appendChild(document.createElement('br'));
body.appendChild(document.createTextNode(line));
});
const actions = document.createElement('div');
actions.style.marginTop = '12px';
[['1','\u2191',sc.up],['-1','\u2193',sc.down]].forEach(([val, arrow, count]) => {
const b = document.createElement('button');
b.className = 'v'; b.dataset.id = ev.id; b.dataset.val = val;
b.textContent = `${arrow} ${count}`;
actions.appendChild(b);
});
div.append(meta, document.createElement('br'), body, actions);
return div;
}
function render() {
let shown = comments.filter(c => c.content.toLowerCase().includes(q));
if (sort.value === 'oldest') shown.sort((a,b) => a.created_at - b.created_at);
if (sort.value === 'newest') shown.sort((a,b) => b.created_at - a.created_at);
if (sort.value === 'upvotes') shown.sort((a,b) => (scores.get(b.id)?.up||0) - (scores.get(a.id)?.up||0));
const total = Array.from(scores.values()).reduce((a,s)=>a+s.up+s.down,0);
const hide = Math.max(5, Math.round(0.1 * total));
list.replaceChildren();
const page = shown.slice(0, pageSize);
if (page.length === 0) {
const empty = document.createElement('i');
empty.style.cssText = 'color:#888;font-size:18px';
empty.textContent = 'No comments yet \u2013 be the first!';
list.appendChild(empty);
} else {
page.forEach(ev => {
const sc = scores.get(ev.id) || {up:0,down:0};
list.appendChild(makeItem(ev, sc, sc.down >= hide));
});
}
loadMore.style.display = shown.length > pageSize ? 'block' : 'none';
}
search.addEventListener('input', () => { q = search.value.toLowerCase(); pageSize = 20; render(); });
sort.onchange = () => { pageSize = 20; render(); };
loadMore.onclick = () => { pageSize += 20; render(); };
s.addEventListener('click', e => {
if (e.target.classList.contains('v')) {
const id = e.target.dataset.id;
const val = Number(e.target.dataset.val);
if (!myPub) return showMsg("Connect first!");
vote(id, val);
}
});
async function vote(id, val) {
const comment = comments.find(c=>c.id===id);
if (!comment) return;
const s = scores.get(id) || {up:0,down:0};
const ev = {kind:7,created_at:Math.floor(Date.now()/1000),tags:[["e",id],["p",comment.pubkey]],content:val===1?'+':'-',pubkey:myPub};
try {
const signed = await window.nostr.signEvent(ev);
["wss://nos.lol","wss://relay.damus.io"].forEach(r=>{
try{const ws=new WebSocket(r);ws.onopen=()=>ws.send(JSON.stringify(["EVENT",signed]));}catch(e){}
});
if (s.my===1) s.up--; if (s.my===-1) s.down--;
if (val===1) s.up++; if (val===-1) s.down++;
s.my = val;
scores.set(id, s);
render();
} catch(e) {
showMsg("Failed to sign — try again.");
}
}
send.onclick = async () => {
if (!myPub) return showMsg("Connect first!");
const text = input.value.trim();
if (!text) return;
send.disabled = true;
const ev = {kind:1,created_at:Math.floor(Date.now()/1000),tags:[["r",pageUrl]],content:text,pubkey:myPub};
try {
const signed = await window.nostr.signEvent(ev);
["wss://nos.lol","wss://relay.damus.io"].forEach(r=>{
try{const ws=new WebSocket(r);ws.onopen=()=>ws.send(JSON.stringify(["EVENT",signed]));}catch(e){}
});
comments.unshift(signed);
input.value = '';
render();
} catch(e) {
showMsg("Failed to sign — try again.");
} finally {
send.disabled = false;
}
};
// Load everything
["wss://nos.lol","wss://relay.damus.io"].forEach(r => {
const ws = new WebSocket(r);
ws.onopen = () => ws.send(JSON.stringify(["REQ",subId,{kinds:[1,7],"#r":[pageUrl],limit:500}]));
ws.onmessage = m => {
let parsed;
try { parsed = JSON.parse(m.data); } catch(e) { return; }
const [type] = parsed;
if (type === 'OK') {
const [, , ok, info] = parsed;
if (!ok) showMsg(`Relay rejected: ${info || 'unknown reason'}`);
return;
}
if (type !== 'EVENT') return;
const ev = parsed[2];
if (!ev) return;
if (ev.kind===1 && ev.tags?.some(t=>t[0]==="r"&&t[1]===pageUrl)) {
if (!comments.find(c=>c.id===ev.id)) comments.push(ev);
}
if (ev.kind===7) {
const e = ev.tags?.find(t=>t[0]==="e")?.[1];
if (e) {
const s = scores.get(e) || {up:0,down:0};
const val = ev.content === '+' || ev.content === '' ? 1 : ev.content === '-' ? -1 : 0;
if (val) { if (val===1) s.up++; if (val===-1) s.down++; scores.set(e, s); }
}
}
render();
};
});
render();
}
})();