NeoBoards By web crawler 0 installs Rating 0.0 (0) approved

Neopets - Neoboard Hover Thread Preview

Hover a Neoboard thread title to preview replies in a Neopets-style popup with classic layout, pagination, draggable header, no report buttons, and clickable URLs in posts.
hover neoboard neopets preview
https://www.scriptneo.com/script/neopets-neoboard-hover-thread-preview

Version selector


SHA256
de9b2a97d79bba014885249e016a7865e85c6e8e865583d9931bfce8588fcc6f
Static scan
Score: 2
Flags: uses_gm_xmlhttprequest

Source code

// ==UserScript==
// @name         Neopets - Neoboard Hover Thread Preview
// @namespace    https://scriptneo.com/
// @version      1.0.0
// @description  Hover a Neoboard thread title to preview replies in a Neopets-style popup with classic layout, pagination, draggable header, no report buttons, and clickable URLs in posts.
// @author       You
// @match        *://www.neopets.com/neoboards/boardlist.phtml*
// @match        *://neopets.com/neoboards/boardlist.phtml*
// @run-at       document-idle
// @grant        GM_xmlhttpRequest
// @grant        GM_addStyle
// @connect      neopets.com
// @connect      www.neopets.com
// @downloadURL  https://www.scriptneo.com/scripts/download.php?id=34
// @updateURL    https://www.scriptneo.com/scripts/download.php?id=34
// ==/UserScript==

