Skip to content

Instantly share code, notes, and snippets.

@swhitt
Created January 17, 2026 14:20
Show Gist options
  • Select an option

  • Save swhitt/0fcf80442f2c0b55c01a90fa3a512df6 to your computer and use it in GitHub Desktop.

Select an option

Save swhitt/0fcf80442f2c0b55c01a90fa3a512df6 to your computer and use it in GitHub Desktop.
HackerWeb (https://hackerweb.app) nested comment collapse
// ==UserScript==
// @name HackerWeb Nested Comment Collapse
// @namespace https://hackerweb.app/
// @version 0.0.1
// @description Collapse buttons for nested comments with shift+click to collapse entire threads
// @author Steve Whittaker <swhitt@gmail.com>
// @match https://hackerweb.app/*
// @grant GM_addStyle
// @run-at document-idle
// @updateURL https://gist.githubusercontent.com/swhitt/0fcf80442f2c0b55c01a90fa3a512df6/raw/hackerweb-collapse.user.js
// @downloadURL https://gist.githubusercontent.com/swhitt/0fcf80442f2c0b55c01a90fa3a512df6/raw/hackerweb-collapse.user.js
// ==/UserScript==
(() => {
'use strict';
/**
* Notes:
* - HackerWeb comments are <li> nodes; replies live in a direct child <ul>.
* - For each comment <li> that has a replies <ul>, we inject a tiny toggle button before that <ul>.
* - Clicking the button toggles only that comment's replies.
* - Shift+click collapses the entire thread (all descendants) starting from the root ancestor.
* - A MutationObserver re-injects buttons when the app re-renders.
*/
const SELECTORS = {
commentLi: 'section li',
repliesUl: ':scope > ul',
childCommentLi: ':scope > li',
ourToggle: ':scope > button.hw-toggle',
originalToggle: ':scope > button.comments-toggle:not(.hw-toggle)',
anyOurToggle: 'button.hw-toggle',
};
const CSS = `
.hw-toggle.comments-toggle {
display: inline-block !important;
font-size: .75em !important;
font-weight: 600 !important;
margin: 2px 0 !important;
padding: 2px 4px !important;
white-space: nowrap !important;
}
.hw-toggle.hw-collapsed { color: #df8060 !important }
li.hw-hl { background-color: rgba(255,255,255,0.04) !important }
`;
const qs = (sel, root = document) => root.querySelector(sel);
const qsa = (sel, root = document) => root.querySelectorAll(sel);
// store booleans in data-* in a predictable way (dataset is always strings).
const setDataBool = (el, key, value) => {
el.dataset[key] = value ? 'true' : 'false';
};
const getDataBool = (el, key) => el?.dataset?.[key] === 'true';
// comment tree
const getRepliesUl = (li) => qs(SELECTORS.repliesUl, li);
const countDescendantReplies = (ul) => {
if (!ul) return 0;
let count = 0;
for (const childLi of qsa(SELECTORS.childCommentLi, ul)) {
count += 1; // the child itself
count += countDescendantReplies(getRepliesUl(childLi)); // and its descendants
}
return count;
};
const findThreadRoot = (li) => {
// walk up through parent <li> nodes until there isn't one
while (true) {
const parentLi = li.parentElement?.closest('li');
if (!parentLi) return li;
li = parentLi;
}
};
// toggles
const setCollapsed = (li, collapsed) => {
const ul = getRepliesUl(li);
const btn = qs(SELECTORS.ourToggle, li);
if (!ul || !btn) return;
ul.style.display = collapsed ? 'none' : '';
setDataBool(btn, 'collapsed', collapsed);
const count = btn.dataset.count;
btn.textContent = collapsed ? `+ ${count}` : count;
btn.classList.toggle('hw-collapsed', collapsed);
};
const collapseWholeThread = (rootLi) => {
// collapse every injected toggle in the subtree
for (const btn of qsa(SELECTORS.anyOurToggle, rootLi)) {
if (getDataBool(btn, 'collapsed')) continue;
const li = btn.closest('li');
if (li) setCollapsed(li, true);
}
};
const createToggleButton = (repliesUl) => {
if (!repliesUl?.children?.length) return null;
const count = countDescendantReplies(repliesUl);
const collapsed = getComputedStyle(repliesUl).display === 'none';
const btn = document.createElement('button');
btn.type = 'button';
btn.className = `comments-toggle hw-toggle${collapsed ? ' hw-collapsed' : ''}`;
btn.textContent = collapsed ? `+ ${count}` : String(count);
btn.dataset.count = String(count);
setDataBool(btn, 'collapsed', collapsed);
return btn;
};
const injectButtons = () => {
for (const li of qsa(SELECTORS.commentLi)) {
const repliesUl = getRepliesUl(li);
if (!repliesUl?.children?.length) continue;
// don't double-inject
if (qs(SELECTORS.ourToggle, li)) continue;
// remove HackerWeb's original toggle so there's only one control.
qs(SELECTORS.originalToggle, li)?.remove();
const btn = createToggleButton(repliesUl);
if (btn) li.insertBefore(btn, repliesUl);
}
};
const clearHighlights = () => {
for (const el of qsa('.hw-hl')) el.classList.remove('hw-hl');
};
const highlightAncestors = (li) => {
clearHighlights();
while (li) {
li.classList.add('hw-hl');
li = li.parentElement?.closest('li');
}
};
// debounce reinjection
const debounce = (fn, ms) => {
let id = 0;
return () => {
clearTimeout(id);
id = setTimeout(fn, ms);
};
};
const debouncedInject = debounce(injectButtons, 150);
// event handlers
const handleToggleClick = (event, btn) => {
event.stopPropagation();
event.preventDefault();
const li = btn.closest('li');
if (!li) return;
if (event.shiftKey) {
collapseWholeThread(findThreadRoot(li));
return;
}
setCollapsed(li, !getDataBool(btn, 'collapsed'));
};
const handleLeftGutterClick = (event, li) => {
// treat clicks very close to the left edge of the comment as a toggle
const rect = li.getBoundingClientRect();
const clickX = event.clientX - rect.left;
if (clickX > 15) return;
const btn = qs(SELECTORS.ourToggle, li);
if (!btn) return;
event.preventDefault();
setCollapsed(li, !getDataBool(btn, 'collapsed'));
};
const addStyle = (cssText) => {
if (typeof GM_addStyle === 'function') {
GM_addStyle(cssText);
return;
}
document.head.append(
Object.assign(document.createElement('style'), { textContent: cssText })
);
};
const init = () => {
addStyle(CSS);
injectButtons();
// re-run injection when it adds comment nodes
new MutationObserver((mutations) => {
if (mutations.some((m) => m.addedNodes.length > 0)) debouncedInject();
}).observe(document.body, { childList: true, subtree: true });
// ancestor chain on hover
document.addEventListener('mouseover', (event) => {
const li = event.target.closest(SELECTORS.commentLi);
if (li) highlightAncestors(li);
});
// we can use one for both toggles and left-gutter clicks
document.addEventListener('click', (event) => {
const btn = event.target.closest('button.hw-toggle');
if (btn) {
handleToggleClick(event, btn);
return;
}
const li = event.target.closest(SELECTORS.commentLi);
if (li) handleLeftGutterClick(event, li);
});
// some navigation patterns change content without a full reload
addEventListener('hashchange', debouncedInject);
};
if (document.readyState === 'complete') init();
else addEventListener('load', init, { once: true });
})();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment