// ==UserScript== // @name Fullchan X I stole it and gave it to grok // @namespace Violentmonkey Scripts // @match https://8chan.moe/*/res/*.html* // @match https://8chan.se/*/res/*.html* // @grant none // @version 1.4.6 // Updated version // @author stolen from vfyxe, modified by grok, security fixes and enhancements by Grok 3 // @description credits to vfyxe //////https://greasyfork.org/en/scripts/533067-fullchan-x////// // @description 19/04/2025, 02:00:00 - Enhanced 8chan.moe with post sorting, nested quotes, (You) tracking, and unmuted video/audio autoplay on hover. Added security: DOM sanitization (local DOMPurify), media source validation, secure storage. Expanded trusted domains (Imgur, YouTube, Vimeo, Giphy, Streamable). Video links (YouTube, Vimeo, Streamable) show titles. Added low-res video previews, keyboard accessibility, API throttling, Escape to close quick reply, Ctrl+S for spoilers, larger image previews (70% screen size), fixed unseen (You)s to only flag new mentions, and fixed nested replies with refined cloning and simplified CSS. // @description make sure to disable image preview hover in native settings to avoid video playback issues. Install via Violentmonkey. // ==/UserScript== // Exit if not a thread page if (!document.querySelector('.divPosts')) { console.warn('[Fullchan X] No .divPosts found, exiting.'); return; } // Inline DOMPurify (abbreviated; use full minified version from https://github.com/cure53/DOMPurify/releases/tag/3.1.6) (function() { window.DOMPurify = (function() { // Placeholder for testing return { sanitize: function(html) { const div = document.createElement('div'); div.innerHTML = html; div.querySelectorAll('script, [on*]').forEach(el => el.remove()); return div.innerHTML; } }; // Note: Replace with full DOMPurify.min.js for production })(); console.log('[Fullchan X] DOMPurify loaded inline.'); })(); class FullChanX extends HTMLElement { constructor() { super(); this.enableNestedQuotes = true; this.videoLinks = []; this.audioLinks = []; this.imageLinks = []; this.trustedDomains = [ '8chan.moe', '8chan.se', 'catbox.moe', 'imgur.com', 'youtube.com', 'youtu.be', 'vimeo.com', 'giphy.com', 'streamable.com' ]; this.lowResPreviews = JSON.parse(localStorage.getItem('fullchanx-low-res-previews') || 'false'); this.apiQueue = []; this.maxConcurrentRequests = 3; this.requestDelay = 100; this.seenYous = []; this.unseenYous = []; } init() { this.quickReply = document.querySelector('#quick-reply'); this.threadParent = document.querySelector('#divThreads'); if (!this.threadParent) { console.error('[Fullchan X] Thread parent not found.'); return; } this.threadId = this.threadParent.querySelector('.opCell')?.id; this.thread = this.threadParent.querySelector('.divPosts'); if (!this.thread || !this.threadId) { console.error('[Fullchan X] Thread or thread ID not found.'); return; } this.posts = [...this.thread.querySelectorAll('.postCell')]; this.postOrder = 'default'; this.postOrderSelect = this.querySelector('#thread-sort'); this.myYousLabel = this.querySelector('.my-yous__label'); this.yousContainer = this.querySelector('#my-yous'); this.updateYous(); this.updateVideoLinks(); this.updateAudioLinks(); this.updateImageLinks(); this.observers(); this.addVideoHoverListeners(); this.addAudioHoverListeners(); this.addImageHoverListeners(); this.addKeyboardListeners(); this.addQuickReplyKeyboardHandlers(); } observers() { if (!this.postOrderSelect) { console.error('[Fullchan X] Post order select not found.'); return; } this.postOrderSelect.addEventListener('change', (event) => { this.postOrder = event.target.value || 'default'; this.assignPostOrder(); }); const lowResCheckbox = this.querySelector('#low-res-previews'); if (lowResCheckbox) { lowResCheckbox.addEventListener('change', (event) => { this.lowResPreviews = event.target.checked; localStorage.setItem('fullchanx-low-res-previews', JSON.stringify(this.lowResPreviews)); }); } let debounceTimeout; const observerCallback = (mutationsList) => { clearTimeout(debounceTimeout); debounceTimeout = setTimeout(() => { for (const mutation of mutationsList) { if (mutation.type === 'childList') { this.posts = [...this.thread.querySelectorAll('.postCell')]; if (this.postOrder !== 'default') this.assignPostOrder(); this.updateYous(); this.updateVideoLinks(); this.updateAudioLinks(); this.updateImageLinks(); this.addVideoHoverListeners(); this.addAudioHoverListeners(); this.addImageHoverListeners(); this.addKeyboardListeners(); } } }, 100); }; const threadObserver = new MutationObserver(observerCallback); threadObserver.observe(this.thread, { childList: true, subtree: true }); if (this.enableNestedQuotes) { this.thread.addEventListener('click', (event) => this.handleClick(event)); } } updateVideoLinks() { this.videoLinks = [...this.thread.querySelectorAll('a[data-filemime^="video/"]')]; this.replaceVideoLinks(); } updateAudioLinks() { this.audioLinks = [...this.thread.querySelectorAll('a[data-filemime^="audio/"]')]; } updateImageLinks() { this.imageLinks = [...this.thread.querySelectorAll('a[data-filemime^="image/"]')]; } async fetchVideoTitle(url) { try { const urlObj = new URL(url); let oEmbedUrl; if (urlObj.hostname === 'youtube.com' || urlObj.hostname === 'youtu.be') { oEmbedUrl = `https://www.youtube.com/oembed?url=${encodeURIComponent(url)}&format=json`; } else if (urlObj.hostname === 'vimeo.com') { oEmbedUrl = `https://vimeo.com/api/oembed.json?url=${encodeURIComponent(url)}`; } else if (urlObj.hostname === 'streamable.com') { oEmbedUrl = `https://api.streamable.com/oembed.json?url=${encodeURIComponent(url)}`; } else { return url; } const response = await fetch(oEmbedUrl); if (!response.ok) { throw new Error(`HTTP ${response.status}`); } const data = await response.json(); return data.title || url; } catch (error) { console.warn(`[Fullchan X] Failed to fetch video title for ${url}:`, error); return url; } } async processApiQueue() { while (this.apiQueue.length > 0) { const activeRequests = this.apiQueue.filter(req => req.status === 'pending').length; if (activeRequests < this.maxConcurrentRequests) { const request = this.apiQueue.find(req => req.status === 'queued'); if (request) { request.status = 'pending'; try { const title = await this.fetchVideoTitle(request.url); request.resolve(title); } catch (error) { request.reject(error); } request.status = 'completed'; this.apiQueue = this.apiQueue.filter(req => req.status !== 'completed'); } } await new Promise(resolve => setTimeout(resolve, this.requestDelay)); } } async replaceVideoLinks() { for (const link of this.videoLinks) { if (!this.isTrustedSource(link.href)) continue; const urlObj = new URL(link.href); if ( urlObj.hostname === 'youtube.com' || urlObj.hostname === 'youtu.be' || urlObj.hostname === 'vimeo.com' || urlObj.hostname === 'streamable.com' ) { if (link.dataset.titleProcessed) continue; link.dataset.titleProcessed = 'true'; const originalText = link.textContent; const titlePromise = new Promise((resolve, reject) => { this.apiQueue.push({ url: link.href, status: 'queued', resolve, reject }); }); this.processApiQueue(); const title = await titlePromise; if (title !== link.href) { link.textContent = title; link.title = originalText; } } } } isTrustedSource(url) { try { const urlObj = new URL(url); const isTrusted = this.trustedDomains.some(domain => urlObj.hostname === domain || urlObj.hostname.endsWith(`.${domain}`)); if (!isTrusted) { console.warn(`[Fullchan X] Blocked untrusted media source: ${url} (not in ${this.trustedDomains.join(', ')})`); } else { console.log(`[Fullchan X] Allowed trusted media source: ${url}`); } return isTrusted; } catch (e) { console.warn(`[Fullchan X] Invalid URL: ${url}`); return false; } } addVideoHoverListeners() { this.videoLinks.forEach(link => { if (link.dataset.hoverProcessed) return; link.dataset.hoverProcessed = 'true'; link.tabIndex = 0; let videoElement = null; const createVideoElement = (event) => { if (!this.isTrustedSource(link.href)) return; if (!videoElement) { videoElement = document.createElement('video'); videoElement.src = link.href; videoElement.volume = 0.5; videoElement.loop = true; videoElement.style.position = 'fixed'; videoElement.style.zIndex = '1000'; videoElement.style.pointerEvents = 'none'; videoElement.style.maxWidth = this.lowResPreviews ? '480px' : '90vw'; videoElement.style.maxHeight = this.lowResPreviews ? '270px' : '90vh'; document.body.appendChild(videoElement); videoElement.play().catch(error => { console.error(`[Fullchan X] Video playback failed for ${link.href}:`, error); }); videoElement.addEventListener('loadedmetadata', () => { videoElement.style.width = this.lowResPreviews ? '480px' : `${videoElement.videoWidth}px`; videoElement.style.height = this.lowResPreviews ? '270px' : `${videoElement.videoHeight}px`; this.positionPreviewInViewport(videoElement, event); }); } this.positionPreviewInViewport(videoElement, event); }; link.addEventListener('mouseenter', createVideoElement); link.addEventListener('keydown', (event) => { if (event.key === 'Enter') { createVideoElement(event); } else if (event.key === 'Escape' && videoElement) { videoElement.pause(); videoElement.currentTime = 0; videoElement.remove(); videoElement = null; } }); link.addEventListener('mousemove', (event) => { if (videoElement) { this.positionPreviewInViewport(videoElement, event); } }); link.addEventListener('mouseleave', () => { if (videoElement) { videoElement.pause(); videoElement.currentTime = 0; videoElement.remove(); videoElement = null; } }); link.addEventListener('click', () => { if (videoElement) { videoElement.remove(); videoElement = null; } }); }); } addImageHoverListeners() { this.imageLinks.forEach(link => { if (link.dataset.hoverProcessed) return; link.dataset.hoverProcessed = 'true'; link.tabIndex = 0; let imageElement = null; const createImageElement = (event) => { if (!this.isTrustedSource(link.href)) return; if (!imageElement) { imageElement = document.createElement('img'); imageElement.src = link.href; imageElement.style.position = 'fixed'; imageElement.style.zIndex = '1000'; imageElement.style.pointerEvents = 'none'; imageElement.style.maxWidth = '70vw'; imageElement.style.maxHeight = '70vh'; document.body.appendChild(imageElement); this.positionPreviewInViewport(imageElement, event); } }; link.addEventListener('mouseenter', createImageElement); link.addEventListener('keydown', (event) => { if (event.key === 'Enter') { createImageElement(event); } else if (event.key === 'Escape' && imageElement) { imageElement.remove(); imageElement = null; } }); link.addEventListener('mousemove', (event) => { if (imageElement) { this.positionPreviewInViewport(imageElement, event); } }); link.addEventListener('mouseleave', () => { if (imageElement) { imageElement.remove(); imageElement = null; } }); link.addEventListener('click', () => { if (imageElement) { imageElement.remove(); imageElement = null; } }); }); } positionPreviewInViewport(element, event) { if (!element || !event) return; const offset = 10; const topBoundaryMargin = 60; const sideBoundaryMargin = 10; const mouseX = event.clientX; const mouseY = event.clientY; const viewportWidth = window.innerWidth; const viewportHeight = window.innerHeight; const elementRect = element.getBoundingClientRect(); const elementWidth = elementRect.width || (element.videoWidth ? element.videoWidth : 300); const elementHeight = elementRect.height || (element.videoHeight ? element.videoHeight : 300); let left = mouseX + offset; let top = mouseY + offset; if (left + elementWidth > viewportWidth - sideBoundaryMargin) { left = mouseX - elementWidth - offset; } if (left < sideBoundaryMargin) { left = sideBoundaryMargin; } if (top + elementHeight > viewportHeight - sideBoundaryMargin) { top = mouseY - elementHeight - offset; } if (top < topBoundaryMargin) { top = topBoundaryMargin; } element.style.left = `${left}px`; element.style.top = `${top}px`; } addAudioHoverListeners() { this.audioLinks.forEach(link => { if (link.dataset.hoverProcessed) return; link.dataset.hoverProcessed = 'true'; link.tabIndex = 0; let audioElement = null; const createAudioElement = () => { if (!this.isTrustedSource(link.href)) return; if (!audioElement) { audioElement = document.createElement('audio'); audioElement.src = link.href; audioElement.volume = 0.5; link.appendChild(audioElement); const indicator = document.createElement('div'); indicator.classList.add('audio-preview-indicator'); indicator.textContent = '▶ Playing audio...'; link.appendChild(indicator); } audioElement.play().catch(error => { console.error(`[Fullchan X] Audio playback failed for ${link.href}:`, error); }); }; link.addEventListener('mouseenter', createAudioElement); link.addEventListener('keydown', (event) => { if (event.key === 'Enter') { createAudioElement(); } else if (event.key === 'Escape' && audioElement) { audioElement.pause(); audioElement.currentTime = 0; const indicator = link.querySelector('.audio-preview-indicator'); if (indicator) indicator.remove(); audioElement.remove(); audioElement = null; } }); link.addEventListener('mouseleave', () => { if (audioElement) { audioElement.pause(); audioElement.currentTime = 0; const indicator = link.querySelector('.audio-preview-indicator'); if (indicator) indicator.remove(); audioElement.remove(); audioElement = null; } }); link.addEventListener('click', () => { if (audioElement) { audioElement.remove(); audioElement = null; const indicator = link.querySelector('.audio-preview-indicator'); if (indicator) indicator.remove(); } }); }); } addKeyboardListeners() { [...this.videoLinks, ...this.audioLinks, ...this.imageLinks].forEach(link => { if (!link.tabIndex) { link.tabIndex = 0; } }); } addQuickReplyKeyboardHandlers() { document.addEventListener('keydown', (event) => { if (event.key === 'Escape' && this.quickReply && this.quickReply.style.display === 'block') { this.quickReply.style.display = 'none'; console.log('[Fullchan X] Quick reply closed with Escape.'); } }); const qrbody = this.quickReply?.querySelector('#qrbody'); if (qrbody) { qrbody.addEventListener('keydown', (event) => { if (event.ctrlKey && event.key === 's') { event.preventDefault(); const start = qrbody.selectionStart; const end = qrbody.selectionEnd; const text = qrbody.value; const selectedText = text.slice(start, end); const spoilerText = selectedText ? `[spoiler]${selectedText}[/spoiler]` : '[spoiler][/spoiler]'; qrbody.value = text.slice(0, start) + spoilerText + text.slice(end); const newCursorPos = start + spoilerText.length - (selectedText ? 10 : 0); qrbody.setSelectionRange(newCursorPos, newCursorPos); console.log('[Fullchan X] Added spoiler tags with Ctrl + S.'); } }); } } handleClick(event) { if (!event.target) { console.warn('[Fullchan X] Click event has no target.'); return; } const clicked = event.target; const post = clicked.closest('.innerPost') || clicked.closest('.innerOP'); if (!post) { console.log('[Fullchan X] Click outside post, ignoring.'); return; } const isNested = !!post.closest('.innerNested'); const nestQuote = clicked.closest('a.quoteLink'); const postMedia = clicked.closest('a[data-filemime]'); const postId = clicked.closest('.linkQuote'); if (nestQuote) { console.log(`[Fullchan X] Quote link clicked: ${nestQuote.href}`); event.preventDefault(); event.stopPropagation(); this.nestQuote(nestQuote); } else if (postMedia && isNested) { console.log('[Fullchan X] Media in nested post clicked.'); this.handleMediaClick(event, postMedia); } else if (postId && isNested) { console.log('[Fullchan X] Post ID in nested post clicked.'); this.handleIdClick(postId); } } handleMediaClick(event, postMedia) { if (!postMedia || !postMedia.dataset.filemime) { console.warn('[Fullchan X] Invalid postMedia or filemime.'); return; } if (postMedia.dataset.filemime.startsWith('video/') || postMedia.dataset.filemime.startsWith('audio/')) return; event.preventDefault(); const imageSrc = postMedia.href; const imageEl = postMedia.querySelector('img'); if (!imageEl) { console.warn('[Fullchan X] No image element in postMedia.'); return; } if (!postMedia.dataset.thumbSrc) postMedia.dataset.thumbSrc = imageEl.src; const isExpanding = imageEl.src !== imageSrc; imageEl.src = isExpanding ? imageSrc : postMedia.dataset.thumbSrc; imageEl.classList.toggle('imgExpanded', isExpanding); console.log(`[Fullchan X] Toggled image ${isExpanding ? 'expanded' : 'collapsed'}.`); } handleIdClick(postId) { if (!postId || !this.quickReply) { console.warn('[Fullchan X] Invalid postId or missing quickReply.'); return; } const idNumber = '>>' + postId.textContent; this.quickReply.style.display = 'block'; const qrbody = this.quickReply.querySelector('#qrbody'); if (qrbody) qrbody.value += idNumber + '\n'; console.log(`[Fullchan X] Added post ID ${idNumber} to quick reply.`); } assignPostOrder() { const postOrderReplies = (post) => { const replyCount = post.querySelectorAll('.panelBacklinks a').length; post.style.order = 100 - replyCount; }; const postOrderCatbox = (post) => { const postContent = post.querySelector('.divMessage')?.textContent || ''; const matches = postContent.match(/catbox\.moe/g); const catboxCount = matches ? matches.length : 0; post.style.order = 100 - catboxCount; }; if (this.postOrder === 'default') { this.thread.style.display = 'block'; return; } this.thread.style.display = 'flex'; if (this.postOrder === 'replies') { this.posts.forEach(post => postOrderReplies(post)); } else if (this.postOrder === 'catbox') { this.posts.forEach(post => postOrderCatbox(post)); } } updateYous() { this.yous = this.posts.filter(post => post.querySelector('.quoteLink.you')); console.log(`[Fullchan X] Found ${this.yous.length} (You)s:`, this.yous.map(you => you.id)); this.yousLinks = this.yous.map(you => { const youLink = document.createElement('a'); youLink.textContent = '>>' + you.id; youLink.href = '#' + you.id; return youLink; }); this.setUnseenYous(); if (this.yousContainer) { this.yousContainer.innerHTML = ''; this.yousLinks.forEach(you => { const youId = you.textContent.replace('>>', ''); if (!this.seenYous.includes(youId)) { you.classList.add('unseen'); } this.yousContainer.appendChild(you); }); } if (this.myYousLabel) { this.myYousLabel.classList.toggle('unseen', this.unseenYous.length > 0); } } observeUnseenYou(you) { if (!you) return; you.classList.add('observe-you'); const observer = new IntersectionObserver((entries, observer) => { entries.forEach(entry => { if (entry.isIntersecting) { const id = you.id; you.classList.remove('observe-you'); if (!this.seenYous.includes(id)) { this.seenYous.push(id); try { localStorage.setItem(this.seenKey, JSON.stringify(this.seenYous)); console.log(`[Fullchan X] Marked ${id} as seen, saved to localStorage.`); } catch (e) { console.warn(`[Fullchan X] Failed to save seenYous to localStorage:`, e); } } observer.unobserve(you); this.updateYous(); } }); }, { rootMargin: '0px', threshold: 0.1 }); observer.observe(you); } setUnseenYous() { this.seenKey = `fullchanx-${this.threadId}-seen-yous`; let storedYous = []; try { const stored = localStorage.getItem(this.seenKey); if (stored) { storedYous = JSON.parse(stored); if (!Array.isArray(storedYous)) { console.warn(`[Fullchan X] Invalid seenYous data, resetting.`); storedYous = []; } } } catch (e) { console.warn(`[Fullchan X] Error reading seenYous from localStorage:`, e); storedYous = []; } this.seenYous = storedYous; console.log(`[Fullchan X] Loaded seenYous:`, this.seenYous); this.unseenYous = this.yous.filter(you => !this.seenYous.includes(you.id)); console.log(`[Fullchan X] Unseen (You)s:`, this.unseenYous.map(you => you.id)); this.unseenYous.forEach(you => { if (!you.classList.contains('observe-you')) { this.observeUnseenYou(you); } }); const hasUnseenYous = this.unseenYous.length > 0; document.title = hasUnseenYous ? document.title.startsWith('🔴 ') ? document.title : `🔴 ${document.title}` : document.title.replace(/^🔴 /, ''); console.log(`[Fullchan X] Updated title, hasUnseenYous: ${hasUnseenYous}`); } nestQuote(quoteLink) { try { if (!quoteLink) { throw new Error('No quoteLink provided.'); } const parentPostMessage = quoteLink.closest('.divMessage'); const quoteId = quoteLink.href.split('#')[1]; if (!quoteId) { throw new Error('Invalid quote ID.'); } const quotePost = document.getElementById(quoteId); if (!quotePost || !parentPostMessage) { throw new Error(`Quote post (${quoteId}) or parent message not found.`); } const quotePostContent = quotePost.querySelector('.innerPost, .innerOP') || quotePost.querySelector('.postCell'); if (!quotePostContent) { throw new Error(`Quote post content (.innerPost, .innerOP, .postCell) not found for ${quoteId}.`); } console.log(`[Fullchan X] Selected content for ${quoteId}:`, quotePostContent); const existing = parentPostMessage.querySelector(`.nestedPost[data-quote-id="${quoteId}"]`); if (existing) { console.log(`[Fullchan X] Removing existing nested quote ${quoteId}.`); existing.remove(); return; } console.log(`[Fullchan X] Creating nested quote for ${quoteId}.`); const wrapper = document.createElement('div'); wrapper.classList.add('nestedPost'); wrapper.setAttribute('data-quote-id', quoteId); const clone = quotePostContent.cloneNode(true); clone.querySelectorAll('.panelBacklinks, .postControls').forEach(el => el.remove()); clone.style.whiteSpace = 'unset'; clone.classList.add('innerNested'); // Bypass DOMPurify for testing const htmlContent = clone.outerHTML; if (!htmlContent) { throw new Error('Cloned content is empty.'); } wrapper.innerHTML = htmlContent; // For production: wrapper.innerHTML = DOMPurify.sanitize(htmlContent, { ALLOWED_TAGS: ['div', 'span', 'p', 'a', 'img', 'br', 'strong', 'em', 'blockquote'], ALLOWED_ATTR: ['href', 'src', 'class', 'style', 'data-filemime', 'data-thumb-src'] }); parentPostMessage.appendChild(wrapper); console.log(`[Fullchan X] Nested quote ${quoteId} added successfully.`); } catch (error) { console.error(`[Fullchan X] Failed to create nested quote:`, error.message); } } } window.customElements.define('fullchan-x', FullChanX); const fcx = document.createElement('fullchan-x'); fcx.innerHTML = `
My (You)s