Greasy Fork is available in English.

8chan Collapsible Thread Chains (Toggle with Auto-Restore)

Make quote links collapsible with indented hierarchy, restore posts when toggled off

Fra 20.04.2025. Se den seneste versjonen.

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Userscripts to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

// ==UserScript==
// @name         8chan Collapsible Thread Chains (Toggle with Auto-Restore)
// @version      2.0.3
// @description  Make quote links collapsible with indented hierarchy, restore posts when toggled off
// @match        https://8chan.moe/*/res/*
// @match        https://8chan.se/*/res/*
// @grant        GM_addStyle
// @grant        GM.addStyle
// @license MIT
// @namespace https://greatest.deepsurf.us/users/1459581
// ==/UserScript==

(function() {
    'use strict';

    GM_addStyle(`
        .collapsible-container {
            margin-left: 20px;
            padding-left: 5px;
            margin-top: 8px;
            border-left: 1px solid #474b53;
            border-top: 1px solid #474b53;
        }
        .post-content.collapsed {
            display: none;
        }
        .altBacklinks {
            display: none !important;
        }
        .postCell.post-content {
            border: none !important;
        }
        .innerPost {
            width: auto;
            max-width: none !important;
        }
        .moved-post {
            position: relative;
            opacity: 0.9;
        }
        .linkQuote.toggled, .panelBacklinks a.toggled {
            color: #9a5;
        }
        .post-placeholder {
            display: none;
            padding: 5px;
            background: rgba(50, 50, 50, 0.3);
            border: 1px dashed #474b53;
            font-style: italic;
            color: #8c8c8c;
            text-align: center;
            margin: 5px 0;
        }
        .placeholder-visible {
            display: block;
        }
    `);

    const movedPosts = new Map();
    const linkContainers = new Map();
    const originalPosts = new Map();

    document.body.addEventListener('click', function(event) {
        const target = event.target;

        if (target.classList.contains('linkQuote') && event.ctrlKey) {
            const postId = target.href.match(/#q?(\d+)/)?.[1];
            if (postId && typeof qr !== 'undefined' && qr.showQr) {
                qr.showQr(postId);
                event.preventDefault();
                return;
            }
        }

        // Handle restore post link clicks
        if (target.classList.contains('restore-post-link')) {
            event.preventDefault();
            const postId = target.dataset.postId;
            if (postId && movedPosts.has(postId)) {
                restorePost(postId);
            }
            return;
        }

        // Handle backlink panel direct restoration
        if ((target.parentNode && target.parentNode.classList.contains('panelBacklinks')) && !event.ctrlKey) {
            const link = target.closest('a');
            if (!link) return;

            const rawHash = link.hash.includes('?') ? link.hash.split('?')[0] : link.hash;
            const targetId = rawHash.substring(1).replace(/^q/, '');

            if (movedPosts.has(targetId)) {
                event.preventDefault();
                restorePost(targetId);
                return;
            }
        }

        // Modified condition: Only process .panelBacklinks
        if ((target.parentNode && target.parentNode.classList.contains('panelBacklinks'))) {
            event.preventDefault();

            const link = target.closest('a');
            if (!link) return;

            const rawHash = link.hash.includes('?') ? link.hash.split('?')[0] : link.hash;
            const targetId = rawHash.substring(1).replace(/^q/, '');

            if (linkContainers.has(link)) {
                const container = linkContainers.get(link);
                const content = container.querySelector('.post-content');

                if (content) {
                    const wasCollapsed = content.classList.contains('collapsed');
                    content.classList.toggle('collapsed');
                    link.classList.toggle('toggled');

                    if (!wasCollapsed && movedPosts.has(targetId)) {
                        const postData = movedPosts.get(targetId);
                        postData.links.delete(link);
                        if (postData.links.size === 0) {
                            restorePost(targetId);
                        }
                    }
                }
                return;
            }

            if (!originalPosts.has(targetId)) {
                let targetPost = document.getElementById(targetId);
                if (!targetPost) return;
                originalPosts.set(targetId, targetPost);
            }

            let postToUse;

            if (movedPosts.has(targetId)) {
                postToUse = movedPosts.get(targetId).element;
            } else {
                postToUse = document.getElementById(targetId) || originalPosts.get(targetId);
                if (!postToUse) return;
            }

            const level = link.closest('.collapsible-container')?.dataset.level || 0;
            const container = document.createElement('div');
            container.className = 'collapsible-container';
            container.dataset.level = parseInt(level) + 1;

            movePostToContainer(targetId, postToUse, container, link);

            const postContainer = link.closest('.innerPost');
            if (postContainer) {
                postContainer.appendChild(container);
            } else {
                link.parentNode.insertBefore(container, link.nextSibling);
            }

            linkContainers.set(link, container);
            link.classList.add('toggled');
        }
    });

    function movePostToContainer(postId, postToUse, container, link) {
        if (movedPosts.has(postId)) {
            const postData = movedPosts.get(postId);
            const lightClone = postData.element.cloneNode(true);
            lightClone.classList.add('post-content', 'moved-post');
            lightClone.setAttribute('data-original-id', postId);
            container.appendChild(lightClone);
            postData.links.add(link);
            return;
        }

        if (!document.getElementById(postId) && originalPosts.has(postId)) {
            const originalPost = originalPosts.get(postId);
            const clone = originalPost.cloneNode(true);
            clone.classList.add('post-content', 'moved-post');
            clone.setAttribute('data-original-id', postId);
            container.appendChild(clone);
            const placeholder = document.createElement('div');
            placeholder.className = 'post-placeholder';
            movedPosts.set(postId, {
                element: clone,
                placeholder: placeholder,
                links: new Set([link])
            });
            return;
        }

        const placeholder = document.createElement('div');
        placeholder.className = 'post-placeholder placeholder-visible';
        placeholder.innerHTML = `Post moved <a href="#" class="restore-post-link" data-post-id="${postId}">Restore</a>`;
        postToUse.parentNode.insertBefore(placeholder, postToUse);
        postToUse.classList.add('post-content', 'moved-post');
        postToUse.setAttribute('data-original-id', postId);
        container.appendChild(postToUse);
        movedPosts.set(postId, {
            element: postToUse,
            placeholder: placeholder,
            links: new Set([link])
        });
    }

    function restorePost(postId) {
        if (!movedPosts.has(postId)) return;

        const {element, placeholder, links} = movedPosts.get(postId);

        links.forEach(link => {
            if (linkContainers.has(link)) {
                const container = linkContainers.get(link);
                container.remove();
                linkContainers.delete(link);
                link.classList.remove('toggled');
            }
        });

        document.querySelectorAll(`.moved-post[data-original-id="${postId}"]`).forEach(instance => {
            if (instance !== element) {
                instance.remove();
            }
        });

        if (placeholder.parentNode) {
            placeholder.parentNode.insertBefore(element, placeholder);
            placeholder.remove();
        }

        element.classList.remove('post-content', 'moved-post');
        element.removeAttribute('data-original-id'); // Remove the data-original-id attribute

        movedPosts.delete(postId);

        if (!originalPosts.has(postId)) {
            originalPosts.set(postId, element);
        }
    }

    function cleanupBacklinks() {
        document.querySelectorAll('span.panelBacklinks a').forEach(link => {
            const href = link.getAttribute('href');
            if (href?.includes('#')) {
                link.href = `#${href.split('#')[1].split('?')[0]}`;
            }
        });
    }

    const observer = new MutationObserver((mutations) => {
        let shouldProcess = false;

        for (const mutation of mutations) {
            if (mutation.addedNodes.length) {
                for (const node of mutation.addedNodes) {
                    if (node.nodeType === 1 &&
                       (node.classList?.contains('post') ||
                        node.querySelector?.('.post, .linkQuote, .panelBacklinks'))) {
                        shouldProcess = true;
                        break;
                    }
                }
                if (shouldProcess) break;
            }
        }

        if (shouldProcess) {
            cleanupBacklinks();
        }
    });

    const threadContainer = document.querySelector('.thread');
    if (threadContainer) {
        observer.observe(threadContainer, { childList: true, subtree: true });
    } else {
        observer.observe(document.body, { childList: true, subtree: false });
    }

    cleanupBacklinks();
})();