// ==UserScript==
// @name Fullchan X mega test
// @namespace Violentmonkey Scripts
// @match https://8chan.moe/*/res/*.html*
// @match https://8chan.se/*/res/*.html*
// @grant none
// @version 1.4.16.4 // Rolled back to v1.4.16.1, added video title and trust check caching, cleaned up description
// @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 Enhances 8chan.moe/se with post sorting, nested quotes, (You) tracking, video title replacement, and media previews. Additions: control box toggle, video title and trust check caching. Fixes: custom element name, nested replies, hotkeys, (You)s marking, YouTube titles. Features DOMPurify sanitization, API throttling, trusted domains (Imgur, YouTube, etc.). Disable native image preview in settings to avoid playback issues. Install via Violentmonkey.
// ==/UserScript==
// Exit if not a thread page
const divPosts = document.querySelector('.divPosts');
if (!divPosts) {
console.warn('[Fullchan X] No .divPosts found, exiting.');
return;
}
// DOMPurify: Include full DOMPurify.min.js from https://github.com/cure53/DOMPurify/releases/tag/3.1.6
(function() {
if (!window.DOMPurify) {
window.DOMPurify = {
sanitize: function(html) {
console.log('[Fullchan X] Sanitizing HTML with placeholder...');
const div = document.createElement('div');
div.innerHTML = html;
const unsafeAttrs = ['onclick', 'onload', 'onerror', 'onmouseover', 'onmouseout'];
div.querySelectorAll('script').forEach(el => el.remove());
div.querySelectorAll('*').forEach(el => {
unsafeAttrs.forEach(attr => el.removeAttribute(attr));
});
const sanitized = div.innerHTML;
console.log('[Fullchan X] Sanitization complete:', sanitized.slice(0, 100) + (sanitized.length > 100 ? '...' : ''));
return sanitized;
}
};
console.warn('[Fullchan X] Using placeholder DOMPurify. Include full DOMPurify.min.js for production.');
} else {
console.log('[Fullchan X] DOMPurify loaded.');
}
})();
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 = [];
this.pendingSeenYous = [];
this.tabFocused = document.visibilityState === 'visible';
// NEW: Cache settings
this.titleCacheKey = 'fullchanx-video-titles';
this.trustCacheKey = 'fullchanx-trusted-urls';
this.titleCacheTTL = 604800000; // 7 days
this.trustCacheTTL = 2592000000; // 30 days
this.titleCacheMaxSize = 1000;
this.trustCacheMaxSize = 5000;
}
init() {
try {
console.log('[Fullchan X] Starting initialization...');
console.log(`[Fullchan X] Tab visibility: ${this.tabFocused ? 'visible' : 'hidden'}`);
this.quickReply = document.querySelector('#quick-reply') || document.querySelector('form[id*="reply"], form');
console.log(`[Fullchan X] Quick reply: ${this.quickReply ? 'found' : 'not found'}`);
this.threadParent = document.querySelector('#divThreads') || document.querySelector('[id*="thread"]');
console.log(`[Fullchan X] Thread parent: ${this.threadParent ? 'found' : 'not found'}`);
if (!this.threadParent) {
console.error('[Fullchan X] Thread parent not found, exiting init.');
return;
}
this.threadId = this.threadParent.querySelector('.opCell')?.id;
console.log(`[Fullchan X] Thread ID: ${this.threadId || 'not found'}`);
this.thread = this.threadParent.querySelector('.divPosts');
console.log(`[Fullchan X] Thread (.divPosts): ${this.thread ? 'found' : 'not found'}`);
if (!this.thread || !this.threadId) {
console.error('[Fullchan X] Thread or thread ID not found, exiting init.');
return;
}
this.posts = [...this.thread.querySelectorAll('.postCell, [class*="post"]')];
console.log(`[Fullchan X] Found ${this.posts.length} posts`);
const youLinks = this.thread.querySelectorAll('.quoteLink.you, .quoteLink[id*="you"]');
console.log(`[Fullchan X] Found ${youLinks.length} .quoteLink.you elements`);
this.postOrder = 'default';
this.postOrderSelect = this.querySelector('#thread-sort');
console.log(`[Fullchan X] Thread sort select: ${this.postOrderSelect ? 'found' : 'not found'}`);
this.myYousLabel = this.querySelector('.my-yous__label');
this.yousContainer = this.querySelector('#my-yous');
console.log(`[Fullchan X] My (You)s container: ${this.yousContainer ? 'found' : 'not found'}`);
if (!this.yousContainer || !this.myYousLabel) {
console.warn('[Fullchan X] #my-yous or .my-yous__label missing, (You)s may not display.');
}
document.addEventListener('visibilitychange', () => {
this.tabFocused = document.visibilityState === 'visible';
console.log(`[Fullchan X] Tab visibility: ${this.tabFocused ? 'visible' : 'hidden'}`);
if (this.tabFocused && this.pendingSeenYous.length > 0) {
console.log(`[Fullchan X] Tab focused, processing ${this.pendingSeenYous.length} pending (You)s`);
this.processPendingYous();
}
});
// NEW: Clean expired caches
this.cleanExpiredCache(this.titleCacheKey, this.titleCacheTTL, this.titleCacheMaxSize);
this.cleanExpiredCache(this.trustCacheKey, this.trustCacheTTL, this.trustCacheMaxSize);
this.updateYous();
this.updateVideoLinks();
this.updateAudioLinks();
this.updateImageLinks();
this.observers();
this.addVideoHoverListeners();
this.addAudioHoverListeners();
this.addImageHoverListeners();
this.addKeyboardListeners();
this.addQuickReplyKeyboardHandlers();
console.log('[Fullchan X] Initialized successfully.');
} catch (error) {
console.error('[Fullchan X] Initialization failed:', error);
}
}
// NEW: Get cached video title
getCachedTitle(url) {
try {
const cache = JSON.parse(localStorage.getItem(this.titleCacheKey) || '{}');
const entry = cache[url];
if (entry && Date.now() - entry.timestamp < this.titleCacheTTL) {
console.log(`[Fullchan X] Title cache hit for ${url}: ${entry.title}`);
return entry.title;
}
console.log(`[Fullchan X] Title cache miss for ${url}`);
return null;
} catch (error) {
console.warn('[Fullchan X] Error reading title cache:', error);
return null;
}
}
// NEW: Set cached video title
setCachedTitle(url, title) {
try {
let cache = JSON.parse(localStorage.getItem(this.titleCacheKey) || '{}');
cache[url] = { title, timestamp: Date.now() };
localStorage.setItem(this.titleCacheKey, JSON.stringify(cache));
console.log(`[Fullchan X] Cached title for ${url}: ${title}`);
} catch (error) {
console.warn('[Fullchan X] Error writing to title cache:', error);
}
}
// NEW: Get cached trust check
getCachedTrust(url) {
try {
const cache = JSON.parse(localStorage.getItem(this.trustCacheKey) || '{}');
const entry = cache[url];
if (entry && Date.now() - entry.timestamp < this.trustCacheTTL) {
console.log(`[Fullchan X] Trust cache hit for ${url}: ${entry.isTrusted}`);
return entry.isTrusted;
}
console.log(`[Fullchan X] Trust cache miss for ${url}`);
return null;
} catch (error) {
console.warn('[Fullchan X] Error reading trust cache:', error);
return null;
}
}
// NEW: Set cached trust check
setCachedTrust(url, isTrusted) {
try {
let cache = JSON.parse(localStorage.getItem(this.trustCacheKey) || '{}');
cache[url] = { isTrusted, timestamp: Date.now() };
localStorage.setItem(this.trustCacheKey, JSON.stringify(cache));
console.log(`[Fullchan X] Cached trust for ${url}: ${isTrusted}`);
} catch (error) {
console.warn('[Fullchan X] Error writing to trust cache:', error);
}
}
// NEW: Clean expired or excess cache entries
cleanExpiredCache(cacheKey, ttl, maxSize) {
try {
let cache = JSON.parse(localStorage.getItem(cacheKey) || '{}');
const now = Date.now();
let entries = Object.entries(cache);
// Remove expired entries
entries = entries.filter(([_, entry]) => now - entry.timestamp < ttl);
// Sort by timestamp (oldest first) and limit size
entries.sort((a, b) => a[1].timestamp - b[1].timestamp);
if (entries.length > maxSize) {
entries = entries.slice(-maxSize);
}
cache = Object.fromEntries(entries);
localStorage.setItem(cacheKey, JSON.stringify(cache));
console.log(`[Fullchan X] Cleaned cache ${cacheKey}, ${entries.length} entries remain`);
} catch (error) {
console.warn(`[Fullchan X] Error cleaning cache ${cacheKey}:`, error);
}
}
processPendingYous() {
try {
console.log('[Fullchan X] Processing pending (You)s...');
if (this.pendingSeenYous.length === 0) {
console.log('[Fullchan X] No pending (You)s to process.');
return;
}
this.seenYous.push(...this.pendingSeenYous);
this.pendingSeenYous = [];
try {
localStorage.setItem(this.seenKey, JSON.stringify(this.seenYous));
console.log(`[Fullchan X] Saved seenYous: ${this.seenYous.length}`);
} catch (e) {
console.warn(`[Fullchan X] Failed to save seenYous: ${e.message}`);
}
this.updateYous();
console.log('[Fullchan X] Pending (You)s processed.');
} catch (error) {
console.error('[Fullchan X] Error processing pending (You)s:', error);
}
}
observers() {
try {
console.log('[Fullchan X] Setting up observers...');
if (!this.postOrderSelect) {
console.error('[Fullchan X] Post order select not found, skipping sort listener.');
} else {
this.postOrderSelect.addEventListener('change', (event) => {
this.postOrder = event.target.value || 'default';
this.assignPostOrder();
console.log(`[Fullchan X] Post order changed to: ${this.postOrder}`);
});
}
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));
console.log(`[Fullchan X] Low-res previews: ${this.lowResPreviews}`);
});
} else {
console.warn('[Fullchan X] Low-res checkbox not found.');
}
const controlsCheckbox = this.querySelector('#toggle-controls');
if (controlsCheckbox) {
controlsCheckbox.addEventListener('change', (event) => {
const isVisible = event.target.checked;
localStorage.setItem('fullchanx-controls-visible', JSON.stringify(isVisible));
const controlsContainer = this.querySelector('.fcx__controls__inner');
if (controlsContainer) {
controlsContainer.style.display = isVisible ? 'flex' : 'none';
console.log(`[Fullchan X] Control box visibility: ${isVisible}`);
}
});
const isVisible = JSON.parse(localStorage.getItem('fullchanx-controls-visible') || 'true');
const controlsContainer = this.querySelector('.fcx__controls__inner');
if (controlsContainer) {
controlsContainer.style.display = isVisible ? 'flex' : 'none';
controlsCheckbox.checked = isVisible;
console.log(`[Fullchan X] Initial control box visibility: ${isVisible}`);
}
} else {
console.warn('[Fullchan X] Controls toggle checkbox not found.');
}
let debounceTimeout;
const observerCallback = (mutationsList) => {
console.log('[Fullchan X] MutationObserver triggered');
clearTimeout(debounceTimeout);
debounceTimeout = setTimeout(() => {
try {
for (const mutation of mutationsList) {
if (mutation.type === 'childList') {
this.posts = [...this.thread.querySelectorAll('.postCell, [class*="post"]')];
console.log(`[Fullchan X] Updated posts: ${this.posts.length}`);
if (this.postOrder !== 'default') this.assignPostOrder();
this.updateYous();
this.updateVideoLinks();
this.updateAudioLinks();
this.updateImageLinks();
this.addVideoHoverListeners();
this.addAudioHoverListeners();
this.addImageHoverListeners();
this.addKeyboardListeners();
}
}
} catch (error) {
console.error('[Fullchan X] Observer error:', error);
}
}, 100);
};
const threadObserver = new MutationObserver(observerCallback);
threadObserver.observe(this.thread, { childList: true, subtree: true });
console.log('[Fullchan X] MutationObserver set up.');
if (this.enableNestedQuotes) {
this.thread.addEventListener('click', (event) => this.handleClick(event));
console.log('[Fullchan X] Nested quote click listener added.');
}
} catch (error) {
console.error('[Fullchan X] Observers setup failed:', error);
}
}
updateYous() {
try {
console.log('[Fullchan X] Updating (You)s...');
this.yous = this.posts.filter(post => {
console.log(`[Fullchan X] Checking for .quoteLink.you in post: id=${post.id}`);
const youLink = post.querySelector('.quoteLink.you, .quoteLink[id*="you"]');
if (youLink) {
console.log(`[Fullchan X] Matched (You) in post: id=${post.id}`);
return true;
}
console.log(`[Fullchan X] No (You) found in post: id=${post.id}`);
return false;
});
console.log(`[Fullchan X] Found ${this.yous.length} (You)s`);
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);
});
console.log('[Fullchan X] Updated #my-yous with links.');
} else {
console.warn('[Fullchan X] #my-yous container not found.');
}
if (this.myYousLabel) {
this.myYousLabel.classList.toggle('unseen', this.unseenYous.length > 0);
console.log(`[Fullchan X] Updated .my-yous__label, unseen: ${this.unseenYous.length > 0}`);
} else {
console.warn('[Fullchan X] .my-yous__label not found.');
}
} catch (error) {
console.error('[Fullchan X] Error updating (You)s:', error);
}
}
setUnseenYous() {
try {
console.log('[Fullchan X] Setting unseen (You)s...');
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 format, resetting to []');
storedYous = [];
localStorage.setItem(this.seenKey, JSON.stringify(storedYous));
console.log('[Fullchan X] Cleared invalid seenYous data.');
}
}
console.log(`[Fullchan X] Loaded seenYous: ${storedYous.length}`);
} catch (e) {
console.warn(`[Fullchan X] Error reading seenYous: ${e.message}, using []`);
storedYous = [];
localStorage.setItem(this.seenKey, JSON.stringify(storedYous));
console.log('[Fullchan X] Initialized seenYous to [] due to error.');
}
this.seenYous = storedYous;
this.unseenYous = this.yous.filter(you => !this.seenYous.includes(you.id));
console.log(`[Fullchan X] Unseen (You)s: ${this.unseenYous.length}`);
this.unseenYous.forEach(you => {
if (!you.classList.contains('observe-you')) {
this.observeUnseenYou(you);
}
});
const hasUnseenYous = this.unseenYous.length > 0 || this.pendingSeenYous.length > 0;
document.title = hasUnseenYous
? document.title.startsWith('🔴 ') ? document.title : `🔴 ${document.title}`
: document.title.replace(/^🔴 /, '');
console.log(`[Fullchan X] Title updated, hasUnseenYous: ${hasUnseenYous}`);
} catch (error) {
console.error('[Fullchan X] Error setting unseen (You)s:', error);
}
}
observeUnseenYou(you) {
try {
if (!you) return;
you.classList.add('observe-you');
console.log(`[Fullchan X] Observing unseen (You): id=${you.id}`);
const observer = new IntersectionObserver((entries, observer) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
const id = you.id;
you.classList.remove('observe-you');
console.log(`[Fullchan X] Detected (You) in view: id=${id}`);
if (this.tabFocused) {
console.log(`[Fullchan X] Tab focused, marking (You) as seen: id=${id}`);
if (!this.seenYous.includes(id)) {
this.seenYous.push(id);
try {
localStorage.setItem(this.seenKey, JSON.stringify(this.seenYous));
console.log(`[Fullchan X] Saved seenYous: ${this.seenYous.length}`);
} catch (e) {
console.warn(`[Fullchan X] Failed to save seenYous: ${e.message}`);
}
}
observer.unobserve(you);
console.log(`[Fullchan X] Stopped observing: id=${id}`);
this.updateYous();
} else {
console.log(`[Fullchan X] Tab not focused, added to pendingSeenYous: id=${id}`);
if (!this.pendingSeenYous.includes(id)) {
this.pendingSeenYous.push(id);
}
observer.unobserve(you);
console.log(`[Fullchan X] Stopped observing: id=${id}`);
}
}
});
}, { rootMargin: '0px', threshold: 0.1 });
observer.observe(you);
} catch (error) {
console.error('[Fullchan X] Error observing unseen (You):', error);
}
}
updateVideoLinks() {
try {
console.log('[Fullchan X] Updating video links...');
const links = [
...this.thread.querySelectorAll('a[data-filemime^="video/"]'),
...this.thread.querySelectorAll('a[href*="youtube.com/"], a[href*="youtu.be/"], a[href*="vimeo.com/"], a[href*="streamable.com/"]')
];
this.videoLinks = [...new Set(links)];
console.log(`[Fullchan X] Found ${this.videoLinks.length} video links:`, this.videoLinks.map(l => l.href));
this.replaceVideoLinks();
} catch (error) {
console.error('[Fullchan X] Error updating video links:', error);
}
}
updateAudioLinks() {
try {
this.audioLinks = [...this.thread.querySelectorAll('a[data-filemime^="audio/"]')];
console.log(`[Fullchan X] Found ${this.audioLinks.length} audio links`);
} catch (error) {
console.error('[Fullchan X] Error updating audio links:', error);
}
}
updateImageLinks() {
try {
this.imageLinks = [...this.thread.querySelectorAll('a[data-filemime^="image/"]')];
console.log(`[Fullchan X] Found ${this.imageLinks.length} image links`);
} catch (error) {
console.error('[Fullchan X] Error updating image links:', error);
}
}
async fetchVideoTitle(url) {
try {
// NEW: Check title cache
const cachedTitle = this.getCachedTitle(url);
if (cachedTitle) return cachedTitle;
const urlObj = new URL(url);
let oEmbedUrl;
if (urlObj.hostname.includes('youtube.com') || urlObj.hostname === 'youtu.be') {
oEmbedUrl = `https://www.youtube.com/oembed?url=${encodeURIComponent(url)}&format=json`;
} else if (urlObj.hostname.includes('vimeo.com')) {
oEmbedUrl = `https://vimeo.com/api/oembed.json?url=${encodeURIComponent(url)}`;
} else if (urlObj.hostname.includes('streamable.com')) {
oEmbedUrl = `https://api.streamable.com/oembed.json?url=${encodeURIComponent(url)}`;
} else {
console.log(`[Fullchan X] Skipping non-supported video URL: ${url}`);
return url;
}
console.log(`[Fullchan X] Fetching title for ${url} via ${oEmbedUrl}`);
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 5000);
const response = await fetch(oEmbedUrl, { signal: controller.signal });
clearTimeout(timeoutId);
if (!response.ok) {
throw new Error(`HTTP ${response.status} for ${oEmbedUrl}`);
}
const data = await response.json();
const title = data.title || url;
console.log(`[Fullchan X] Title fetched: ${title}`);
// NEW: Cache the title
this.setCachedTitle(url, title);
return title;
} catch (error) {
console.warn(`[Fullchan X] Failed to fetch title for ${url}:`, error.message);
return url;
}
}
async processApiQueue() {
try {
console.log('[Fullchan X] Processing API queue...');
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));
}
console.log('[Fullchan X] API Queue cleared.');
} catch (error) {
console.error('[Fullchan X] API Queue error:', error);
}
}
async replaceVideoLinks() {
try {
console.log('[Fullchan X] Replacing video link titles...');
for (const link of this.videoLinks) {
try {
const isTrusted = this.isTrustedSource(link.href);
console.log(`[Fullchan X] Checking trust for ${link.href}: ${isTrusted}`);
if (!isTrusted) {
console.log(`[Fullchan X] Skipped untrusted video link: ${link.href}`);
continue;
}
const urlObj = new URL(link.href);
if (
urlObj.hostname.includes('youtube.com') ||
urlObj.hostname === 'youtu.be' ||
urlObj.hostname.includes('vimeo.com') ||
urlObj.hostname.includes('streamable.com')
) {
if (link.dataset.titleProcessed) {
console.log(`[Fullchan X] Skipping already processed link: ${link.href}`);
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;
console.log(`[Fullchan X] Replaced title for ${link.href}: ${title}`);
} else {
console.log(`[Fullchan X] No title change for ${link.href}`);
}
} else {
console.log(`[Fullchan X] Skipped non-supported video link: ${link.href}`);
}
} catch (error) {
console.warn(`[Fullchan X] Error processing video link ${link.href}:`, error);
}
}
} catch (error) {
console.error('[Fullchan X] Error replacing video links:', error);
}
}
isTrustedSource(url) {
try {
// NEW: Check trust cache
const cachedTrust = this.getCachedTrust(url);
if (cachedTrust !== null) return cachedTrust;
const urlObj = new URL(url);
const isTrusted = this.trustedDomains.some(domain =>
urlObj.hostname === domain ||
urlObj.hostname.endsWith(`.${domain}`) ||
urlObj.hostname.includes(domain)
);
console.log(`[Fullchan X] Trust check for ${url}: hostname=${urlObj.hostname}, trusted=${isTrusted}`);
// NEW: Cache trust result
this.setCachedTrust(url, isTrusted);
return isTrusted;
} catch (e) {
console.warn(`[Fullchan X] Invalid URL: ${url}`);
return false;
}
}
addVideoHoverListeners() {
try {
console.log('[Fullchan X] Adding video hover listeners...');
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;
}
});
});
} catch (error) {
console.error('[Fullchan X] Error adding video hover listeners:', error);
}
}
addImageHoverListeners() {
try {
console.log('[Fullchan X] Adding image hover listeners...');
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;
}
});
});
} catch (error) {
console.error('[Fullchan X] Error adding image hover listeners:', error);
}
}
positionPreviewInViewport(element, event) {
try {
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`;
} catch (error) {
console.error('[Fullchan X] Error positioning preview:', error);
}
}
addAudioHoverListeners() {
try {
console.log('[Fullchan X] Adding audio hover listeners...');
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();
}
});
});
} catch (error) {
console.error('[Fullchan X] Error adding audio hover listeners:', error);
}
}
addKeyboardListeners() {
try {
console.log('[Fullchan X] Adding keyboard listeners...');
[...this.videoLinks, ...this.audioLinks, ...this.imageLinks].forEach(link => {
if (!link.tabIndex) {
link.tabIndex = 0;
}
});
} catch (error) {
console.error('[Fullchan X] Error adding keyboard listeners:', error);
}
}
addQuickReplyKeyboardHandlers() {
try {
console.log('[Fullchan X] Setting up quick reply keyboard handlers...');
document.addEventListener('keydown', (event) => {
try {
if (event.key === 'Escape' && this.quickReply && getComputedStyle(this.quickReply).display !== 'none') {
this.quickReply.style.display = 'none';
console.log('[Fullchan X] Quick reply closed via Escape.');
}
} catch (error) {
console.error('[Fullchan X] Keyboard handler error:', error);
}
});
console.log('[Fullchan X] Escape key listener added.');
const qrbody = this.quickReply?.querySelector('#qrbody') || this.quickReply?.querySelector('textarea');
if (!qrbody) {
console.warn('[Fullchan X] #qrbody or textarea not found, skipping spoiler keybind.');
return;
}
console.log('[Fullchan X] #qrbody or textarea found, adding spoiler keybind.');
qrbody.addEventListener('keydown', (event) => {
try {
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 = selectedText ? start + 9 + selectedText.length : start + 9;
qrbody.setSelectionRange(newCursorPos, newCursorPos);
console.log(`[Fullchan X] Added spoiler tags, cursor at: ${newCursorPos}`);
}
} catch (error) {
console.error('[Fullchan X] Spoiler keybind error:', error);
}
});
console.log('[Fullchan X] Ctrl+S key listener added.');
} catch (error) {
console.error('[Fullchan X] Error adding quick reply handlers:', error);
}
}
handleClick(event) {
try {
if (!event.target) return;
const clicked = event.target;
const post = clicked.closest('.innerPost') || clicked.closest('.innerOP');
if (!post) 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) {
event.preventDefault();
event.stopPropagation();
this.nestQuote(nestQuote);
} else if (postMedia && isNested) {
this.handleMediaClick(event, postMedia);
} else if (postId && isNested) {
this.handleIdClick(postId);
}
} catch (error) {
console.error('[Fullchan X] Click handler error:', error);
}
}
handleMediaClick(event, postMedia) {
try {
if (!postMedia || !postMedia.dataset.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) 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);
} catch (error) {
console.error('[Fullchan X] Media click error:', error);
}
}
handleIdClick(postId) {
try {
if (!postId || !this.quickReply) return;
const idNumber = '>>' + postId.textContent;
this.quickReply.style.display = 'block';
const qrbody = this.quickReply.querySelector('#qrbody') || this.quickReply.querySelector('textarea');
if (qrbody) qrbody.value += idNumber + '\n';
} catch (error) {
console.error('[Fullchan X] ID click error:', error);
}
}
assignPostOrder() {
try {
console.log(`[Fullchan X] Assigning post order: ${this.postOrder}`);
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));
}
} catch (error) {
console.error('[Fullchan X] Post order error:', error);
}
}
nestQuote(quoteLink) {
try {
console.log('[Fullchan X] Nesting quote...');
if (!quoteLink) throw new Error('No quoteLink provided.');
const parentPostMessage = quoteLink.closest('.divMessage');
const quoteId = quoteLink.href.split('#')[1];
console.log(`[Fullchan X] Quote ID: ${quoteId}`);
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, [class*="post"]');
console.log(`[Fullchan X] Quote post content: ${quotePostContent ? 'found' : 'not found'}`);
if (!quotePostContent) throw new Error(`Quote post content not found for ${quoteId}.`);
const existing = parentPostMessage.querySelector(`.nestedPost[data-quote-id="${quoteId}"]`);
if (existing) {
existing.remove();
console.log(`[Fullchan X] Removed existing nested quote: ${quoteId}`);
return;
}
const wrapper = document.createElement('div');
wrapper.classList.add('nestedPost');
wrapper.setAttribute('data-quote-id', quoteId);
const clone = quotePostContent.cloneNode(true);
console.log('[Fullchan X] Cloned post content.');
clone.querySelectorAll('.panelBacklinks, .postControls').forEach(el => el.remove());
clone.style.whiteSpace = 'unset';
clone.classList.add('innerNested');
const htmlContent = DOMPurify.sanitize(clone.outerHTML, {
ALLOWED_TAGS: ['div', 'span', 'p', 'a', 'img', 'br', 'strong', 'em', 'blockquote', 'pre', 'code'],
ALLOWED_ATTR: ['href', 'src', 'class', 'style', 'data-filemime', 'data-thumb-src', 'alt', 'title']
});
console.log(`[Fullchan X] Sanitized content length: ${htmlContent.length}`);
if (!htmlContent) throw new Error('Sanitized content is empty.');
wrapper.innerHTML = htmlContent;
parentPostMessage.appendChild(wrapper);
console.log(`[Fullchan X] Nested quote added: ${quoteId}`);
} catch (error) {
console.error('[Fullchan X] Failed to create nested quote:', error.message);
}
}
}
try {
console.log('[Fullchan X] Defining custom element: fullchan-x');
window.customElements.define('fullchan-x', FullChanX);
console.log('[Fullchan X] Custom element defined successfully.');
} catch (error) {
console.error('[Fullchan X] Failed to define custom element:', error);
}
try {
console.log('[Fullchan X] Creating custom element...');
const fcx = document.createElement('fullchan-x');
console.log(`[Fullchan X] Element created, instance of FullChanX: ${fcx instanceof FullChanX}`);
fcx.innerHTML = `
<div class="fcx__controls">
<label for="toggle-controls" style="margin-bottom: 5px;">
<input type="checkbox" id="toggle-controls" ${JSON.parse(localStorage.getItem('fullchanx-controls-visible') || 'true') ? 'checked' : ''}>
Show Controls
</label>
<div class="fcx__controls__inner" style="display: ${JSON.parse(localStorage.getItem('fullchanx-controls-visible') || 'true') ? 'flex' : 'none'}; flex-direction: column; gap: 5px;">
<select id="thread-sort" aria-label="Sort thread posts">
<option value="default">Default</option>
<option value="replies">Replies</option>
<option value="catbox">Catbox</option>
</select>
<label for="low-res-previews" style="margin-top: 5px;">
<input type="checkbox" id="low-res-previews" ${JSON.parse(localStorage.getItem('fullchanx-low-res-previews') || 'false') ? 'checked' : ''}>
Low-Res Video Previews
</label>
<div class="fcx__my-yous" role="region" aria-label="Posts mentioning you">
<p class="my-yous__label">My (You)s</p>
<div class="my-yous__yous" id="my-yous"></div>
</div>
</div>
</div>
`;
document.body.appendChild(fcx);
console.log('[Fullchan X] Custom element appended to body.');
if (fcx instanceof FullChanX) {
fcx.init();
console.log('[Fullchan X] Custom element initialized.');
} else {
console.error('[Fullchan X] Element is not a FullChanX instance, skipping init.');
}
} catch (error) {
console.error('[Fullchan X] Failed to create or initialize custom element:', error);
}
const style = document.createElement('style');
style.innerHTML = `
fullchan-x {
display: block;
position: fixed;
top: 2.5rem;
right: 2rem;
padding: 10px;
background: var(--contrast-color);
border: 1px solid var(--navbar-text-color);
color: var(--link-color);
font-size: 14px;
opacity: 0.5;
z-index: 1000;
}
fullchan-x:hover { opacity: 1; }
.divPosts { flex-direction: column; }
.fcx__controls { display: flex; flex-direction: column; gap: 5px; }
#thread-sort {
padding: 0.4rem 0.6rem;
background: white;
border: none;
border-radius: 0.2rem;
transition: all ease 150ms;
cursor: pointer;
}
#low-res-previews, #toggle-controls {
margin-right: 5px;
}
.my-yous__yous { display: none; flex-direction: column; }
.my-yous__label {
padding: 0.4rem 0.6rem;
background: white;
border: none;
border-radius: 0.2rem;
transition: all ease 150ms;
cursor: pointer;
}
.fcx__my-yous:hover .my-yous__yous { display: flex; }
.innerPost:has(.quoteLink.you) { border-left: solid #dd003e 6px; }
.innerPost:has(.youName) { border-left: solid #68b723 6px; }
.nestedPost {
max-width: 100%;
box-sizing: border-box;
background: var(--contrast-color);
border-radius: 3px;
display: block;
visibility: visible;
}
.divMessage .nestedPost {
display: block;
white-space: normal;
overflow-wrap: anywhere;
margin-top: 0.5em;
margin-bottom: 0.5em;
border: 1px solid var(--navbar-text-color);
padding: 8px;
}
.nestedPost .innerNested {
width: 100%;
max-width: 100%;
display: block;
font-size: 0.95em;
}
.nestedPost .divMessage {
margin: 0;
padding: 0;
white-space: normal;
display: block;
}
.nestedPost .postInfo {
margin-bottom: 5px;
font-size: 0.9em;
display: block;
}
.nestedPost img, .nestedPost .imgLink img {
max-width: 100%;
max-height: 200px;
height: auto;
display: block;
}
.nestedPost .imgLink .imgExpanded {
max-width: 100%;
max-height: 300px;
width: auto;
height: auto;
}
.nestedPost a[data-filemime] {
pointer-events: auto;
display: inline-block;
}
.my-yous__label.unseen {
background: var(--link-hover-color);
color: white;
}
.my-yous__yous .unseen {
font-weight: 900;
color: var(--link-hover-color);
}
a[data-filemime^="video/"]:hover, a[data-filemime^="image/"]:hover, a[data-filemime^="audio/"]:hover {
position: relative;
}
a[data-filemime]:focus {
outline: 2px solid var(--link-hover-color);
}
.audio-preview-indicator {
position: absolute;
background: rgba(0, 0, 0, 0.7);
color: white;
padding: 5px;
font-size: 12px;
border-radius: 3px;
z-index: 1000;
}
`;
try {
document.head.appendChild(style);
console.log('[Fullchan X] Styles appended to head.');
} catch (error) {
console.error('[Fullchan X] Failed to append styles:', error);
}