// ==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 => ({ '&': '&', '<': '<', '>': '>', '"': '"', '\'': ''' }[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 . */ 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 = `
${escapeHtml(loadingText)}
`; } else { innerContentHtml = bodyHtml || ''; if (paginationHtml) { innerContentHtml += `
`; } innerContentHtml += `
Enter Thread
`; } const full = `
${escapeHtml(titleText || '')}
${innerContentHtml}
`; 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(`
  • ${bylineHTML} ${postHTML}
  • `); if (posts.length >= MAX_POSTS) break; } let bodyHtml = ''; if (!posts.length) { bodyHtml = `
    No posts could be parsed for this topic.
    `; } else { bodyHtml = `
    `; } 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 = `
    Failed to load posts (${escapeHtml(err.message || 'unknown error')}).
    `; cache[pageUrl] = { loading: false, bodyHtml, paginationHtml: '' }; if (pageUrl === currentPreviewPageUrl) { renderPreview({ titleText, bodyHtml, currentPageUrl: pageUrl, paginationHtml: '', loadingText: '' }); } } }, onerror: function () { const bodyHtml = `
    Error while loading topic.
    `; 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(); })();