// ==UserScript==
// @name Fullchan X final v2
// @namespace Violentmonkey Scripts
// @match https://8chan.moe/*/res/*.html*
// @match https://8chan.se/*/res/*.html*
// @grant none
// @version 1.4.16.5 // Optimized v1.4.16.4 with debounced video titles, batched DOM updates, filtered MutationObserver, delegated listeners, quota handling, centralized config
// @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.apiQueue = [];
this.seenYous = [];
this.unseenYous = [];
this.pendingSeenYous = [];
this.tabFocused = document.visibilityState === 'visible';
this.processedVideoLinks = new Set(); // NEW: Track processed video links
// NEW: Centralized configuration
this.config = {
trustedDomains: [
'8chan.moe',
'8chan.se',
'catbox.moe',
'imgur.com',
'youtube.com',
'youtu.be',
'vimeo.com',
'giphy.com',
'streamable.com'
],
titleCache: { key: 'fullchanx-video-titles', ttl: 604800000, maxSize: 500 },
trustCache: { key: 'fullchanx-trusted-urls', ttl: 2592000000, maxSize: 2000 },
api: { maxConcurrentRequests: 3, requestDelay: 200 },
previews: { lowRes: JSON.parse(localStorage.getItem('fullchanx-low-res-previews') || 'false') }
};
// NEW: Selector constants
this.selectors = {
post: '.postCell, [class*="post"]',
youLink: '.quoteLink.you, .quoteLink[id*="you"]',
videoLink: 'a[data-filemime^="video/"], a[href*="youtube.com"], a[href*="youtu.be"], a[href*="vimeo.com"], a[href*="streamable.com"]',
audioLink: 'a[data-filemime^="audio/"]',
imageLink: 'a[data-filemime^="image/"]'
};
}
init() {
try {
console.log('[Fullchan X] Starting initialization...');
this.quickReply = document.querySelector('#quick-reply') || document.querySelector('form[id*="reply"], form');
this.threadParent = document.querySelector('#divThreads') || document.querySelector('[id*="thread"]');
if (!this.threadParent) {
console.error('[Fullchan X] Thread parent not found, exiting init.');
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, exiting init.');
return;
}
this.posts = [...this.thread.querySelectorAll(this.selectors.post)];
console.log(`[Fullchan X] Found ${this.posts.length} posts`);
this.postOrder = 'default';
this.postOrderSelect = this.querySelector('#thread-sort');
this.myYousLabel = this.querySelector('.my-yous__label');
this.yousContainer = this.querySelector('#my-yous');
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';
if (this.tabFocused && this.pendingSeenYous.length > 0) {
console.log(`[Fullchan X] Tab focused, processing ${this.pendingSeenYous.length} pending (You)s`);
this.processPendingYous();
}
});
this.cleanExpiredCache(this.config.titleCache.key, this.config.titleCache.ttl, this.config.titleCache.maxSize);
this.cleanExpiredCache(this.config.trustCache.key, this.config.trustCache.ttl, this.config.trustCache.maxSize);
this.updateYous();
this.updateVideoLinks();
this.updateAudioLinks();
this.updateImageLinks();
this.observers();
this.addMediaHoverListeners();
this.addKeyboardListeners();
this.addQuickReplyKeyboardHandlers();
console.log('[Fullchan X] Initialized successfully.');
} catch (error) {
console.error('[Fullchan X] Initialization failed:', error);
}
}
// NEW: Handle localStorage quota errors
setCachedTitle(url, title) {
try {
let cache = JSON.parse(localStorage.getItem(this.config.titleCache.key) || '{}');
cache[url] = { title, timestamp: Date.now() };
try {
localStorage.setItem(this.config.titleCache.key, JSON.stringify(cache));
console.log(`[Fullchan X] Cached title for ${url}: ${title}`);
} catch (error) {
if (error.name === 'QuotaExceededError') {
console.warn('[Fullchan X] localStorage quota exceeded, clearing oldest title entries');
this.cleanExpiredCache(this.config.titleCache.key, this.config.titleCache.ttl, this.config.titleCache.maxSize / 2);
localStorage.setItem(this.config.titleCache.key, JSON.stringify(cache));
} else {
throw error;
}
}
} catch (error) {
console.warn('[Fullchan X] Error writing to title cache:', error);
}
}
getCachedTitle(url) {
try {
const cache = JSON.parse(localStorage.getItem(this.config.titleCache.key) || '{}');
const entry = cache[url];
if (entry && Date.now() - entry.timestamp < this.config.titleCache.ttl) {
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;
}
}
setCachedTrust(url, isTrusted) {
try {
let cache = JSON.parse(localStorage.getItem(this.config.trustCache.key) || '{}');
cache[url] = { isTrusted, timestamp: Date.now() };
try {
localStorage.setItem(this.config.trustCache.key, JSON.stringify(cache));
console.log(`[Fullchan X] Cached trust for ${url}: ${isTrusted}`);
} catch (error) {
if (error.name === 'QuotaExceededError') {
console.warn('[Fullchan X] localStorage quota exceeded, clearing oldest trust entries');
this.cleanExpiredCache(this.config.trustCache.key, this.config.trustCache.ttl, this.config.trustCache.maxSize / 2);
localStorage.setItem(this.config.trustCache.key, JSON.stringify(cache));
} else {
throw error;
}
}
} catch (error) {
console.warn('[Fullchan X] Error writing to trust cache:', error);
}
}
getCachedTrust(url) {
try {
const cache = JSON.parse(localStorage.getItem(this.config.trustCache.key) || '{}');
const entry = cache[url];
if (entry && Date.now() - entry.timestamp < this.config.trustCache.ttl) {
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;
}
}
cleanExpiredCache(cacheKey, ttl, maxSize) {
try {
let cache = JSON.parse(localStorage.getItem(cacheKey) || '{}');
const now = Date.now();
let entries = Object.entries(cache).filter(([_, entry]) => now - entry.timestamp < ttl);
entries.sort((a, b) => a[1].timestamp - b[1].timestamp);
if (entries.length > maxSize) {
entriesRAND = 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 {
if (this.pendingSeenYous.length === 0) return;
this.seenYous.push(...this.pendingSeenYous);
this.pendingSeenYous = [];
try {
localStorage.setItem(this.seenKey, JSON.stringify(this.seenYous));
} catch (e) {
console.warn(`[Fullchan X] Failed to save seenYous: ${e.message}`);
}
this.updateYous();
} catch (error) {
console.error('[Fullchan X] Error processing pending (You)s:', error);
}
}
observers() {
try {
if (this.postOrderSelect) {
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.config.previews.lowRes = event.target.checked;
localStorage.setItem('fullchanx-low-res-previews', JSON.stringify(this.config.previews.lowRes));
});
}
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';
}
});
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;
}
}
let debounceTimeout;
const observerCallback = (mutationsList) => {
const relevantMutations = mutationsList.filter(mutation =>
mutation.addedNodes.length &&
Array.from(mutation.addedNodes).some(node =>
node.matches?.(this.selectors.post + ', ' + this.selectors.youLink)
)
);
if (!relevantMutations.length) return;
clearTimeout(debounceTimeout);
debounceTimeout = setTimeout(() => {
try {
this.posts = [...this.thread.querySelectorAll(this.selectors.post)];
if (this.postOrder !== 'default') this.assignPostOrder();
this.updateYous();
this.updateVideoLinks();
this.updateAudioLinks();
this.updateImageLinks();
this.addMediaHoverListeners();
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 });
if (this.enableNestedQuotes) {
this.thread.addEventListener('click', (event) => this.handleClick(event));
}
} catch (error) {
console.error('[Fullchan X] Observers setup failed:', error);
}
}
updateYous() {
try {
this.yous = this.posts.filter(post => post.querySelector(this.selectors.youLink));
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) {
const fragment = document.createDocumentFragment();
this.yousLinks.forEach(you => {
const youId = you.textContent.replace('>>', '');
if (!this.seenYous.includes(youId)) {
you.classList.add('unseen');
}
fragment.appendChild(you);
});
this.yousContainer.innerHTML = '';
this.yousContainer.appendChild(fragment);
}
if (this.myYousLabel) {
this.myYousLabel.classList.toggle('unseen', this.unseenYous.length > 0);
}
} catch (error) {
console.error('[Fullchan X] Error updating (You)s:', error);
}
}
setUnseenYous() {
try {
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)) {
storedYous = [];
localStorage.setItem(this.seenKey, JSON.stringify(storedYous));
}
}
} catch (e) {
storedYous = [];
localStorage.setItem(this.seenKey, JSON.stringify(storedYous));
}
this.seenYous = storedYous;
this.unseenYous = this.yous.filter(you => !this.seenYous.includes(you.id));
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(/^🔴 /, '');
} catch (error) {
console.error('[Fullchan X] Error setting unseen (You)s:', error);
}
}
observeUnseenYou(you) {
try {
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.tabFocused) {
if (!this.seenYous.includes(id)) {
this.seenYous.push(id);
try {
localStorage.setItem(this.seenKey, JSON.stringify(this.seenYous));
} catch (e) {
console.warn(`[Fullchan X] Failed to save seenYous: ${e.message}`);
}
}
observer.unobserve(you);
this.updateYous();
} else {
if (!this.pendingSeenYous.includes(id)) {
this.pendingSeenYous.push(id);
}
observer.unobserve(you);
}
}
});
}, { rootMargin: '0px', threshold: 0.1 });
observer.observe(you);
} catch (error) {
console.error('[Fullchan X] Error observing unseen (You):', error);
}
}
updateVideoLinks() {
try {
this.videoLinks = [...new Set(this.thread.querySelectorAll(this.selectors.videoLink))];
this.replaceVideoLinks();
} catch (error) {
console.error('[Fullchan X] Error updating video links:', error);
}
}
updateAudioLinks() {
try {
this.audioLinks = [...this.thread.querySelectorAll(this.selectors.audioLink)];
} catch (error) {
console.error('[Fullchan X] Error updating audio links:', error);
}
}
updateImageLinks() {
try {
this.imageLinks = [...this.thread.querySelectorAll(this.selectors.imageLink)];
} catch (error) {
console.error('[Fullchan X] Error updating image links:', error);
}
}
async fetchVideoTitle(url) {
try {
const cachedTitle = this.getCachedTitle(url);
if (cachedTitle) return cachedTitle;
if (!url || typeof url !== 'string' || !url.startsWith('http')) {
console.warn(`[Fullchan X] Invalid URL: ${url}`);
return url;
}
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 {
return url;
}
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;
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 {
const batchSize = 10;
while (this.apiQueue.length > 0) {
const batch = this.apiQueue.slice(0, batchSize);
const activeRequests = batch.filter(req => req.status === 'pending').length;
if (activeRequests < this.config.api.maxConcurrentRequests) {
const request = batch.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.config.api.requestDelay));
}
} catch (error) {
console.error('[Fullchan X] API Queue error:', error);
}
}
async replaceVideoLinks() {
try {
const fragment = document.createDocumentFragment();
const newLinks = this.videoLinks.filter(link => !this.processedVideoLinks.has(link.href));
for (const link of newLinks) {
if (this.processedVideoLinks.has(link.href)) continue;
this.processedVideoLinks.add(link.href);
if (!this.isTrustedSource(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) 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) {
const newLink = link.cloneNode(true);
newLink.textContent = title;
newLink.title = originalText;
fragment.appendChild(newLink);
link.replaceWith(newLink);
}
}
}
} catch (error) {
console.error('[Fullchan X] Error replacing video links:', error);
}
}
isTrustedSource(url) {
try {
if (!url || typeof url !== 'string' || !url.startsWith('http')) {
console.warn(`[Fullchan X] Invalid URL: ${url}`);
return false;
}
const cachedTrust = this.getCachedTrust(url);
if (cachedTrust !== null) return cachedTrust;
const urlObj = new URL(url);
const isTrusted = this.config.trustedDomains.some(domain =>
urlObj.hostname === domain ||
urlObj.hostname.endsWith(`.${domain}`) ||
urlObj.hostname.includes(domain)
);
this.setCachedTrust(url, isTrusted);
return isTrusted;
} catch (e) {
console.warn(`[Fullchan X] Invalid URL: ${url}`);
return false;
}
}
addMediaHoverListeners() {
try {
console.log('[Fullchan X] Adding media hover listeners...');
const handleHover = (event, type) => {
const link = event.target.closest(this.selectors[type]);
if (!link || link.dataset.hoverProcessed) return;
link.dataset.hoverProcessed = 'true';
link.tabIndex = 0;
let mediaElement = null;
const createMediaElement = (event) => {
if (!this.isTrustedSource(link.href)) return;
if (type === 'videoLink') {
mediaElement = document.createElement('video');
mediaElement.src = link.href;
mediaElement.volume = 0.5;
mediaElement.loop = true;
mediaElement.style.maxWidth = this.config.previews.lowRes ? '480px' : '90vw';
mediaElement.style.maxHeight = this.config.previews.lowRes ? '270px' : '90vh';
mediaElement.addEventListener('loadedmetadata', () => {
mediaElement.style.width = this.config.previews.lowRes ? '480px' : `${mediaElement.videoWidth}px`;
mediaElement.style.height = this.config.previews.lowRes ? '270px' : `${mediaElement.videoHeight}px`;
this.positionPreviewInViewport(mediaElement, event);
});
mediaElement.play().catch(error => {
console.error(`[Fullchan X] Video playback failed for ${link.href}:`, error);
});
} else if (type === 'imageLink') {
mediaElement = document.createElement('img');
mediaElement.src = link.href;
mediaElement.style.maxWidth = '90vw';
mediaElement.style.maxHeight = '90vh';
this.positionPreviewInViewport(mediaElement, event);
} else if (type === 'audioLink') {
mediaElement = document.createElement('audio');
mediaElement.src = link.href;
mediaElement.volume = 0.5;
link.appendChild(mediaElement);
const indicator = document.createElement('div');
indicator.classList.add('audio-preview-indicator');
indicator.textContent = 'â–¶ Playing audio...';
link.appendChild(indicator);
mediaElement.play().catch(error => {
console.error(`[Fullchan X] Audio playback failed for ${link.href}:`, error);
});
return;
}
mediaElement.style.position = 'fixed';
mediaElement.style.zIndex = '1000';
mediaElement.style.pointerEvents = 'none';
document.body.appendChild(mediaElement);
this.positionPreviewInViewport(mediaElement, event);
};
link.addEventListener('mouseenter', createMediaElement);
link.addEventListener('keydown', (event) => {
if (event.key === 'Enter') {
createMediaElement(event);
} else if (event.key === 'Escape' && mediaElement) {
if (type === 'audioLink') {
mediaElement.pause();
mediaElement.currentTime = 0;
const indicator = link.querySelector('.audio-preview-indicator');
if (indicator) indicator.remove();
mediaElement.remove();
mediaElement = null;
} else {
mediaElement.pause?.();
mediaElement.currentTime = 0;
mediaElement.remove();
mediaElement = null;
}
}
});
link.addEventListener('mousemove', (event) => {
if (mediaElement && type !== 'audioLink') {
this.positionPreviewInViewport(mediaElement, event);
}
});
link.addEventListener('mouseleave', () => {
if (mediaElement) {
if (type === 'audioLink') {
mediaElement.pause();
mediaElement.currentTime = 0;
const indicator = link.querySelector('.audio-preview-indicator');
if (indicator) indicator.remove();
mediaElement.remove();
} else {
mediaElement.pause?.();
mediaElement.currentTime = 0;
mediaElement.remove();
}
mediaElement = null;
}
});
link.addEventListener('click', () => {
if (mediaElement) {
if (type === 'audioLink') {
const indicator = link.querySelector('.audio-preview-indicator');
if (indicator) indicator.remove();
mediaElement.remove();
} else {
mediaElement.remove();
}
mediaElement = null;
}
});
};
this.thread.addEventListener('mouseenter', (event) => {
handleHover(event, 'videoLink');
handleHover(event, 'imageLink');
handleHover(event, 'audioLink');
}, true);
} catch (error) {
console.error('[Fullchan X] Error adding media 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);
}
}
addKeyboardListeners() {
try {
[...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 {
document.addEventListener('keydown', (event) => {
if (event.key === 'Escape' && this.quickReply && getComputedStyle(this.quickReply).display !== 'none') {
this.quickReply.style.display = 'none';
}
});
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;
}
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 = selectedText ? start + 9 + selectedText.length : start + 9;
qrbody.setSelectionRange(newCursorPos, newCursorPos);
}
});
} 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 {
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 {
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(this.selectors.post);
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();
return;
}
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');
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']
});
if (!htmlContent) throw new Error('Sanitized content is empty.');
wrapper.innerHTML = htmlContent;
parentPostMessage.appendChild(wrapper);
} catch (error) {
console.error('[Fullchan X] Failed to create nested quote:', error.message);
}
}
}
try {
window.customElements.define('fullchan-x', FullChanX);
} catch (error) {
console.error('[Fullchan X] Failed to define custom element:', error);
}
try {
const fcx = document.createElement('fullchan-x');
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);
if (fcx instanceof FullChanX) {
fcx.init();
} 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);
} catch (error) {
console.error('[Fullchan X] Failed to append styles:', error);
}