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