Greasy Fork is available in English.

HackerBrain - sidebar for efficient information consumption

Read Hacker News top-level comments in right-sidebar with truncation support and persistence.

Voor het installeren van scripts heb je een extensie nodig, zoals Tampermonkey, Greasemonkey of Violentmonkey.

Voor het installeren van scripts heb je een extensie nodig, zoals {tampermonkey_link:Tampermonkey}.

Voor het installeren van scripts heb je een extensie nodig, zoals Tampermonkey of Violentmonkey.

Voor het installeren van scripts heb je een extensie nodig, zoals Tampermonkey of Userscripts.

Voor het installeren van scripts heb je een extensie nodig, zoals {tampermonkey_link:Tampermonkey}.

Voor het installeren van scripts heb je een gebruikersscriptbeheerder nodig.

(Ik heb al een user script manager, laat me het downloaden!)

Voor het installeren van gebruikersstijlen heb je een extensie nodig, zoals {stylus_link:Stylus}.

Voor het installeren van gebruikersstijlen heb je een extensie nodig, zoals {stylus_link:Stylus}.

Voor het installeren van gebruikersstijlen heb je een extensie nodig, zoals {stylus_link:Stylus}.

Voor het installeren van gebruikersstijlen heb je een gebruikersstijlbeheerder nodig.

Voor het installeren van gebruikersstijlen heb je een gebruikersstijlbeheerder nodig.

Voor het installeren van gebruikersstijlen heb je een gebruikersstijlbeheerder nodig.

(Ik heb al een beheerder - laat me doorgaan met de installatie!)

// ==UserScript==
// @name         HackerBrain - sidebar for efficient information consumption
// @namespace    https://news.ycombinator.com/
// @version      3.00
// @description  Read Hacker News top-level comments in right-sidebar with truncation support and persistence.
// @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",
        STORAGE_KEY_THREAD_DATA: "hn-sidebar-thread-data",
        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;
    let cachedThreadData = 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);
            }
        }
    };

    const getThreadData = () => {
        if (!cachedThreadData) {
            try {
                cachedThreadData = JSON.parse(localStorage.getItem(CONFIG.STORAGE_KEY_THREAD_DATA)) || {};
            } catch {
                cachedThreadData = {};
            }
        }
        return cachedThreadData;
    };

    const saveThreadData = (itemId, roots) => {
        const threadData = getThreadData();
        const topCommentIds = roots.map((r) => r.id);

        threadData[itemId] = {
            totalTop: roots.length,
            topCommentIds: topCommentIds,
        };

        const keys = Object.keys(threadData);
        if (keys.length > CONFIG.MAX_STORED_ITEMS) {
            delete threadData[keys[0]];
        }

        try {
            localStorage.setItem(CONFIG.STORAGE_KEY_THREAD_DATA, JSON.stringify(threadData));
        } catch (e) {
            console.warn("HN Sidebar: Failed to save thread data", 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,
        #hn-sidebar-content {
            cursor: crosshair;
        }

        #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;
            cursor: default;
        }

        #hn-sidebar-x {
            color: inherit;
            text-decoration: none;
            font-weight: bold;
            cursor: pointer;
        }

        .hn-sidebar-comment {
            margin-bottom: 8px;
            padding: 6px;
            background: #f6f6ef;
            cursor: default;
        }

        .hn-sidebar-comment .reply {
            display: none !important;
        }

        .hn-sidebar-user {
            color: #828282;
            font-size: 8pt;
            margin-bottom: 4px;
            cursor: default;
        }

        .hn-sidebar-user-link {
            color: #828282;
            text-decoration: none;
            cursor: pointer;
        }

        .hn-sidebar-text {
            color: inherit;
            line-height: 1.4;
            cursor: auto;
        }

        .hn-sidebar-text a {
            color: #0000aa;
            text-decoration: underline;
            cursor: pointer;
        }

        .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;
            cursor: default;
        }

        .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");
            }
        }
    });

    // --- Optimized Resizing Logic ---
    let isResizing = false;
    let resizeAnimationFrame = null;

    const onMouseMove = (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`;
            }
        });
    };

    const onMouseUp = () => {
        if (!isResizing) return;
        isResizing = false;
        document.body.style.userSelect = "";

        localStorage.setItem(CONFIG.STORAGE_KEY_WIDTH, parseInt(sidebar.offsetWidth, 10));

        // Cleanup listeners when dragging ends
        document.removeEventListener("mousemove", onMouseMove);
        document.removeEventListener("mouseup", onMouseUp);
    };

    resizeHandle.addEventListener("mousedown", (e) => {
        e.preventDefault();
        isResizing = true;
        document.body.style.userSelect = "none";

        // Bind listeners dynamically on interaction
        document.addEventListener("mousemove", onMouseMove);
        document.addEventListener("mouseup", onMouseUp);
    });

    // --- 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();
    });

    // Left-click to close ONLY when targeting empty sidebar background space
    sidebar.addEventListener("click", (e) => {
        if (e.target === sidebar || e.target === sidebarContent) {
            closeSidebar();
        }
    });

    // Right-click to close ONLY when targeting empty sidebar background space
    sidebar.addEventListener("contextmenu", (e) => {
        if (e.target === sidebar || e.target === sidebarContent) {
            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;
    };

    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");
        }

        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);
        }

        const textDiv = document.createElement("div");
        textDiv.className = "hn-sidebar-text";
        textDiv.appendChild(sanitizeToFragment(comment.text));

        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);

            saveThreadData(itemId, comments);

            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();
        const truncatedComments = getTruncatedComments();
        const threadData = getThreadData();

        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");

                const itemData = threadData[itemId];
                if (itemData && Array.isArray(itemData.topCommentIds)) {
                    const truncatedCount = itemData.topCommentIds.filter((id) =>
                        truncatedComments.includes(id)
                    ).length;
                    const untruncatedCount = Math.max(0, itemData.totalTop - truncatedCount);
                    triggerLink.textContent = untruncatedCount;
                }
            }

            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();
})();