Converts image links (jpg/png/webp) in shoutbox to inline thumbnails with click-to-enlarge fullscreen popup.
// ==UserScript==
// @name Shoutbox Direct Image Link Viewer
// @icon https://icons.duckduckgo.com/ip3/torrentbd.net.ico
// @namespace foxbinner
// @version 1.0.1
// @description Converts image links (jpg/png/webp) in shoutbox to inline thumbnails with click-to-enlarge fullscreen popup.
// @match https://*.torrentbd.com/*
// @match https://*.torrentbd.net/*
// @match https://*.torrentbd.org/*
// @match https://*.torrentbd.me/*
// @grant none
// @author foxbinner
// @license MIT
// ==/UserScript==
(function () {
'use strict';
// Ignore Links - Tenor.com links should remain as regular links
const ignoreRegex = /https?:\/\/(?:tenor\.com)/i;
// Fixed regex - handles GIFs with query params (?abc=123)
const imageRegex = /https?:\/\/[^\s'"><]+\.(?:png|jpe?g|webp|gif)(?:\?[^\s'">]*)?(?=[^\w\-]|$)/gi;
function convertLinksInNode(node) {
const textField = node.querySelector('.shout-text');
if (!textField) return;
const walker = document.createTreeWalker(textField, NodeFilter.SHOW_TEXT, null);
const textNodes = [];
let curr;
while ((curr = walker.nextNode())) textNodes.push(curr);
textNodes.forEach(textNode => {
const text = textNode.nodeValue;
if (!imageRegex.test(text)) return;
const frag = document.createDocumentFragment();
let lastIndex = 0;
imageRegex.lastIndex = 0;
let match;
while ((match = imageRegex.exec(text)) !== null) {
const url = match[0];
// Skip if matches ignore pattern (Tenor links)
if (ignoreRegex.test(url)) {
if (match.index > lastIndex) {
frag.appendChild(document.createTextNode(text.slice(lastIndex, match.index)));
}
frag.appendChild(document.createTextNode(url));
frag.appendChild(document.createTextNode(' '));
lastIndex = imageRegex.lastIndex;
continue;
}
if (match.index > lastIndex) {
frag.appendChild(document.createTextNode(text.slice(lastIndex, match.index)));
}
const img = document.createElement('img');
img.src = url;
img.dataset.fullsrc = url;
img.style.cssText = 'max-width:100px; max-height:100px; vertical-align:middle; display:inline; margin:0 4px; cursor:pointer; border-radius:4px;';
img.alt = 'image';
frag.appendChild(document.createTextNode(' '));
frag.appendChild(img);
frag.appendChild(document.createTextNode(' '));
lastIndex = imageRegex.lastIndex;
}
if (lastIndex < text.length) {
frag.appendChild(document.createTextNode(text.slice(lastIndex)));
}
textNode.parentNode.replaceChild(frag, textNode);
});
}
function createOverlay(img) {
const existing = document.querySelector('.image-overlay');
if (existing) existing.remove();
const overlay = document.createElement('div');
overlay.className = 'image-overlay';
overlay.style.cssText = `
position: fixed; top: 0; left: 0; width: 100vw; height: 100vh;
background: rgba(0,0,0,0.9); z-index: 9999;
display: flex; align-items: center; justify-content: center;
cursor: pointer;
`;
const fullImg = document.createElement('img');
fullImg.src = img.dataset.fullsrc || img.src;
fullImg.style.cssText = `
max-width: 90vw; max-height: 90vh;
border-radius: 8px; box-shadow: 0 10px 30px rgba(0,0,0,0.5);
`;
overlay.appendChild(fullImg);
overlay.onclick = () => overlay.remove();
fullImg.onclick = (e) => e.stopPropagation();
document.addEventListener('keydown', function escClose(e) {
if (e.key === 'Escape') overlay.remove();
}, { once: true });
document.body.appendChild(overlay);
}
function addClickHandlers() {
document.querySelectorAll('.shout-text img[data-fullsrc]').forEach(img => {
img.onclick = (e) => {
e.preventDefault();
e.stopPropagation();
createOverlay(img);
};
if (img.parentElement.tagName === 'A') {
img.parentElement.onclick = (e) => e.preventDefault();
}
});
}
function scanAllShouts() {
document.querySelectorAll('.shout-item').forEach(convertLinksInNode);
addClickHandlers();
}
scanAllShouts();
const shoutContainer = document.querySelector('#shouts-container');
if (shoutContainer) {
const observer = new MutationObserver((mutations) => {
mutations.forEach((m) => {
m.addedNodes.forEach((node) => {
if (node.nodeType === Node.ELEMENT_NODE &&
(node.classList?.contains('shout-item') ||
node.querySelector?.('.shout-item'))) {
const shoutItem = node.classList?.contains('shout-item') ? node : node.querySelector('.shout-item');
setTimeout(() => {
convertLinksInNode(shoutItem);
addClickHandlers();
}, 50);
}
});
});
});
observer.observe(shoutContainer, { childList: true, subtree: true });
}
})();