Greasy Fork is available in English.
Read Hacker News top-comments in right-sidebar with DOM node construction (no innerHTML).
// ==UserScript==
// @name HackerBrain Sidebar
// @namespace https://news.ycombinator.com/
// @version 2.94
// @description Read Hacker News top-comments in right-sidebar with DOM node construction (no innerHTML).
// @match https://news.ycombinator.com/*
// @grant GM_xmlhttpRequest
// @connect news.ycombinator.com
// @license MIT
// author adrianwaj (HN)
// ==/UserScript==
(function () {
"use strict";
if (location.pathname === "/item") return;
// --- Configuration & Constants ---
const CONFIG = {
STORAGE_KEY_VISITED: "hn-sidebar-visited-items",
STORAGE_KEY_WIDTH: "hn-sidebar-width",
STORAGE_KEY_TRUNCATED: "hn-sidebar-truncated-comments",
DEFAULT_WIDTH: 360,
MIN_WIDTH: 200,
MAX_WIDTH: 800,
MAX_STORED_ITEMS: 500,
};
let activeSidebarLink = null;
let activeTruncateBtn = null;
let lastSidebarItem = null;
// --- State Caching ---
let cachedVisited = null;
let cachedTruncated = null;
// --- LocalStorage Helpers with Auto-Pruning ---
const getVisitedItems = () => {
if (!cachedVisited) {
try {
cachedVisited = JSON.parse(localStorage.getItem(CONFIG.STORAGE_KEY_VISITED)) || [];
} catch {
cachedVisited = [];
}
}
return cachedVisited;
};
const markItemAsVisited = (itemId) => {
const visited = getVisitedItems();
if (!visited.includes(itemId)) {
visited.push(itemId);
while (visited.length > CONFIG.MAX_STORED_ITEMS) {
visited.shift();
}
try {
localStorage.setItem(CONFIG.STORAGE_KEY_VISITED, JSON.stringify(visited));
} catch (e) {
console.warn("HN Sidebar: Failed to save visited state", e);
}
}
};
const getTruncatedComments = () => {
if (!cachedTruncated) {
try {
cachedTruncated = JSON.parse(localStorage.getItem(CONFIG.STORAGE_KEY_TRUNCATED)) || [];
} catch {
cachedTruncated = [];
}
}
return cachedTruncated;
};
const markCommentAsTruncated = (commentId) => {
const truncated = getTruncatedComments();
if (!truncated.includes(commentId)) {
truncated.push(commentId);
while (truncated.length > CONFIG.MAX_STORED_ITEMS) {
truncated.shift();
}
try {
localStorage.setItem(CONFIG.STORAGE_KEY_TRUNCATED, JSON.stringify(truncated));
} catch (e) {
console.warn("HN Sidebar: Failed to save truncated comment state", e);
}
}
};
// --- DOM Sanitizer returning DocumentFragment ---
const sanitizeToFragment = (rawHtml) => {
const fragment = document.createDocumentFragment();
if (!rawHtml) return fragment;
const parser = new DOMParser();
const doc = parser.parseFromString(rawHtml, "text/html");
const allowedTags = new Set([
"A", "P", "B", "I", "U", "EM", "STRONG", "CODE", "PRE",
"SPAN", "DIV", "BR", "BLOCKQUOTE", "SUB", "SUP"
]);
const cleanNode = (node) => {
const children = Array.from(node.childNodes);
for (const child of children) {
if (child.nodeType === Node.ELEMENT_NODE) {
const tagName = child.tagName.toUpperCase();
if (!allowedTags.has(tagName)) {
child.remove();
continue;
}
const attrs = Array.from(child.attributes);
for (const attr of attrs) {
const attrName = attr.name.toLowerCase();
const attrVal = attr.value.trim().toLowerCase();
if (attrName.startsWith("on") ||
((attrName === "href" || attrName === "src") &&
(attrVal.startsWith("javascript:") || attrVal.startsWith("data:")))) {
child.removeAttribute(attr.name);
}
}
if (tagName === "A") {
child.setAttribute("rel", "noopener noreferrer");
child.setAttribute("target", "_blank");
}
cleanNode(child);
}
}
};
cleanNode(doc.body);
while (doc.body.firstChild) {
fragment.appendChild(doc.body.firstChild);
}
return fragment;
};
// --- Inject CSS Styles ---
const style = document.createElement("style");
style.textContent = `
#hn-comment-sidebar {
position: fixed;
right: 0;
top: 0;
width: ${localStorage.getItem(CONFIG.STORAGE_KEY_WIDTH) || CONFIG.DEFAULT_WIDTH}px;
height: 100vh;
overflow-y: auto;
background: #f6f6ef;
padding: 12px;
z-index: 99999;
font-family: Verdana, Geneva, sans-serif;
font-size: 10pt;
border-left: 1px solid #828282;
box-sizing: border-box;
color: #000000;
}
#hn-sidebar-resize {
position: absolute;
left: -4px;
top: 0;
width: 8px;
height: 100%;
cursor: ew-resize;
}
#hn-sidebar-close {
margin-top: 16px;
padding-bottom: 12px;
}
#hn-sidebar-x {
color: inherit;
text-decoration: none;
font-weight: bold;
}
.hn-sidebar-comment {
margin-bottom: 8px;
padding: 6px;
background: #f6f6ef;
}
.hn-sidebar-comment .reply {
display: none !important;
}
.hn-sidebar-user {
color: #828282;
font-size: 8pt;
margin-bottom: 4px;
}
.hn-sidebar-user-link {
color: #828282;
text-decoration: none;
}
.hn-sidebar-text {
color: inherit;
line-height: 1.4;
}
.hn-sidebar-text a {
color: #0000aa;
text-decoration: underline;
}
.hn-sidebar-count {
color: #828282;
cursor: pointer;
margin-left: 5px;
user-select: none;
font-weight: bold;
}
.hn-sidebar-replies {
display: none;
margin-left: 14px;
margin-top: 8px;
border-left: 2px solid #e0e0d8;
padding-left: 6px;
}
.hn-sidebar-comment.open > .hn-sidebar-replies {
display: block;
}
.hn-sidebar-link {
color: #828282;
text-decoration: none;
margin-left: 4px;
padding: 0 3px;
border-radius: 2px;
}
.hn-sidebar-link.active-open {
background: #ff6600;
color: #ffffff !important;
}
.hn-sidebar-link.active-closed {
background: #a8a8a8;
color: #ffffff !important;
}
.hn-sidebar-truncate-btn {
background: #a7dca5;
color: #222222 !important;
padding: 0 3px;
margin-left: 4px;
text-decoration: none;
border-radius: 2px;
cursor: pointer;
user-select: none;
}
.hn-sidebar-loading, .hn-sidebar-error {
color: #828282;
font-style: italic;
padding: 10px 0;
}
.hn-sidebar-comment.hidden-comment {
display: none !important;
}
@media (prefers-color-scheme: dark) {
#hn-comment-sidebar {
background: #1a1a1a;
border-left-color: #444444;
color: #cccccc;
}
.hn-sidebar-comment {
background: #1a1a1a;
}
.hn-sidebar-text a {
color: #63a0ff;
}
.hn-sidebar-replies {
border-left-color: #333333;
}
.hn-sidebar-truncate-btn {
background: #2e5c2d;
color: #e0e0e0 !important;
}
.hn-sidebar-link.active-closed {
background: #555555;
}
}
`;
document.head.appendChild(style);
// --- Build UI ---
const sidebar = document.createElement("div");
sidebar.id = "hn-comment-sidebar";
sidebar.style.display = "none";
const resizeHandle = document.createElement("div");
resizeHandle.id = "hn-sidebar-resize";
const sidebarContent = document.createElement("div");
sidebarContent.id = "hn-sidebar-content";
const closeContainer = document.createElement("div");
closeContainer.id = "hn-sidebar-close";
const closeBtn = document.createElement("a");
closeBtn.id = "hn-sidebar-x";
closeBtn.href = "#";
closeBtn.textContent = "x";
closeContainer.appendChild(closeBtn);
sidebar.appendChild(resizeHandle);
sidebar.appendChild(sidebarContent);
sidebar.appendChild(closeContainer);
document.body.appendChild(sidebar);
// Delegated click listener for toggling reply counters
sidebarContent.addEventListener("click", (e) => {
if (e.target && e.target.classList.contains("hn-sidebar-count")) {
e.stopPropagation();
const commentNode = e.target.closest(".hn-sidebar-comment");
if (commentNode) {
commentNode.classList.toggle("open");
}
}
});
// --- Resizing Logic ---
let isResizing = false;
let resizeAnimationFrame = null;
resizeHandle.addEventListener("mousedown", (e) => {
e.preventDefault();
isResizing = true;
document.body.style.userSelect = "none";
});
document.addEventListener("mousemove", (e) => {
if (!isResizing) return;
if (resizeAnimationFrame) cancelAnimationFrame(resizeAnimationFrame);
resizeAnimationFrame = requestAnimationFrame(() => {
const newWidth = window.innerWidth - e.clientX;
if (newWidth >= CONFIG.MIN_WIDTH && newWidth <= CONFIG.MAX_WIDTH) {
sidebar.style.width = `${newWidth}px`;
}
});
});
document.addEventListener("mouseup", () => {
if (!isResizing) return;
isResizing = false;
document.body.style.userSelect = "";
localStorage.setItem(CONFIG.STORAGE_KEY_WIDTH, parseInt(sidebar.offsetWidth, 10));
});
// --- Counter Handlers ---
const updateCounts = () => {
if (!sidebarContent) return;
const totalTopComments = sidebarContent.children.length;
const visibleTopComments = sidebarContent.querySelectorAll(":scope > .hn-sidebar-comment:not(.hidden-comment)").length;
if (activeSidebarLink) {
activeSidebarLink.textContent = totalTopComments;
}
if (activeTruncateBtn) {
activeTruncateBtn.textContent = visibleTopComments;
}
};
const removeTruncateBtn = () => {
if (activeTruncateBtn) {
activeTruncateBtn.remove();
activeTruncateBtn = null;
}
};
const closeSidebar = () => {
const remainingCount = activeTruncateBtn ? activeTruncateBtn.textContent : null;
sidebar.style.display = "none";
removeTruncateBtn();
if (activeSidebarLink) {
activeSidebarLink.classList.remove("active-open");
activeSidebarLink.classList.add("active-closed");
if (remainingCount !== null) {
activeSidebarLink.textContent = remainingCount;
}
}
};
closeBtn.addEventListener("click", (e) => {
e.preventDefault();
closeSidebar();
});
sidebar.addEventListener("contextmenu", (e) => {
e.preventDefault();
closeSidebar();
});
const truncateTopComment = () => {
const topVisible = sidebarContent.querySelector(":scope > .hn-sidebar-comment:not(.hidden-comment)");
if (topVisible) {
topVisible.classList.add("hidden-comment");
if (topVisible.dataset.commentId) {
markCommentAsTruncated(topVisible.dataset.commentId);
}
sidebar.scrollTop = 0;
updateCounts();
}
};
// Keyboard Hotkeys
document.addEventListener("keydown", (e) => {
if (sidebar.style.display === "none") return;
const activeTag = document.activeElement ? document.activeElement.tagName.toUpperCase() : "";
if (activeTag === "INPUT" || activeTag === "TEXTAREA" || document.activeElement.isContentEditable) {
return;
}
if (e.key === "Escape") {
closeSidebar();
} else if (e.code === "Space") {
e.preventDefault();
truncateTopComment();
}
});
const createTruncateButton = (parentElement) => {
removeTruncateBtn();
const btn = document.createElement("a");
btn.href = "#";
btn.textContent = "...";
btn.className = "hn-sidebar-truncate-btn";
btn.title = "Hide top visible comment (Spacebar)";
btn.addEventListener("click", (e) => {
e.preventDefault();
truncateTopComment();
});
parentElement.appendChild(btn);
activeTruncateBtn = btn;
};
// --- Network & Parsing ---
const fetchItemHtml = (id) => {
return new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: "GET",
url: `https://news.ycombinator.com/item?id=${id}`,
onload: (res) => {
if (res.status >= 200 && res.status < 400) {
resolve(res.responseText);
} else {
reject(new Error(`HTTP ${res.status}`));
}
},
onerror: reject,
});
});
};
const parseComments = (html) => {
const doc = new DOMParser().parseFromString(html, "text/html");
const comments = [];
doc.querySelectorAll(".athing.comtr").forEach((row) => {
const indent = row.querySelector(".ind img");
const level = indent ? Number(indent.width) / 40 : 0;
const commentId = row.id.replace("comment_", "");
const rawCommentHtml = row.querySelector(".comment")?.innerHTML || "";
comments.push({
id: commentId,
level,
user: row.querySelector(".hnuser")?.textContent || "deleted",
userUrl: `https://news.ycombinator.com/item?id=${commentId}`,
text: rawCommentHtml,
replies: [],
});
});
const roots = [];
const stack = [];
comments.forEach((comment) => {
while (stack.length && stack[stack.length - 1].level >= comment.level) {
stack.pop();
}
if (stack.length) {
stack[stack.length - 1].replies.push(comment);
} else {
roots.push(comment);
}
stack.push(comment);
});
return roots;
};
// Construct DOM nodes directly without innerHTML
const renderComment = (comment, truncatedList) => {
const element = document.createElement("div");
element.className = "hn-sidebar-comment";
element.dataset.commentId = comment.id;
if (truncatedList.includes(comment.id)) {
element.classList.add("hidden-comment");
}
// Header (User + Reply Count)
const userDiv = document.createElement("div");
userDiv.className = "hn-sidebar-user";
const userLink = document.createElement("a");
userLink.className = "hn-sidebar-user-link";
userLink.href = comment.userUrl;
userLink.target = "_blank";
userLink.rel = "noopener noreferrer";
userLink.textContent = comment.user;
userDiv.appendChild(userLink);
const count = comment.replies.length;
if (count > 0) {
const countSpan = document.createElement("span");
countSpan.className = "hn-sidebar-count";
countSpan.textContent = ` [${count}]`;
userDiv.appendChild(countSpan);
}
// Body Text
const textDiv = document.createElement("div");
textDiv.className = "hn-sidebar-text";
textDiv.appendChild(sanitizeToFragment(comment.text));
// Replies Container
const repliesContainer = document.createElement("div");
repliesContainer.className = "hn-sidebar-replies";
if (count > 0) {
const repliesFragment = document.createDocumentFragment();
comment.replies.forEach((reply) => {
repliesFragment.appendChild(renderComment(reply, truncatedList));
});
repliesContainer.appendChild(repliesFragment);
}
element.appendChild(userDiv);
element.appendChild(textDiv);
element.appendChild(repliesContainer);
return element;
};
// --- Sidebar Actions ---
const openSidebar = async (itemId, triggerLink) => {
sidebar.style.display = "block";
markItemAsVisited(itemId);
if (activeSidebarLink && activeSidebarLink !== triggerLink) {
activeSidebarLink.classList.remove("active-open");
activeSidebarLink.classList.add("active-closed");
}
activeSidebarLink = triggerLink;
activeSidebarLink.classList.remove("active-closed");
activeSidebarLink.classList.add("active-open");
createTruncateButton(triggerLink.parentNode);
if (lastSidebarItem !== itemId) {
sidebarContent.replaceChildren();
const loadingDiv = document.createElement("div");
loadingDiv.className = "hn-sidebar-loading";
loadingDiv.textContent = "Loading...";
sidebarContent.appendChild(loadingDiv);
sidebar.scrollTop = 0;
}
try {
const html = await fetchItemHtml(itemId);
const comments = parseComments(html);
sidebarContent.replaceChildren();
if (!comments.length) {
const emptyDiv = document.createElement("div");
emptyDiv.className = "hn-sidebar-loading";
emptyDiv.textContent = "No comments yet.";
sidebarContent.appendChild(emptyDiv);
} else {
const fragment = document.createDocumentFragment();
const truncatedList = getTruncatedComments();
comments.forEach((comment) => {
fragment.appendChild(renderComment(comment, truncatedList));
});
sidebarContent.appendChild(fragment);
}
updateCounts();
lastSidebarItem = itemId;
} catch {
sidebarContent.replaceChildren();
const errorDiv = document.createElement("div");
errorDiv.className = "hn-sidebar-error";
errorDiv.textContent = "Error loading comments.";
sidebarContent.appendChild(errorDiv);
}
};
// --- DOM Injection ---
const addSidebarLinks = () => {
const visitedItems = getVisitedItems();
document.querySelectorAll(".athing").forEach((row) => {
const itemId = row.id;
if (!itemId) return;
const subtext = row.nextElementSibling?.querySelector(".subtext");
if (!subtext || subtext.querySelector(".hn-sidebar-link")) return;
const commentLink = [...subtext.querySelectorAll("a")].find((a) =>
/comment|discuss/i.test(a.textContent)
);
if (!commentLink) return;
const hasComments = /\d+\s+comment/i.test(commentLink.textContent);
if (!hasComments) return;
const triggerLink = document.createElement("a");
triggerLink.href = "#";
triggerLink.textContent = "--";
triggerLink.className = "hn-sidebar-link";
triggerLink.title = "Preview comments in sidebar";
if (visitedItems.includes(itemId)) {
triggerLink.classList.add("active-closed");
}
triggerLink.addEventListener("click", (e) => {
e.preventDefault();
if (sidebar.style.display !== "none" && activeSidebarLink === triggerLink) {
closeSidebar();
return;
}
openSidebar(itemId, triggerLink);
});
const fragment = document.createDocumentFragment();
fragment.appendChild(document.createTextNode(" "));
fragment.appendChild(triggerLink);
subtext.appendChild(fragment);
});
};
addSidebarLinks();
})();