Blizzard Forums Tweaks

Combined tweaks for Blizzard forums: remove main outlet padding, hide pinned topics, enable clickable links (even broken ones), hide more topics, hide signup card and hide footer — with per-feature toggles.

// ==UserScript==
// @name         Blizzard Forums Tweaks
// @namespace    https://greasyfork.org/users/877912
// @version      0.1
// @description  Combined tweaks for Blizzard forums: remove main outlet padding, hide pinned topics, enable clickable links (even broken ones), hide more topics, hide signup card and hide footer — with per-feature toggles.
// @author       NWP
// @license      MIT
// @match        https://*.forums.blizzard.com/*
// @grant        GM_registerMenuCommand
// @grant        GM_getValue
// @grant        GM_setValue
// ==/UserScript==

(function () {
    'use strict';

    const logError = (label, err) => {
        console.error(`[Blizzard Forums Tweaks] Error in ${label}:`, err);
    };

    const features = [
        {
            key: 'removePadding',
            label: 'Remove Main Outlet Padding',
            default: true,
            apply: () => {
                try {
                    const main = document.getElementById('main-outlet');
                    if (main) main.style.paddingTop = '0';
                } catch (err) {
                    logError('removePadding.apply', err);
                }
            },
            initObserver: () => {
                try {
                    const fn = () => features.find(f => f.key === 'removePadding').apply();
                    fn();
                    new MutationObserver(fn)
                        .observe(document.body, { childList: true, subtree: true });
                } catch (err) {
                    logError('removePadding.initObserver', err);
                }
            }
        },
        {
            key: 'hidePinned',
            label: 'Hide Pinned Topics',
            default: true,
            apply: () => {
                try {
                    document.querySelectorAll('tr.topic-list-item.pinned')
                        .forEach(el => el.style.display = 'none');
                } catch (err) {
                    logError('hidePinned.apply', err);
                }
            },
            initObserver: () => {
                try {
                    const fn = () => features.find(f => f.key === 'hidePinned').apply();
                    fn();
                    new MutationObserver(fn)
                        .observe(document.body, { childList: true, subtree: true });
                } catch (err) {
                    logError('hidePinned.initObserver', err);
                }
            }
        },
        {
            key: 'clickableLinks',
            label: 'Clickable Links',
            default: true,
            apply: () => {
                try {
                    if (!document.getElementById('gm-clickable-links-style')) {
                        const style = document.createElement('style');
                        style.id = 'gm-clickable-links-style';
                        style.textContent = `
                            code a.generated-link,
                            a.generated-link, a.generated-link:visited {
                                color: lightgreen !important;
                                text-decoration: underline;
                                background-color: transparent !important;
                                font-size: 1.25em !important;
                            }
                            a.generated-link.visited-manual {
                                color: orange !important;
                            }
                            .cooked a:not(.generated-link) {
                                color: lightgreen !important;
                                text-decoration: underline;
                            }
                            .cooked a:not(.generated-link).visited-manual {
                                color: orange !important;
                            }
                            code {
                                background-color: transparent !important;
                                border: none !important;
                                box-shadow: none !important;
                            }
                        `;
                        document.head.appendChild(style);
                    }
                } catch (err) {
                    logError('clickableLinks.apply', err);
                }
            },
            initObserver: () => {
                try {
                    const feat = features.find(f => f.key === 'clickableLinks');
                    feat.apply();

                    const urlRegex = /\b((?:https?:\/\/|ftp:\/\/|www\.|htps:\/\/)[a-z0-9\-]+(?:\.[a-z0-9\-]+)+(?:\/[^\s<>"'`()\[\]]*[^.,:;"'\s<>()\[\]])?)/gi;

                    const isInsideLink = node => {
                        while (node) {
                            if (node.nodeName === 'A') return true;
                            node = node.parentNode;
                        }
                        return false;
                    };

                    const markAsVisited = link => link.classList.add('visited-manual');

                    const attachClickTracking = link => {
                        link.addEventListener('click', e => {
                            if (e.button === 0 || e.button === 1) markAsVisited(link);
                        });
                        link.addEventListener('auxclick', e => {
                            if (e.button === 1) markAsVisited(link);
                        });
                    };

                    const convertTextNode = node => {
                        if (node.nodeType !== Node.TEXT_NODE || isInsideLink(node)) return;

                        const cleanedText = node.textContent.replace(/\bhtps:\/\//gi, 'https://');
                        if (!cleanedText.match(urlRegex)) return;

                        const parent = node.parentNode;
                        const fragment = document.createDocumentFragment();
                        let lastIndex = 0;

                        [...cleanedText.matchAll(urlRegex)].forEach(match => {
                            const url = match[0];
                            const index = match.index;

                            fragment.appendChild(document.createTextNode(cleanedText.slice(lastIndex, index)));

                            const a = document.createElement('a');
                            a.href = url.match(/^https?:\/\//i) || url.match(/^ftp:\/\//i) ? url : 'https://' + url;
                            a.textContent = url;
                            a.target = '_blank';
                            a.rel = 'noopener noreferrer';
                            a.className = 'generated-link';
                            attachClickTracking(a);
                            fragment.appendChild(a);

                            lastIndex = index + url.length;
                        });

                        fragment.appendChild(document.createTextNode(cleanedText.slice(lastIndex)));
                        parent.replaceChild(fragment, node);
                    };

                    const processCooked = (root = document.body) => {
                        root.querySelectorAll('.cooked, .cooked *').forEach(el => {
                            Array.from(el.childNodes).forEach(convertTextNode);
                        });
                    };

                    const scanCodeBlocks = (root = document.body) => {
                        root.querySelectorAll('code').forEach(el => {
                            Array.from(el.childNodes).forEach(convertTextNode);
                        });
                    };

                    const enhanceExistingLinks = (root = document.body) => {
                        root.querySelectorAll('.cooked a:not(.generated-link)').forEach(link => {
                            if (!link.hasAttribute('data-click-tracked')) {
                                attachClickTracking(link);
                                link.setAttribute('data-click-tracked', 'true');
                            }
                        });
                    };

                    scanCodeBlocks();
                    processCooked();
                    enhanceExistingLinks();

                    new MutationObserver(mutations => {
                        for (const m of mutations) {
                            for (const node of m.addedNodes) {
                                if (node.nodeType === Node.ELEMENT_NODE) {
                                    scanCodeBlocks(node);
                                    processCooked(node);
                                    enhanceExistingLinks(node);
                                }
                            }
                        }
                    }).observe(document.body, { childList: true, subtree: true });
                } catch (err) {
                    logError('clickableLinks.initObserver', err);
                }
            }
        },
        {
            key: 'hideSignupCard',
            label: 'Hide Signup Card',
            default: true,
            apply: () => {
                try {
                    const card = document.querySelector('.signup-cta.alert.alert-info');
                    if (card) {
                        const parent = card.closest('.ember-view');
                        if (parent) parent.style.display = 'none';
                    }
                } catch (err) {
                    logError('hideSignupCard.apply', err);
                }
            },
            initObserver: () => {
                try {
                    const fn = () => features.find(f => f.key === 'hideSignupCard').apply();
                    fn();
                    new MutationObserver(fn)
                        .observe(document.body, { childList: true, subtree: true });
                } catch (err) {
                    logError('hideSignupCard.initObserver', err);
                }
            }
        },
        {
            key: 'hideMoreTopics',
            label: 'Hide More Topics Section',
            default: true,
            apply: () => {
                try {
                    const m = document.querySelector('.more-topics__container');
                    if (m) m.style.display = 'none';
                } catch (err) {
                    logError('hideMoreTopics.apply', err);
                }
            },
            initObserver: () => {
                try {
                    const fn = () => features.find(f => f.key === 'hideMoreTopics').apply();
                    fn();
                    new MutationObserver(fn)
                        .observe(document.body, { childList: true, subtree: true });
                } catch (err) {
                    logError('hideMoreTopics.initObserver', err);
                }
            }
        },
        {
            key: 'hideFooter',
            label: 'Hide Footer',
            default: true,
            apply: () => {
                try {
                    const f = document.querySelector('div[id^="ember"].custom-footer-content');
                    if (f) f.style.display = 'none';
                } catch (err) {
                    logError('hideFooter.apply', err);
                }
            },
            initObserver: () => {
                try {
                    const fn = () => features.find(f => f.key === 'hideFooter').apply();
                    fn();
                    new MutationObserver(fn)
                        .observe(document.body, { childList: true, subtree: true });
                } catch (err) {
                    logError('hideFooter.initObserver', err);
                }
            }
        }
    ];

    const orderedKeys = [
        'removePadding',
        'hidePinned',
        'clickableLinks',
        'hideSignupCard',
        'hideMoreTopics',
        'hideFooter'
    ];

    for (const key of orderedKeys) {
        try {
            const feat = features.find(f => f.key === key);
            const enabled = GM_getValue(feat.key, feat.default);

            GM_registerMenuCommand(
                `${enabled ? 'Disable' : 'Enable'} ${feat.label}`,
                () => {
                    GM_setValue(feat.key, !enabled);
                    location.reload();
                }
            );

            if (enabled && feat.initObserver) {
                feat.initObserver();
            }
        } catch (err) {
            logError(`main init for ${key}`, err);
        }
    }
})();