HackerBrain - sidebar for efficient information consumption

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

Você precisará instalar uma extensão como Tampermonkey, Greasemonkey ou Violentmonkey para instalar este script.

Você precisará instalar uma extensão como Tampermonkey para instalar este script.

Você precisará instalar uma extensão como Tampermonkey ou Violentmonkey para instalar este script.

Você precisará instalar uma extensão como Tampermonkey ou Userscripts para instalar este script.

Você precisará instalar uma extensão como o Tampermonkey para instalar este script.

Você precisará instalar um gerenciador de scripts de usuário para instalar este script.

(Eu já tenho um gerenciador de scripts de usuário, me deixe instalá-lo!)

Você precisará instalar uma extensão como o Stylus para instalar este estilo.

Você precisará instalar uma extensão como o Stylus para instalar este estilo.

Você precisará instalar uma extensão como o Stylus para instalar este estilo.

Você precisará instalar um gerenciador de estilos de usuário para instalar este estilo.

Você precisará instalar um gerenciador de estilos de usuário para instalar este estilo.

Você precisará instalar um gerenciador de estilos de usuário para instalar este estilo.

(Eu já possuo um gerenciador de estilos de usuário, me deixar fazer a instalação!)

// ==UserScript==
// @name         HackerBrain - sidebar for efficient information consumption
// @namespace    https://news.ycombinator.com/
// @version      3.19
// @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: 5000,
        TRUNCATE_TTL_MS: 90 * 24 * 60 * 60 * 1000, // 90 days in milliseconds
    };

    let activeSidebarLink = null;
    let activeTruncateBtn = null;
    let lastSidebarItem = null;
    let currentThreadRoots = []; // Local in-memory store for active item's root comments
    let currentFetchController = null; // AbortController for active fetch requests

    // --- 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 {
                const raw = JSON.parse(localStorage.getItem(CONFIG.STORAGE_KEY_TRUNCATED)) || [];
                const now = Date.now();

                // Migrate legacy string/number arrays & prune expired timestamps (>90 days)
                cachedTruncated = raw
                    .map((item) => (typeof item === "object" && item !== null ? item : { id: String(item), ts: now }))
                    .filter((item) => now - item.ts < CONFIG.TRUNCATE_TTL_MS);
            } catch {
                cachedTruncated = [];
            }
        }
        return cachedTruncated;
    };

    const getTruncatedCommentIds = () => {
        return getTruncatedComments().map((item) => item.id);
    };

    const markCommentAsTruncated = (commentId) => {
        let truncated = getTruncatedComments();
        const now = Date.now();
        const strId = String(commentId);

        truncated = truncated.filter((item) => now - item.ts < CONFIG.TRUNCATE_TTL_MS);

        if (!truncated.some((item) => item.id === strId)) {
            truncated.push({ id: strId, ts: now });
            while (truncated.length > CONFIG.MAX_STORED_ITEMS) {
                truncated.shift();
            }
            cachedTruncated = truncated;
            try {
                localStorage.setItem(CONFIG.STORAGE_KEY_TRUNCATED, JSON.stringify(truncated));
            } catch (e) {
                console.warn("HN Sidebar: Failed to save truncated comment state", e);
            }
        }
    };

    const removeTruncatedComments = (commentIds) => {
        const strIds = commentIds.map((id) => String(id));
        let truncated = getTruncatedComments();
        truncated = truncated.filter((item) => !strIds.includes(item.id));
        cachedTruncated = truncated;
        try {
            localStorage.setItem(CONFIG.STORAGE_KEY_TRUNCATED, JSON.stringify(truncated));
        } catch (e) {
            console.warn("HN Sidebar: Failed to remove truncated comments", 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-header {
            display: flex;
            justify-content: space-between;
            align-items: center;
            margin-top: 16px;
            padding-bottom: 12px;
            cursor: default;
        }

        #hn-sidebar-reset {
            display: inline-flex;
            align-items: center;
            justify-content: center;
            width: 22px;
            height: 22px;
            border-radius: 50%;
            background: transparent;
            border: 1px solid #828282;
            color: #828282;
            text-decoration: none;
            font-size: 12px;
            line-height: 1;
            cursor: pointer;
            user-select: none;
            transition: all 0.15s ease-in-out;
        }

        #hn-sidebar-reset:hover {
            background: #e0e0d8;
            color: #222222;
            border-color: #222222;
        }

        #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: 7.5pt;
            opacity: 0.85;
            margin-bottom: 4px;
            cursor: default;
        }

        .hn-sidebar-user-link {
            color: inherit;
            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: inherit;
            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;
            opacity: 1;
        }

        .hn-sidebar-link.active-closed {
            background: #606060;
            color: #ffffff !important;
            opacity: 0.4;
        }

        .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;
            opacity: 1;
            pointer-events: auto;
        }

        /* Transparent background when count is 0 */
        .hn-sidebar-truncate-btn.zero-count {
            background: transparent !important;
            color: #828282 !important;
            opacity: 1;
        }

        /* Grayed out & unclickable when dimmed (if not 0) */
        .hn-sidebar-truncate-btn.dimmed {
            background: #828282 !important;
            color: #ffffff !important;
            opacity: 0.5;
            cursor: default;
            pointer-events: none;
        }

        .hn-sidebar-truncate-btn.dimmed.zero-count {
            background: transparent !important;
            color: #828282 !important;
            opacity: 0.5;
        }

        .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-reset {
                border-color: #555555;
                color: #aaaaaa;
            }

            #hn-sidebar-reset:hover {
                background: #333333;
                color: #ffffff;
                border-color: #888888;
            }

            .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-truncate-btn.zero-count {
                background: transparent !important;
                color: #828282 !important;
            }

            .hn-sidebar-truncate-btn.dimmed {
                background: #555555 !important;
                color: #aaaaaa !important;
                opacity: 0.4;
            }

            .hn-sidebar-truncate-btn.dimmed.zero-count {
                background: transparent !important;
                color: #555555 !important;
                opacity: 0.4;
            }

            .hn-sidebar-link.active-closed {
                background: #2c2c2c;
                color: #666666 !important;
                opacity: 0.4;
            }
        }
    `;
    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 headerContainer = document.createElement("div");
    headerContainer.id = "hn-sidebar-header";

    const resetBtn = document.createElement("a");
    resetBtn.id = "hn-sidebar-reset";
    resetBtn.href = "#";
    resetBtn.textContent = "↺";
    resetBtn.title = "Restore all hidden comments for this thread";

    const closeBtn = document.createElement("a");
    closeBtn.id = "hn-sidebar-x";
    closeBtn.href = "#";
    closeBtn.textContent = "x";

    headerContainer.appendChild(resetBtn);
    headerContainer.appendChild(closeBtn);

    sidebar.appendChild(resizeHandle);
    sidebar.appendChild(sidebarContent);
    sidebar.appendChild(headerContainer);
    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));

        document.removeEventListener("mousemove", onMouseMove);
        document.removeEventListener("mouseup", onMouseUp);
    };

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

        document.addEventListener("mousemove", onMouseMove);
        document.addEventListener("mouseup", onMouseUp);
    });

    // --- Counter & Reset Handlers ---
    const updateCounts = () => {
        if (!sidebarContent) return;
        const totalTopComments = currentThreadRoots.length;
        const visibleTopComments = currentThreadRoots.filter((comment) => !comment.truncated).length;

        if (activeSidebarLink) {
            activeSidebarLink.textContent = totalTopComments;
        }
        if (activeTruncateBtn) {
            activeTruncateBtn.textContent = visibleTopComments;
            if (visibleTopComments === 0) {
                activeTruncateBtn.classList.add("zero-count");
            } else {
                activeTruncateBtn.classList.remove("zero-count");
            }
        }
    };

    const resetTruncatedComments = () => {
        if (!currentThreadRoots.length) return;

        const truncatedIdsToRemove = [];
        currentThreadRoots.forEach((comment) => {
            if (comment.truncated) {
                comment.truncated = false;
                truncatedIdsToRemove.push(comment.id);
            }
        });

        if (truncatedIdsToRemove.length > 0) {
            removeTruncatedComments(truncatedIdsToRemove);
            sidebarContent.querySelectorAll(".hn-sidebar-comment.hidden-comment").forEach((node) => {
                node.classList.remove("hidden-comment");
            });
            updateCounts();
        }
    };

    resetBtn.addEventListener("click", (e) => {
        e.preventDefault();
        resetTruncatedComments();
    });

    const closeSidebar = () => {
        sidebar.style.display = "none";

        if (activeTruncateBtn) {
            activeTruncateBtn.classList.add("dimmed");
        }

        if (activeSidebarLink) {
            activeSidebarLink.classList.remove("active-open");
            activeSidebarLink.classList.add("active-closed");
        }
    };

    closeBtn.addEventListener("click", (e) => {
        e.preventDefault();
        closeSidebar();
    });

    sidebar.addEventListener("click", (e) => {
        if (e.target === sidebar || e.target === sidebarContent) {
            closeSidebar();
        }
    });

    sidebar.addEventListener("contextmenu", (e) => {
        if (e.target === sidebar || e.target === sidebarContent) {
            e.preventDefault();
            closeSidebar();
        }
    });

    const truncateTopComment = () => {
        // Find first untruncated comment from memory state
        const targetComment = currentThreadRoots.find((comment) => !comment.truncated);
        if (targetComment) {
            // Update flag locally
            targetComment.truncated = true;
            markCommentAsTruncated(targetComment.id);

            // Reflect state change in DOM
            const element = sidebarContent.querySelector(`:scope > .hn-sidebar-comment[data-comment-id="${targetComment.id}"]`);
            if (element) {
                element.classList.add("hidden-comment");
            }

            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 setupTruncateButton = (parentElement) => {
        let btn = parentElement.querySelector(".hn-sidebar-truncate-btn");
        if (!btn) {
            btn = document.createElement("a");
            btn.href = "#";
            btn.textContent = "...";
            btn.className = "hn-sidebar-truncate-btn dimmed";
            btn.title = "Hide top visible comment (Spacebar)";

            btn.addEventListener("click", (e) => {
                e.preventDefault();
                truncateTopComment();
            });

            parentElement.appendChild(btn);
        }

        activeTruncateBtn = btn;
    };

    // --- Network & Parsing with AbortController ---
    const fetchItemHtml = (id) => {
        if (currentFetchController) {
            currentFetchController.abort();
        }
        currentFetchController = new AbortController();

        return new Promise((resolve, reject) => {
            const req = 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,
                onabort: () => reject(new DOMException("Aborted", "AbortError")),
            });

            currentFetchController.signal.addEventListener("abort", () => {
                req.abort();
            });
        });
    };

    const parseComments = (html) => {
        const doc = new DOMParser().parseFromString(html, "text/html");
        const comments = [];
        const truncatedIds = getTruncatedCommentIds();

        doc.querySelectorAll(".athing.comtr").forEach((row) => {
            const indentImg = row.querySelector(".ind img");
            const widthAttr = indentImg ? parseInt(indentImg.getAttribute("width") || "0", 10) : 0;
            const level = Math.floor(widthAttr / 40);
            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 {
                // Attach explicit truncation status flag on root-level items
                comment.truncated = truncatedIds.includes(comment.id);
                roots.push(comment);
            }

            stack.push(comment);
        });

        return roots;
    };

    const renderComment = (comment) => {
        const element = document.createElement("div");
        element.className = "hn-sidebar-comment";
        element.dataset.commentId = comment.id;

        // Render visibility based directly on item's truncated flag state
        if (comment.truncated) {
            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));
            });
            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);

        // Dim button of the previously active item if changing items
        if (activeTruncateBtn && activeSidebarLink !== triggerLink) {
            activeTruncateBtn.classList.add("dimmed");
        }

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

        // Setup button for this item's container & remove dimmed state for active item
        setupTruncateButton(triggerLink.parentNode);
        activeTruncateBtn.classList.remove("dimmed");

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

            if (lastSidebarItem !== itemId) return;

            // Store parsed top-level comments locally in memory
            currentThreadRoots = parseComments(html);

            saveThreadData(itemId, currentThreadRoots);

            sidebarContent.replaceChildren();

            if (!currentThreadRoots.length) {
                const emptyDiv = document.createElement("div");
                emptyDiv.className = "hn-sidebar-loading";
                emptyDiv.textContent = "No comments yet.";
                sidebarContent.appendChild(emptyDiv);
            } else {
                const fragment = document.createDocumentFragment();

                currentThreadRoots.forEach((comment) => {
                    fragment.appendChild(renderComment(comment));
                });

                sidebarContent.appendChild(fragment);
            }

            updateCounts();
        } catch (err) {
            if (err.name === "AbortError") return; // Ignore aborted network requests
            if (lastSidebarItem !== itemId) return;

            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 truncatedIds = getTruncatedCommentIds();
        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";

            const fragment = document.createDocumentFragment();
            fragment.appendChild(document.createTextNode(" "));
            fragment.appendChild(triggerLink);

            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) =>
                        truncatedIds.includes(String(id))
                    ).length;
                    const untruncatedCount = Math.max(0, itemData.totalTop - truncatedCount);

                    triggerLink.textContent = itemData.totalTop;

                    // Create dimmed truncate button on page load for previously fetched items
                    const truncateBtn = document.createElement("a");
                    truncateBtn.href = "#";
                    truncateBtn.textContent = untruncatedCount;
                    truncateBtn.className = "hn-sidebar-truncate-btn dimmed";
                    if (untruncatedCount === 0) {
                        truncateBtn.classList.add("zero-count");
                    }
                    truncateBtn.title = "Hide top visible comment (Spacebar)";

                    truncateBtn.addEventListener("click", (e) => {
                        e.preventDefault();
                        truncateTopComment();
                    });

                    fragment.appendChild(truncateBtn);
                }
            }

            triggerLink.addEventListener("click", (e) => {
                e.preventDefault();
                openSidebar(itemId, triggerLink);
            });

            subtext.appendChild(fragment);
        });
    };

    addSidebarLinks();
})();