(function () {
    'use strict';

    /***** CONFIG *****/
    const HOVER_DELAY_MS = 400; // delay before fetching after hover
    const MAX_POSTS      = 10;  // max posts per page in preview

    /***** STYLES – light Neoboards-style popup *****/
    GM_addStyle(`
        /* Full-screen popup background */
        .nbp-overlay {
            position: fixed;
            inset: 0;
            background: rgba(0, 0, 0, 0.45);
            z-index: 9998;
        }
        .nbp-overlay.hidden {
            display: none;
        }

        /* Popup box (thread preview) */
        .nb-hover-preview {
            position: fixed;
            z-index: 9999;
            width: 760px;
            max-width: 96vw;
            max-height: 520px;
            border: 1px solid #999999;
            background: #e6ecf5;
            box-shadow: 0 0 10px rgba(0,0,0,0.45);
            font-family: Verdana, Arial, Helvetica, sans-serif;
            font-size: 11px;
            color: #000000;
            overflow: hidden;
        }
        .nb-hover-preview.hidden {
            display: none;
        }

        /* Header bar – Neopets blue, draggable */
        .nb-hover-preview-header {
            background: #1568e6;
            color: #ffffff;
            padding: 4px 8px;
            display: flex;
            align-items: center;
            justify-content: space-between;
            border-bottom: 1px solid #0d4aa0;
            cursor: move;
        }
        .nb-hover-preview-title-text {
            flex: 1;
            font-weight: bold;
            font-size: 12px;
            margin-right: 8px;
            white-space: nowrap;
            overflow: hidden;
            text-overflow: ellipsis;
        }
        .nb-hover-preview-close {
            cursor: pointer;
            font-weight: bold;
            padding: 0 6px;
            font-size: 13px;
            border-radius: 3px;
        }
        .nb-hover-preview-close:hover {
            background: rgba(255,255,255,0.2);
        }

        /* Inner scroll area */
        .nb-hover-preview-inner {
            max-height: 480px;
            overflow-y: auto;
            padding: 6px 8px 8px 8px;
            background: #f5f7fb;
        }

        /* Thread replies container – Neoboards-like */
        .nb-hover-preview-thread {
            background: #ffffff;
            border: 1px solid #cccccc;
        }
        .nb-hover-preview-thread ul {
            list-style: none;
            margin: 0;
            padding: 0;
        }
        .nb-hover-preview-thread ul li {
            display: flex;
            align-items: flex-start;
            border-bottom: 1px solid #dddddd;
            background: #ffffff;
        }
        .nb-hover-preview-thread ul li:last-child {
            border-bottom: none;
        }

        /* Left column: author & pet info */
        .nb-hover-preview-thread .boardPostByline {
            width: 220px;
            box-sizing: border-box;
            padding: 6px;
            border-right: 1px solid #dddddd;
            background: #f2f2f7;
        }
        .nb-hover-preview-thread .postAuthor {
            margin-bottom: 6px;
        }
        .nb-hover-preview-thread .authorIcon {
            width: 50px;
            height: 50px;
            border: 1px solid #000000;
            margin-right: 4px;
            background-position: center;
            background-repeat: no-repeat;
        }
        .nb-hover-preview-thread .postAuthorName {
            margin: 0;
            font-size: 11px;
        }
        .nb-hover-preview-thread .postAuthorInfo p {
            margin: 0;
            line-height: 1.2;
        }
        .nb-hover-preview-thread .postPet {
            margin-top: 4px;
        }
        .nb-hover-preview-thread .postAuthorPetIcon img {
            border: 1px solid #000000;
        }

        /* Right column: date + message */
        .nb-hover-preview-thread .boardPost {
            flex: 1;
            box-sizing: border-box;
            padding: 6px 10px;
            background: #ffffff;
        }
        .nb-hover-preview-thread .boardPostDate {
            margin: 0 0 4px 0;
            font-size: 10px;
            color: #777777;
        }
        .nb-hover-preview-thread .boardPostMessage {
            border: 1px solid #e0d0a8;
            background: #ffffff;
            padding: 6px;
            min-height: 24px;
        }

        .nb-hover-preview-loading,
        .nb-hover-preview-error {
            padding: 4px 0;
            color: #555555;
        }

        /* Pagination */
        .nb-hover-preview-pagination {
            margin-top: 6px;
            text-align: center;
            padding-top: 4px;
            border-top: 1px solid #cccccc;
        }
        .nb-hover-preview-pagination .pageNavTitle {
            font-weight: bold;
            margin-right: 4px;
        }
        .nb-hover-preview-pagination .boardPageButton,
        .nb-hover-preview-pagination .boardPageButton-active {
            margin: 0 2px;
        }

        /* Enter thread button */
        .nb-hover-preview-enter-wrapper {
            margin-top: 6px;
            text-align: right;
        }
        .nb-hover-preview-enter-btn {
            display: inline-block;
            padding: 3px 9px;
            background: #ffcc66;
            border: 1px solid #cc9900;
            color: #000000 !important;
            text-decoration: none !important;
            font-weight: bold;
            border-radius: 3px;
        }
        .nb-hover-preview-enter-btn:hover {
            background: #ffdd88;
        }
    `);

    // Only run on boardlist pages
    if (!/boardlist\.phtml/i.test(location.pathname)) return;

    /***** STATE *****/
    const cache = Object.create(null); // pageUrl -> { loading, bodyHtml, paginationHtml }
    let hoverTimer            = null;
    let currentAnchor         = null;
    let currentPreviewPageUrl = null;
    let currentPreviewTitle   = '';

    // Overlay + popup
    const overlay = document.createElement('div');
    overlay.className = 'nbp-overlay hidden';
    document.body.appendChild(overlay);

    const previewBox = document.createElement('div');
    previewBox.className = 'nb-hover-preview hidden';
    document.body.appendChild(previewBox);

    /***** DRAGGABLE LOGIC *****/
    let isDragging = false;
    let dragOffsetX = 0;
    let dragOffsetY = 0;

    function onDragStart(e) {
        const header = e.target.closest('.nb-hover-preview-header');
        if (!header) return;
        if (e.target.closest('.nb-hover-preview-close')) return; // don't drag when clicking X

        isDragging = true;
        const rect = previewBox.getBoundingClientRect();
        dragOffsetX = e.clientX - rect.left;
        dragOffsetY = e.clientY - rect.top;

        document.addEventListener('mousemove', onDragMove);
        document.addEventListener('mouseup', onDragEnd);
    }

    function onDragMove(e) {
        if (!isDragging) return;

        const padding = 10;
        const boxW = previewBox.offsetWidth;
        const boxH = previewBox.offsetHeight;

        let x = e.clientX - dragOffsetX;
        let y = e.clientY - dragOffsetY;

        const maxX = window.innerWidth - boxW - padding;
        const maxY = window.innerHeight - boxH - padding;

        if (x < padding) x = padding;
        if (y < padding) y = padding;
        if (x > maxX) x = maxX;
        if (y > maxY) y = maxY;

        previewBox.style.left = x + 'px';
        previewBox.style.top  = y + 'px';
    }

    function onDragEnd() {
        if (!isDragging) return;
        isDragging = false;
        document.removeEventListener('mousemove', onDragMove);
        document.removeEventListener('mouseup', onDragEnd);
    }

    previewBox.addEventListener('mousedown', onDragStart);

    /***** OVERLAY CLICK TO CLOSE *****/
    overlay.addEventListener('click', function (e) {
        if (e.target === overlay) {
            hidePreview();
        }
    });

    /***** UTILS *****/
    function escapeHtml(str) {
        return String(str || '').replace(/[&<>"']/g, s => ({
            '&': '&amp;',
            '<': '&lt;',
            '>': '&gt;',
            '"': '&quot;',
            '\'': '&#39;'
        }[s]));
    }

    function centerPreview() {
        const boxW = previewBox.offsetWidth || 580;
        const boxH = previewBox.offsetHeight || 340;
        const padding = 10;

        let x = (window.innerWidth - boxW) / 2;
        let y = (window.innerHeight - boxH) / 2;

        if (x < padding) x = padding;
        if (y < padding) y = padding;

        previewBox.style.left = x + 'px';
        previewBox.style.top  = y + 'px';
    }

    function showPreview() {
        overlay.classList.remove('hidden');
        previewBox.classList.remove('hidden');
        centerPreview();
    }

    function hidePreview() {
        overlay.classList.add('hidden');
        previewBox.classList.add('hidden');
        previewBox.innerHTML = '';
        currentPreviewPageUrl = null;
        currentPreviewTitle   = '';
    }

    /**
     * Turn plain URLs inside an element into clickable links.
     * - Handles http://, https://, and www.*
     * - Skips text that's already inside an <a>.
     */
    function linkifyUrlsInElement(element) {
        if (!element) return;

        const doc = element.ownerDocument;
        const walker = doc.createTreeWalker(element, NodeFilter.SHOW_TEXT, null);
        const textNodes = [];
        let node;

        while ((node = walker.nextNode())) {
            textNodes.push(node);
        }

        const urlPattern = /\b(https?:\/\/[^\s<]+|www\.[^\s<]+)\b/gi;

        textNodes.forEach(textNode => {
            // Don't touch text already inside a link
            if (textNode.parentElement && textNode.parentElement.closest('a')) return;

            const text = textNode.textContent;
            if (!urlPattern.test(text)) {
                urlPattern.lastIndex = 0;
                return;
            }
            urlPattern.lastIndex = 0;

            const frag = doc.createDocumentFragment();
            let lastIndex = 0;
            let match;

            while ((match = urlPattern.exec(text)) !== null) {
                const url = match[0];
                const index = match.index;

                if (index > lastIndex) {
                    frag.appendChild(doc.createTextNode(text.slice(lastIndex, index)));
                }

                let href = url;
                if (href.startsWith('www.')) {
                    href = 'https://' + href;
                }

                const a = doc.createElement('a');
                a.href = href;
                a.target = '_blank';
                a.rel = 'noopener noreferrer';
                a.textContent = url;

                frag.appendChild(a);
                lastIndex = index + url.length;
            }

            if (lastIndex < text.length) {
                frag.appendChild(doc.createTextNode(text.slice(lastIndex)));
            }

            if (frag.childNodes.length) {
                textNode.parentNode.replaceChild(frag, textNode);
            }
        });
    }

    function renderPreview(opts) {
        const {
            titleText,
            bodyHtml,
            currentPageUrl,
            paginationHtml,
            loadingText
        } = opts;

        currentPreviewTitle = titleText || '';

        let innerContentHtml = '';

        if (loadingText) {
            innerContentHtml = `<div class="nb-hover-preview-loading">${escapeHtml(loadingText)}</div>`;
        } else {
            innerContentHtml = bodyHtml || '';

            if (paginationHtml) {
                innerContentHtml += `
                    <div class="topicNavBottom">
                        <div class="pageNav nb-hover-preview-pagination">
                            ${paginationHtml}
                        </div>
                    </div>
                `;
            }

            innerContentHtml += `
                <div class="nb-hover-preview-enter-wrapper">
                    <a class="nb-hover-preview-enter-btn" href="${currentPageUrl}" target="_blank" rel="noopener noreferrer">
                        Enter Thread
                    </a>
                </div>
            `;
        }

        const full = `
            <div class="nb-hover-preview-header">
                <span class="nb-hover-preview-title-text" title="${escapeHtml(titleText || '')}">
                    ${escapeHtml(titleText || '')}
                </span>
                <span class="nb-hover-preview-close" title="Close">✕</span>
            </div>
            <div class="nb-hover-preview-inner">
                ${innerContentHtml}
            </div>
        `;

        previewBox.innerHTML = full;
        showPreview();

        const closeBtn = previewBox.querySelector('.nb-hover-preview-close');
        if (closeBtn) {
            closeBtn.addEventListener('click', hidePreview);
        }

        // Wire pagination links
        if (!loadingText && paginationHtml) {
            const pagLinks = previewBox.querySelectorAll('.nb-hover-preview-pagination a.boardPageButton');
            pagLinks.forEach(link => {
                const rel = link.getAttribute('href');
                if (!rel) return;
                const abs = new URL(rel, currentPageUrl).href;

                link.addEventListener('click', function (ev) {
                    ev.preventDefault();
                    openPage(abs, titleText);
                });
            });
        }
    }

    /***** PARSE TOPIC HTML -> Classic replies layout + pagination *****/
    function parseTopicDocument(doc) {
        const posts = [];
        const liNodes = Array.from(doc.querySelectorAll('li'));

        for (const li of liNodes) {
            const liClone = li.cloneNode(true);

            // Remove report buttons from preview
            liClone.querySelectorAll('.reportButton-neoboards').forEach(btn => btn.remove());

            const byline = liClone.querySelector('.boardPostByline');
            const post   = liClone.querySelector('.boardPost');
            if (!post) continue;

            // Linkify URLs inside the message
            const msg = post.querySelector('.boardPostMessage');
            if (msg) {
                linkifyUrlsInElement(msg);
            }

            let bylineHTML = '';
            let postHTML   = '';

            if (byline) bylineHTML = byline.outerHTML;
            postHTML = post.outerHTML;

            posts.push(`
                <li>
                    ${bylineHTML}
                    ${postHTML}
                </li>
            `);

            if (posts.length >= MAX_POSTS) break;
        }

        let bodyHtml = '';
        if (!posts.length) {
            bodyHtml = `<div class="nb-hover-preview-error">No posts could be parsed for this topic.</div>`;
        } else {
            bodyHtml = `
                <div class="nb-hover-preview-thread">
                    <ul>
                        ${posts.join('')}
                    </ul>
                </div>
            `;
        }

        let paginationHtml = '';
        const nav =
            doc.querySelector('.topicNavBottom .pageNav') ||
            doc.querySelector('.topicNavTop .pageNav');

        if (nav) {
            paginationHtml = nav.innerHTML;
        }

        return { bodyHtml, paginationHtml };
    }

    /***** FETCH PAGE *****/
    function fetchTopicPage(pageUrl, titleText) {
        const cached = cache[pageUrl];
        if (cached && cached.bodyHtml && !cached.loading) {
            if (pageUrl === currentPreviewPageUrl) {
                renderPreview({
                    titleText,
                    bodyHtml: cached.bodyHtml,
                    currentPageUrl: pageUrl,
                    paginationHtml: cached.paginationHtml,
                    loadingText: ''
                });
            }
            return;
        }

        cache[pageUrl] = { loading: true, bodyHtml: '', paginationHtml: '' };

        GM_xmlhttpRequest({
            method: 'GET',
            url: pageUrl,
            onload: function (res) {
                try {
                    if (res.status !== 200) {
                        throw new Error('HTTP ' + res.status);
                    }

                    const parser = new DOMParser();
                    const doc = parser.parseFromString(res.responseText, 'text/html');

                    const { bodyHtml, paginationHtml } = parseTopicDocument(doc);
                    cache[pageUrl] = { loading: false, bodyHtml, paginationHtml };

                    if (pageUrl === currentPreviewPageUrl) {
                        renderPreview({
                            titleText,
                            bodyHtml,
                            currentPageUrl: pageUrl,
                            paginationHtml,
                            loadingText: ''
                        });
                    }
                } catch (err) {
                    const bodyHtml = `<div class="nb-hover-preview-error">Failed to load posts (${escapeHtml(err.message || 'unknown error')}).</div>`;
                    cache[pageUrl] = { loading: false, bodyHtml, paginationHtml: '' };

                    if (pageUrl === currentPreviewPageUrl) {
                        renderPreview({
                            titleText,
                            bodyHtml,
                            currentPageUrl: pageUrl,
                            paginationHtml: '',
                            loadingText: ''
                        });
                    }
                }
            },
            onerror: function () {
                const bodyHtml = `<div class="nb-hover-preview-error">Error while loading topic.</div>`;
                cache[pageUrl] = { loading: false, bodyHtml, paginationHtml: '' };

                if (pageUrl === currentPreviewPageUrl) {
                    renderPreview({
                        titleText,
                        bodyHtml,
                        currentPageUrl: pageUrl,
                        paginationHtml: '',
                        loadingText: ''
                    });
                }
            }
        });
    }

    /***** OPEN / SWITCH PAGE *****/
    function openPage(pageUrl, titleText) {
        currentPreviewPageUrl = pageUrl;
        currentPreviewTitle   = titleText;

        const cached = cache[pageUrl];
        if (cached && cached.bodyHtml && !cached.loading) {
            renderPreview({
                titleText,
                bodyHtml: cached.bodyHtml,
                currentPageUrl: pageUrl,
                paginationHtml: cached.paginationHtml,
                loadingText: ''
            });
        } else {
            renderPreview({
                titleText,
                bodyHtml: '',
                currentPageUrl: pageUrl,
                paginationHtml: '',
                loadingText: 'Loading posts…'
            });
            fetchTopicPage(pageUrl, titleText);
        }
    }

    /***** HOVER HANDLERS *****/
    function handleMouseEnter(e) {
        const a = e.currentTarget;
        currentAnchor = a;

        if (hoverTimer) {
            clearTimeout(hoverTimer);
            hoverTimer = null;
        }

        hoverTimer = setTimeout(() => {
            if (currentAnchor !== a) return;

            const href = a.href;
            if (!href || !href.includes('topic.phtml?topic=')) return;

            const titleText = (a.textContent || '').trim();

            openPage(href, titleText);
        }, HOVER_DELAY_MS);
    }

    function handleMouseLeave(e) {
        const a = e.currentTarget;
        if (currentAnchor === a) {
            currentAnchor = null;
        }
        if (hoverTimer) {
            clearTimeout(hoverTimer);
            hoverTimer = null;
        }
        // Popup stays until closed.
    }

    /***** INIT *****/
    function init() {
        const threadLinks = document.querySelectorAll(
            '.boardTopicTitle a[href*="topic.phtml?topic="]'
        );

        threadLinks.forEach(a => {
            a.addEventListener('mouseenter', handleMouseEnter);
            a.addEventListener('mouseleave', handleMouseLeave);
        });
    }

    init();
})();