HackerBrain Sidebar

Read Hacker News top-comments in right-sidebar with DOM node construction (no innerHTML).

Vous devrez installer une extension telle que Tampermonkey, Greasemonkey ou Violentmonkey pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey ou Violentmonkey pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey ou Userscripts pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey pour installer ce script.

Vous devrez installer une extension de gestionnaire de script utilisateur pour installer ce script.

(J'ai déjà un gestionnaire de scripts utilisateur, laissez-moi l'installer !)

Vous devrez installer une extension telle que Stylus pour installer ce style.

Vous devrez installer une extension telle que Stylus pour installer ce style.

Vous devrez installer une extension telle que Stylus pour installer ce style.

Vous devrez installer une extension du gestionnaire de style pour utilisateur pour installer ce style.

Vous devrez installer une extension du gestionnaire de style pour utilisateur pour installer ce style.

Vous devrez installer une extension du gestionnaire de style pour utilisateur pour installer ce style.

(J'ai déjà un gestionnaire de style utilisateur, laissez-moi l'installer!)

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