Created
January 17, 2026 14:20
-
-
Save swhitt/0fcf80442f2c0b55c01a90fa3a512df6 to your computer and use it in GitHub Desktop.
HackerWeb (https://hackerweb.app) nested comment collapse
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // ==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