Not a member of Pastebin yet?
Sign Up,
it unlocks many cool features!
- // ==UserScript==
- // @name schizophrenia (v5.0)
- // @namespace violentmonkey‑fullchan‑x
- // @match https://8chan.moe/*/res/*.html*
- // @match https://8chan.se/*/res/*.html*
- // @version 5.0‑2025‑04‑28
- // @run-at document-start
- // @description Recursive inlined quotes, single posts-by-ID window, (You)s window, media previews, preloading
- // @author schizo75
- // @grant none
- // ==/UserScript==
- const hiddenFilters = {
- posts: new Set(), // post IDs like '145059', '145001'
- ids: new Set(), // 6-char poster ID hexes like 'ab12cd'
- };
- function prepareToRenderPage() {
- if (!window._fcxObservers) {
- //console.warn('[FCX] No MutationObserver hook installed. Skipping prepareToRenderPage.');
- return;
- }
- const dangerousTargets = new Set([
- document.body,
- document.documentElement,
- document.querySelector('.thread, .divPosts, .threadContainer, .page')
- ].filter(Boolean));
- window._fcxObservers.forEach(({ observer, target }) => {
- if (dangerousTargets.has(target)) {
- //console.warn('[FCX] Disconnecting MutationObserver on:', target);
- try {
- observer.disconnect();
- } catch (err) {
- console.error('[FCX] Failed to disconnect observer:', err);
- }
- }
- });
- console.debug('[FCX] prepareToRenderPage completed.');
- }
- function loadHiddenFilters() {
- try {
- const raw = localStorage.getItem('fcx-hidden-filters');
- if (raw) {
- const data = JSON.parse(raw);
- hiddenFilters.posts = new Set(data.posts || []);
- hiddenFilters.ids = new Set(data.ids || []);
- }
- } catch (err) {
- console.error('[FCX] Failed to load hidden filters', err);
- }
- }
- function saveHiddenFilters(maxPosts = 500, maxIds = 100) {
- try {
- // Trim oldest if limits exceeded
- const posts = [...hiddenFilters.posts].slice(-maxPosts);
- const ids = [...hiddenFilters.ids].slice(-maxIds);
- const data = { posts, ids };
- localStorage.setItem('fcx-hidden-filters', JSON.stringify(data));
- } catch (err) {
- console.error('[FCX] Failed to save hidden filters', err);
- }
- }
- (function() {
- 'use strict';
- function prepareToRenderPage() {
- if (!window._fcxObservers) {
- console.warn('[FCX] No MutationObserver hook installed. Skipping prepareToRenderPage.');
- return;
- }
- const dangerousTargets = new Set([
- document.body,
- document.documentElement,
- document.querySelector('.thread, .divPosts, .threadContainer, .page')
- ].filter(Boolean));
- window._fcxObservers.forEach(({ observer, target }) => {
- if (dangerousTargets.has(target)) {
- console.warn('[FCX] Disconnecting MutationObserver on:', target);
- try {
- observer.disconnect();
- } catch (err) {
- console.error('[FCX] Failed to disconnect observer:', err);
- }
- }
- });
- console.debug('[FCX] prepareToRenderPage completed.');
- }
- function overrideProperty(obj, propName, createFake, blockMessage) {
- let _value;
- Object.defineProperty(obj, propName, {
- configurable: true,
- get() {
- if (!_value) {
- _value = createFake();
- }
- return _value;
- },
- set(val) {
- console.warn(`[FCX] Blocking site from replacing ${propName}`);
- _value = { ...val, ...createFake() };
- }
- });
- }
- function neuterWatcher() {
- try {
- window.watcher = Object.freeze({
- init: () => console.info('[FCX] watcher.init() blocked'),
- updateWatcherCounter: () => console.info('[FCX] watcher.updateWatcherCounter() blocked'),
- iterateWatchedThreads: () => console.info('[FCX] watcher.iterateWatchedThreads() blocked'),
- runWatchedThreadsCheck: () => console.info('[FCX] watcher.runWatchedThreadsCheck() blocked'),
- scheduleWatchedThreadsCheck: () => console.info('[FCX] watcher.scheduleWatchedThreadsCheck() blocked'),
- addWatchedCell: () => console.info('[FCX] watcher.addWatchedCell() blocked'),
- getStoredWatchedData: () => {
- console.info('[FCX] watcher.getStoredWatchedData() blocked');
- return {};
- },
- watchedMenu: null,
- counters: [],
- elementRelation: {},
- isInThread: false,
- watcherAlertCounter: 0
- });
- } catch (err) {
- console.warn('[FCX] Failed to neuter watcher.js', err);
- }
- }
- // --- Start execution ---
- prepareToRenderPage();
- overrideProperty(window, 'hiding', () => ({
- isFiltered: () => false,
- isQuoteFiltered: () => false,
- checkFilterHiding: () => {}
- }));
- neuterWatcher();
- })();
- // ✅ Utility query selectors for brevity
- const QS = (s, r = document) => r.querySelector(s);
- const QSA = (s, r = document) => [...r.querySelectorAll(s)];
- // ✅ Normalizes post IDs (6-digit hex string)
- const idNorm = s => (s.match(/^[0-9a-f]{6}/i) || [''])[0].toLowerCase();
- // ✅ Site-specific constants
- const HOST = location.hostname;
- const OK_MIME = /^(video\/(mp4|webm)|image\/(png|jpe?g|gif)|audio\/mpeg)$/i;
- const OK_EXT = /\.(mp4|webm|png|jpe?g|gif|mp3)$/i;
- const PANEL_SEL = '#fcx-yous, [id^="fcx-ids-"]';
- const MAX_FAILURES = 4;
- const MAX_LOAD_TIME = 30000; // 30 seconds
- const singlePostIdMap = new Map(); // idHex -> { postId, count }
- const idUsageCountMap = new Map(); // idHex -> count
- const timeouts = new Set();
- const intervals = new Set();
- const STYLE_PANEL_BASE = {
- position: 'fixed',
- background: 'var(--contrast-color)',
- color: 'var(--link-color)',
- border: '1px solid var(--navbar-text-color)',
- padding: '6px',
- minWidth: '160px',
- maxWidth: '90vw',
- zIndex: 9999,
- transition: 'width 0.2s ease, height 0.2s ease',
- font: '14px/1 sans-serif'
- };
- const STYLE_SPINNER = {
- position: 'absolute',
- top: '0',
- left: '0',
- zIndex: 10000,
- fontSize: '14px',
- color: 'white',
- background: 'rgba(0,0,0,0.6)',
- padding: '2px 6px',
- borderRadius: '4px',
- pointerEvents: 'none'
- };
- const STYLE_HOVER_MEDIA = {
- position: 'fixed',
- left: '0px',
- top: '0px',
- maxWidth: '90vw',
- maxHeight: '90vh',
- zIndex: 10000,
- pointerEvents: 'none'
- };
- // ✅ Initialize preload tracking
- const FAILURE_EXPIRY_MS = 5 * 60 * 1000;
- const preloadCache = new Map();
- const preloadFailures = new Map();
- const mediaMap = new Map();
- const preloadQueue = [];
- let activePreloads = 0;
- const MAX_CONCURRENT = 10;
- // 🔥 Extra memory cleanup
- function fullMemoryReset() {
- try {
- if (this._idleInterval) {
- clearInterval(this._idleInterval);
- this._idleInterval = null;
- }
- if (this._idleListeners) {
- this._idleListeners.forEach(({ evt, handler }) => {
- window.removeEventListener(evt, handler);
- });
- this._idleListeners = null;
- }
- if (this.io) {
- this.io.disconnect();
- this.io = null;
- }
- // 🧹 Destroy preloadCache entries
- for (const el of preloadCache.values()) {
- safeDestroy(el);
- }
- preloadCache.clear();
- // 🧹 Destroy mediaMap entries
- for (const data of mediaMap.values()) {
- safeDestroy(data?.img);
- safeDestroy(data?.media);
- }
- mediaMap.clear();
- // 🧹 Clear preload queue
- preloadQueue.length = 0;
- activePreloads = 0;
- // 🧹 Clear Maps
- idUsageCountMap.clear();
- singlePostIdMap.clear();
- recentQuoteStore.clear();
- timeouts.clear();
- intervals.clear();
- // 🧹 Clear postElements if any
- this.postElements = [];
- this.seen = [];
- // 🧹 Wipe all posts
- if (typeof this.existingPosts !== 'undefined') {
- for (const key in this.existingPosts) {
- safeDestroy(this.existingPosts[key]);
- }
- Object.keys(this.existingPosts).forEach(k => delete this.existingPosts[k]);
- }
- // 🧹 Wipe knownPosts too
- if (typeof this.knownPosts !== 'undefined') {
- for (const key in this.knownPosts) {
- safeDestroy(this.knownPosts[key]);
- }
- Object.keys(this.knownPosts).forEach(k => delete this.knownPosts[k]);
- }
- // 🧹 Wipe quoteMap if exists
- if (typeof this.quoteMap !== 'undefined') {
- this.quoteMap.clear?.();
- }
- // 🧹 Optionally clear any backlinks UI if separately stored
- QSA('.panelBacklinks, .altBacklinks').forEach(safeDestroy);
- } catch (err) {
- console.error('[FCX] Memory reset error:', err);
- }
- }
- // ✅ Returns the number of recent failures for a given URL
- // If too much time has passed, the failure record is cleared
- function getFailureCount(url) {
- const entry = preloadFailures.get(url); // { count, lastFail }
- if (!entry) return 0;
- // Expired? Clear and return 0
- if (Date.now() - entry.lastFail > FAILURE_EXPIRY_MS) {
- preloadFailures.delete(url);
- return 0;
- }
- return entry.count;
- }
- // ✅ Queues a media file (image, video, etc.) for preload if eligible
- function queuePreloadImage(url, mime = '', onDone, priority = 'low', distance = Infinity) {
- // 🛑 Too many recent failures? Abort.
- if (getFailureCount(url) >= MAX_FAILURES) return;
- // 🛑 Prevent duplicate URLs in the queue
- if (preloadQueue.some(q => q.url === url)) return;
- // ✅ Skip if already cached
- const cached = preloadCache.get(url);
- if (cached && cached._fcxReady) {
- onDone?.(); // Gracefully call the callback even if skipped
- return;
- }
- // ✅ Limit the queue size based on user settings
- const limit = fcxInstance?.settings?.preloadPosts ?? 20;
- if (preloadQueue.length >= limit) {
- const idx = preloadQueue.findIndex(q => q.priority === 'low');
- if (priority === 'high' || idx === -1) return; // Can’t bump anything
- preloadQueue.splice(idx, 1); // Drop a low-priority task to make room
- }
- // 🧪 Queue the request for processing
- preloadQueue.push({
- url,
- mime: mime.toLowerCase(),
- onDone,
- priority,
- distance
- });
- // 🔼 Sort to prioritize closest items (based on scroll distance)
- preloadQueue.sort((a, b) => a.distance - b.distance);
- // 🕒 Schedule processing (as soon as possible)
- const timeout = setTimeout(processPreloadQueue, 0);
- timeouts.add(timeout);
- }
- // ✅ Displays a temporary error tooltip when media fails to load
- function showHoverLoadError(anchor) {
- if (!anchor?.isConnected) return; // 🛑 Don't show error on dead anchor
- if (anchor._hoverErrorMsg) return;
- const rect = anchor.getBoundingClientRect();
- const msg = document.createElement('div');
- msg.className = 'fcx-hover-error';
- msg.textContent = '⚠️ Failed to load media ⚠️';
- Object.assign(msg.style, {
- position: 'absolute',
- top: `${rect.bottom + window.scrollY + 6}px`,
- left: `${rect.left + window.scrollX}px`,
- background: 'rgba(0, 0, 0, 0.85)',
- color: 'white',
- padding: '4px 8px',
- fontSize: '13px',
- borderRadius: '4px',
- zIndex: 10000,
- pointerEvents: 'none',
- maxWidth: '240px'
- });
- document.body.appendChild(msg);
- anchor._hoverErrorMsg = msg;
- const remove = () => {
- msg.remove();
- delete anchor._hoverErrorMsg;
- anchor.removeEventListener('mouseleave', remove);
- };
- anchor.addEventListener('mouseleave', remove, { once: true });
- }
- setInterval(() => {
- // Clean hover previews
- QSA('img[data-fcxHover], video[data-fcxHover], audio[data-fcxHover]').forEach(el => {
- if (!document.contains(el)) {
- safeDestroy(el);
- }
- });
- // Clean manual fallback videos
- QSA('video[data-fcxManual]').forEach(el => {
- if (!document.contains(el)) {
- safeDestroy(el);
- }
- });
- // Clean detached backlinks
- QSA('span.panelBacklinks, span.altBacklinks').forEach(el => {
- if (!document.contains(el)) {
- safeDestroy(el);
- }
- });
- // Clean stray detached .postCell (rare)
- QSA('div.postCell').forEach(el => {
- if (!document.contains(el)) {
- safeDestroy(el);
- }
- });
- }, 20000);
- // ✅ Shortens long original filenames in download links (while preserving extension)
- function shortenOriginalFilenames(root = document) {
- // 📦 Select all target links inside the given root element
- const links = root.querySelectorAll('a.originalNameLink');
- links.forEach(link => {
- // 🏷️ Get the full filename (from 'download' attr or link text)
- const fullName = link.getAttribute('download') || link.textContent;
- // ❌ If there's no extension, skip this link
- const dotIndex = fullName.lastIndexOf('.');
- if (dotIndex === -1) return;
- // 🧩 Split filename into base and extension
- const name = fullName.slice(0, dotIndex);
- const ext = fullName.slice(dotIndex);
- // ✂️ Truncate the name if too long, append ellipsis
- const shortName = name.length > 10
- ? name.slice(0, 9) + '…' + ext
- : name + ext;
- // ✅ Only update if it's different from what's already shown
- if (link.textContent !== shortName) {
- link.textContent = shortName;
- // 💅 Dynamically set maxWidth based on estimated character width
- link.style.maxWidth = `${shortName.length * 0.65}em`;
- }
- });
- }
- // ✅ Check if an element is visible in the current viewport
- function isInViewport(el) {
- const rect = el?.getBoundingClientRect?.();
- return rect && rect.top < window.innerHeight && rect.bottom > 0;
- }
- // ✅ Determine whether an anchor should be protected from eviction
- function isProtected(anchor) {
- if (!anchor) return false;
- return isInViewport(anchor) || anchor.dataset._fcxHoverActive === 'true';
- }
- // ✅ Evicts old media entries from preloadCache and mediaMap if limits are hit
- function evictFromCacheAndMediaMap(maxSize) {
- // 🔁 First, evict from preloadCache if over limit
- if (preloadCache.size >= maxSize) {
- for (const [url, el] of preloadCache.entries()) {
- try {
- el?.pause?.(); // Stop video/audio if playing
- el?.remove?.(); // Remove from DOM if needed
- } catch {}
- preloadCache.delete(url); // Remove from cache
- break; // Only remove one item per call
- }
- }
- // 🔁 Then, evict from mediaMap if it exceeds configured limit
- const limit = fcxInstance?.settings?.mediaCacheSize ?? 50;
- while (mediaMap.size >= limit) {
- for (const [anchor] of mediaMap.entries()) {
- if (!anchor?.isConnected || !isProtected(anchor)) {
- cleanupHoverMedia(anchor);
- mediaMap.delete(anchor);
- break; // Remove one per call
- }
- }
- }
- }
- // ✅ Processes media preload tasks from the queue, with throttling and error handling
- function processPreloadQueue() {
- if (activePreloads >= MAX_CONCURRENT || preloadQueue.length === 0) return;
- const maxSize = fcxInstance?.settings?.mediaCacheSize ?? 32;
- let tasksProcessed = 0;
- while (activePreloads < MAX_CONCURRENT && preloadQueue.length > 0) {
- const { url, mime, onDone } = preloadQueue.shift();
- // 🛑 Guard: skip if too many failures
- if (getFailureCount(url) >= MAX_FAILURES) {
- onDone?.(false);
- continue;
- }
- activePreloads++;
- preloadCache.set(url, { _fcxReady: false, preloadInProgress: true });
- let resolved = false;
- let timeoutId;
- const resolve = (el, success = true) => {
- if (resolved) return;
- resolved = true;
- clearTimeout(timeoutId);
- activePreloads--;
- if (!success) {
- preloadCache.delete(url);
- preloadFailures.set(url, {
- count: getFailureCount(url) + 1,
- lastFail: Date.now()
- });
- } else {
- if (el) {
- el.dataset.fcxHover = 'true';
- el._fcxReady = true;
- preloadCache.set(url, el);
- }
- preloadFailures.delete(url);
- }
- onDone?.(success);
- };
- if (mime.startsWith('image/')) {
- const img = new Image();
- img.src = url;
- timeoutId = setTimeout(() => {
- console.warn('[FCX] Image preload timeout:', url);
- resolve(img, false);
- }, MAX_LOAD_TIME);
- timeouts.add(timeoutId);
- img.onload = () => resolve(img, true);
- img.onerror = () => resolve(img, false);
- img.decode?.().catch(() => {}); // ignore decode errors
- } else if (mime.startsWith('video/') || mime.startsWith('audio/')) {
- const el = document.createElement(mime.startsWith('video/') ? 'video' : 'audio');
- el.src = url;
- el.loop = true;
- el.muted = true;
- el.volume = 0;
- el.preload = 'auto';
- el.style.display = 'none';
- let attempts = 0;
- const cleanup = () => {
- el.onloadeddata = null;
- el.onerror = null;
- };
- timeoutId = setTimeout(() => {
- console.warn('[FCX] Media preload timeout:', url);
- cleanup();
- resolve(el, false);
- }, MAX_LOAD_TIME);
- timeouts.add(timeoutId);
- const tryLoad = () => {
- el.onloadeddata = () => {
- cleanup();
- resolve(el, true);
- };
- el.onerror = () => {
- cleanup();
- attempts++;
- if (attempts > 1) {
- resolve(el, false);
- } else {
- el.load();
- tryLoad();
- }
- };
- el.load();
- };
- tryLoad();
- } else if (OK_MIME.test(mime) || OK_EXT.test(url)) {
- // Fallback fetch (we can't directly create media elements)
- const controller = new AbortController();
- timeoutId = setTimeout(() => {
- controller.abort();
- console.warn('[FCX] Fetch timeout for', url);
- resolve({}, false);
- }, MAX_LOAD_TIME);
- timeouts.add(timeoutId);
- fetch(url, { mode: 'no-cors', signal: controller.signal })
- .then(() => resolve({}, true))
- .catch(() => resolve({}, false));
- } else {
- // Unsupported file type → skip cleanly
- resolve({}, true);
- }
- tasksProcessed++;
- }
- // 🕒 Only reschedule if more tasks exist
- if (preloadQueue.length > 0 && activePreloads < MAX_CONCURRENT) {
- this._request4 = requestIdleCallback(processPreloadQueue, { timeout: 100 });
- }
- }
- window.addEventListener('error', function(e) {
- const message = e?.message || '';
- if (
- message.includes('interfaceUtils.shownMenu.remove') ||
- message.includes('settingsMenu.init') ||
- message.includes('Cannot read properties of null (reading \'cloneNode\')') ||
- message.includes('tooltips.addAltBacklinksAndReplyPreview') ||
- message.includes('posting.parseExistingPost') ||
- message.includes('posting.addPost')
- ) {
- // console.info('[FCX] Suppressed expected native error:', message);
- e.preventDefault?.(); // Try prevent
- return true; // Block it
- }
- });
- if (!window.posting) window.posting = {};
- // ✅ Patch posting.yous to a real array
- if (!Array.isArray(window.posting.yous)) {
- window.posting.yous = [];
- }
- let myPosterId = null;
- function detectMyPosterIdFromYous() {
- if (!Array.isArray(window.posting?.yous)) return null;
- // Check from oldest to newest
- for (const postNum of window.posting.yous) {
- const post = fcxInstance?.threadJson?.find(p => p.postId == postNum);
- if (post && post.id && post.id.length === 6) {
- console.debug('[FCX] Detected your poster ID:', post.id.toLowerCase());
- return post.id.toLowerCase();
- }
- }
- return null;
- }
- function markQuotesToYou(postEl = null) {
- if (!myPosterId) return;
- const posts = postEl ? [postEl] : QSA('.postCell, .opCell, .post').filter(p => p.querySelector('a.quoteLink, a.quotelink'));
- for (const post of posts) {
- const quotes = post.querySelectorAll('a.quoteLink, a.quotelink');
- for (const quote of quotes) {
- const idMatch = quote.textContent.match(/^>>(\d+)$/);
- if (!idMatch) continue;
- const quotedPostId = idMatch[1];
- const quotedPost = fcxInstance?.threadJson?.find(p => String(p.postId) === quotedPostId);
- if (!quotedPost) continue;
- if (quotedPost.id?.toLowerCase() === myPosterId) {
- if (!quote.classList.contains('you')) {
- quote.classList.add('you');
- }
- }
- }
- }
- }
- // ✅ Cleans up media elements, listeners, cache entries, and DOM artifacts tied to a hovered anchor
- function cleanupHoverMedia(anchor) {
- if (!anchor) return;
- const url = anchor.href;
- const data = mediaMap.get(anchor);
- try {
- safeDestroy(data?.img);
- safeDestroy(data?.media);
- safeDestroy(data?.spinner);
- } catch {
- // Ignore
- }
- mediaMap.delete(anchor);
- delete anchor._preloaded;
- delete anchor._prev;
- delete anchor.dataset._fcxHoverActive;
- if (anchor._hoverErrorMsg) {
- safeDestroy(anchor._hoverErrorMsg);
- delete anchor._hoverErrorMsg;
- }
- }
- function drag(el, grip = el) {
- let sx, sy, ox, oy, moved = false;
- grip.style.cursor = 'move';
- grip.onmousedown = e => {
- sx = e.clientX; sy = e.clientY;
- ({ left: ox, top: oy } = el.getBoundingClientRect());
- const move = ev => {
- const dx = ev.clientX - sx, dy = ev.clientY - sy;
- if (Math.abs(dx) + Math.abs(dy) > 2) moved = true;
- el.style.left = ox + dx + 'px';
- el.style.top = oy + dy + 'px';
- };
- const up = () => {
- window.removeEventListener('mousemove', move);
- if (!moved) grip.dispatchEvent(new CustomEvent('tap'));
- moved = false;
- };
- window.addEventListener('mousemove', move);
- window.addEventListener('mouseup', up, { once: true });
- };
- }
- function fit(box) {
- const wrap = box.querySelector('.fcx-id-posts');
- if (!wrap) return;
- // ✅ Temporarily override flex for measurement
- wrap.style.display = 'inline-block';
- wrap.style.flexDirection = 'column';
- wrap.style.alignItems = 'start';
- wrap.style.maxWidth = 'none';
- this._aRaf10 = requestAnimationFrame(() => {
- const rect = wrap.getBoundingClientRect();
- box.style.width = Math.min(rect.width + 32, window.innerWidth * 0.9) + 'px';
- box.style.height = rect.height + 64 + 'px';
- });
- }
- function injectMediaFallback(anchor) {
- const url = anchor.href;
- const mime = anchor.dataset.filemime;
- // Create video element
- const video = document.createElement('video');
- video.src = url;
- video.controls = false;
- video.autoplay = true;
- video.loop = true;
- video.style.maxWidth = '90vw';
- video.style.maxHeight = '90vh';
- video.style.display = 'block';
- video.style.marginTop = '8px';
- video.dataset.fcxManual = 'true'; // Tag it as manual fix
- // Optional: remove broken sibling node if any
- const siblings = anchor.parentElement?.querySelectorAll('video, audio');
- siblings?.forEach(el => el.src = '');
- siblings?.forEach(el => el.remove());
- // Insert right after the anchor
- anchor.parentNode.insertBefore(video, anchor.nextSibling);
- }
- function cleanupHoverQuote() {
- QSA('.fcx-hover-quote').forEach(safeDestroy);
- QSA('a[data-fcx-hover-quote]').forEach(a => {
- delete a._fcxHoverQuote;
- });
- }
- function pruneCachesByDistance() {
- const limit = fcxInstance?.settings?.mediaCacheSize ?? 32;
- if (preloadCache.size <= limit) return;
- const viewportCenter = window.scrollY + window.innerHeight / 2;
- // Map URL -> approximate distance from viewport center
- const urlDistances = new Map();
- const anchors = QSA('a[data-filemime]');
- for (let anchor of anchors) {
- const post = anchor.closest('.post, .postCell, .opCell');
- if (!post) continue;
- const rect = post.getBoundingClientRect();
- const postCenter = window.scrollY + rect.top + rect.height / 2;
- const dist = Math.abs(postCenter - viewportCenter);
- urlDistances.set(anchor.href, dist);
- }
- const sorted = [...preloadCache.entries()].map(([url, el]) => ({
- url,
- el,
- dist: urlDistances.get(url) ?? Infinity // If no anchor, assume far away
- })).sort((a, b) => b.dist - a.dist); // Farthest first
- // Evict farthest entries first
- while (preloadCache.size > limit && sorted.length) {
- const { url, el } = sorted.shift();
- try {
- el.pause?.();
- el.src = '';
- el.removeAttribute('src');
- el.load?.(); // forces to stop fetching/loading
- el.remove?.();
- } catch {}
- preloadCache.delete(url);
- }
- }
- function prunePreloadCache(centerPostIndex, limit = 50) {
- const threadJson = window.fcxInstance?.threadJson;
- if (!threadJson?.length || preloadCache.size <= limit) return;
- // 1. Map file URLs to their post index once
- const urlToPostIndex = new Map();
- for (let i = 0; i < threadJson.length; i++) {
- const post = threadJson[i];
- if (post?.fileUrl) {
- urlToPostIndex.set(post.fileUrl, i);
- }
- }
- // 2. Rank preloadCache entries by distance from center
- const distances = [...preloadCache.entries()].map(([url, el]) => {
- const postIndex = urlToPostIndex.get(url);
- const dist = typeof postIndex === 'number' ? Math.abs(centerPostIndex - postIndex) : Infinity;
- return { url, el, dist };
- });
- distances.sort((a, b) => b.dist - a.dist); // Farthest first
- // 3. Remove entries beyond limit
- while (preloadCache.size > limit && distances.length) {
- const { url, el } = distances.shift();
- try {
- el.pause?.();
- el.src = '';
- el.removeAttribute('src');
- el.load?.(); // forces to stop fetching/loading
- el.remove?.();
- } catch {}
- preloadCache.delete(url);
- }
- }
- let smartPreloadQueued = false;
- function maybeTriggerSmartPreload(doSmartPreloadFn, scrollDir = 'down', lookahead = 50) {
- if (smartPreloadQueued || preloadQueue.length > 0 || activePreloads >= MAX_CONCURRENT) return;
- smartPreloadQueued = true;
- this._request3 = requestIdleCallback(() => {
- smartPreloadQueued = false;
- doSmartPreloadFn(scrollDir, lookahead);
- }, { timeout: 200 });
- }
- function scanForSinglePosters(newPost = null) {
- if (!fcxInstance?.settings?.hideSinglePosters) return;
- if (newPost) {
- // 👉 Update maps for a single post
- const id = idNorm(newPost.id);
- if (!id) return;
- const newCount = (idUsageCountMap.get(id) || 0) + 1;
- idUsageCountMap.set(id, newCount);
- if (newCount === 1) {
- // First time seeing this ID
- singlePostIdMap.set(id, { postId: `p${newPost.postId}`, count: 1 });
- const postEl = document.getElementById(`p${newPost.postId}`);
- if (postEl) postEl.style.display = 'none';
- } else if (newCount === 2) {
- // Second time → unhide the first post
- const entry = singlePostIdMap.get(id);
- if (entry) {
- const firstPost = document.getElementById(entry.postId);
- if (firstPost) firstPost.style.display = '';
- singlePostIdMap.delete(id);
- }
- }
- } else {
- // 👉 Full rescan (e.g., after full page render)
- idUsageCountMap.clear();
- singlePostIdMap.clear();
- // 🚀 Use threadJson directly instead of DOM
- for (const post of fcxInstance?.threadJson || []) {
- const id = idNorm(post.id);
- if (!id) continue;
- const newCount = (idUsageCountMap.get(id) || 0) + 1;
- idUsageCountMap.set(id, newCount);
- if (newCount === 1) {
- singlePostIdMap.set(id, { postId: `p${post.postId}`, count: 1 });
- } else if (newCount === 2) {
- singlePostIdMap.delete(id);
- }
- }
- // Now hide single posters
- for (const [id, { postId }] of singlePostIdMap.entries()) {
- const postEl = document.getElementById(postId);
- if (postEl) postEl.style.display = 'none';
- }
- }
- }
- function pruneMediaMapByDistance() {
- const limit = fcxInstance?.settings?.mediaCacheSize ?? 50;
- if (mediaMap.size <= limit) return;
- const viewportCenter = window.scrollY + window.innerHeight / 2;
- const entries = [...mediaMap.entries()].map(([anchor, data]) => {
- if (!anchor?.isConnected || isProtected(anchor)) return null;
- const post = anchor.closest('.post, .postCell, .opCell');
- if (!post) return null;
- const rect = post.getBoundingClientRect();
- const postCenter = window.scrollY + rect.top + rect.height / 2;
- const dist = Math.abs(postCenter - viewportCenter);
- return { anchor, data, dist };
- }).filter(Boolean);
- entries.sort((a, b) => b.dist - a.dist); // Farthest first
- while (mediaMap.size > limit && entries.length) {
- const { anchor } = entries.shift();
- cleanupHoverMedia(anchor);
- mediaMap.delete(anchor);
- }
- }
- function parseQuoteHref(link) {
- const href = link?.getAttribute('href') || '';
- // Match /board/res/2152.html#113855
- const cross = href.match(/\/res\/(\d+)\.html#(\d+)/);
- if (cross) return { threadId: cross[1], postId: cross[2] };
- // Match #113855 or #q113855
- const local = href.match(/#q?(\d+)/);
- if (local) return { threadId: null, postId: local[1] };
- return null;
- }
- const recentQuoteStore = new Map(); // key: `${threadId}-${postId}`, value: { postData, timestamp }
- function cacheCrossPost(threadId, postId, postData) {
- const key = `${threadId}-${postId}`;
- if (!postData) return;
- // Update or insert
- recentQuoteStore.set(key, { postData, timestamp: Date.now() });
- // Trim if over 10 entries
- if (recentQuoteStore.size > 10) {
- const oldest = [...recentQuoteStore.entries()].sort((a, b) => a[1].timestamp - b[1].timestamp)[0];
- if (oldest) recentQuoteStore.delete(oldest[0]);
- }
- }
- function getCachedCrossPost(threadId, postId) {
- const key = `${threadId}-${postId}`;
- return recentQuoteStore.get(key)?.postData || null;
- }
- function buildHideMenu(postElement) {
- const postId = postElement.id.replace(/^p/, '');
- const idLabel = postElement.querySelector('.labelId');
- const idHex = idLabel?.textContent?.trim()?.toLowerCase() || '';
- return [
- {
- name: 'Hide post',
- callback: () => hidePost(postElement)
- },
- {
- name: 'Hide post+',
- callback: () => hidePostPlus(postElement)
- },
- {
- name: 'Hide ID',
- callback: () => hideId(idHex)
- },
- {
- name: 'Hide ID+',
- callback: () => hideIdPlus(idHex)
- },
- ];
- }
- function renderOP(op) {
- const wrap = document.createElement('div');
- wrap.className = 'innerOP';
- // ===== Upload Panel =====
- if (op.files?.length) {
- const panelUploads = document.createElement('div');
- panelUploads.className = 'panelUploads opUploadPanel';
- const figure = document.createElement('figure');
- figure.className = 'uploadCell';
- const details = document.createElement('details');
- details.open = true;
- const summary = document.createElement('summary');
- const uploadDetails = document.createElement('div');
- uploadDetails.className = 'uploadDetails';
- const file = op.files[0];
- const fileLink = document.createElement('a');
- fileLink.className = 'nameLink coloredIcon';
- fileLink.href = file.path;
- fileLink.target = '_blank';
- uploadDetails.appendChild(fileLink);
- const hideButton = document.createElement('span');
- hideButton.className = 'hideFileButton glowOnHover coloredIcon';
- uploadDetails.appendChild(hideButton);
- uploadDetails.insertAdjacentHTML('beforeend', `
- <span class="hideMobile">(</span>
- <span class="sizeLabel">${(file.size / 1024).toFixed(2)} KB</span>
- <span class="dimensionLabel">${file.width}x${file.height}</span>
- <a class="originalNameLink" href="${file.path}" download="${file.originalName}">${file.originalName}</a>
- <span class="hideMobile">)</span>
- `);
- summary.appendChild(uploadDetails);
- const blankDiv = document.createElement('div');
- summary.appendChild(blankDiv);
- details.appendChild(summary);
- const imgLink = document.createElement('a');
- imgLink.className = 'imgLink';
- imgLink.href = file.path;
- imgLink.target = '_blank';
- imgLink.dataset.filewidth = file.width;
- imgLink.dataset.fileheight = file.height;
- imgLink.dataset.filemime = file.mime;
- const img = document.createElement('img');
- img.loading = 'lazy';
- img.src = file.thumb;
- img.width = Math.floor(file.width * 0.6); // Adjust scaling
- img.height = Math.floor(file.height * 0.6);
- imgLink.appendChild(img);
- details.appendChild(document.createElement('br'));
- details.appendChild(imgLink);
- figure.appendChild(details);
- panelUploads.appendChild(figure);
- wrap.appendChild(panelUploads);
- }
- // ===== OP Head / Title =====
- const opHead = document.createElement('div');
- opHead.className = 'opHead title';
- opHead.innerHTML = `
- <input type="checkbox" class="deletionCheckBox" name="vyt-${op.threadId}">
- <label class="hideButton glowOnHover coloredIcon" title="Hide"></label>
- <label class="extraMenuButton glowOnHover coloredIcon" title="Post Menu"></label>
- <span class="watchButton glowOnHover coloredIcon" title="Watch Thread"></span>
- <span class="labelSubject">${op.subject || ''}</span>
- <a class="linkName noEmailName">${op.name || 'Anonymous'}</a>
- <span class="labelCreated">${new Date(op.creation).toLocaleString()}</span>
- <span class="spanId">Id: <span class="labelId" style="background-color: #${op.id || '888888'}">${op.id || ''}</span></span>
- <a class="linkSelf" href="#${op.threadId}">No.</a>
- <a class="linkQuote" href="#q${op.threadId}">${op.threadId}</a>
- <span class="panelBacklinks"></span>
- `;
- wrap.appendChild(opHead);
- // ===== Message Body =====
- const divMessage = document.createElement('div');
- divMessage.className = 'divMessage';
- divMessage.innerHTML = op.markdown || op.message || '';
- wrap.appendChild(divMessage);
- // ===== Alt Backlinks / Reply Preview placeholders =====
- const altBacklinks = document.createElement('div');
- altBacklinks.className = 'altBacklinks';
- altBacklinks.innerHTML = `<span class="panelBacklinks"></span>`;
- wrap.appendChild(altBacklinks);
- const replyPreview = document.createElement('div');
- replyPreview.className = 'replyPreview';
- wrap.appendChild(replyPreview);
- return wrap;
- }
- function hidePost(post) {
- if (!post) return;
- const postId = post.id.replace(/^p/, '');
- hiddenFilters.posts.add(postId); // ✅ Remember this post is hidden
- saveHiddenFilters(); // ✅ Save
- const board = location.pathname.split('/')[1] || '';
- const unhide = document.createElement('span');
- unhide.className = 'unhideButton glowOnHover';
- unhide.textContent = `[Unhide post /${board}/${postId}]`;
- unhide.style.cursor = 'pointer';
- unhide.onclick = () => {
- hiddenFilters.posts.delete(postId); // ✅ Unhide = forget hidden post
- saveHiddenFilters(); // ✅ Save
- unhide.replaceWith(post);
- post.style.display = ''; // show post again
- };
- post.replaceWith(unhide);
- }
- function hidePostPlus(postElement) {
- if (!postElement) return;
- // First, hide the main post
- hideSinglePost(postElement);
- // Then, look for its backlinks
- const backlinksContainer = postElement.querySelector('.panelBacklinks, .altBacklinks');
- if (!backlinksContainer) return;
- const links = backlinksContainer.querySelectorAll('a[href^="#"]');
- for (const link of links) {
- const replyId = link.getAttribute('href').substring(1); // Remove the '#'
- const replyPost = document.getElementById(replyId);
- if (replyPost) {
- hideSinglePost(replyPost);
- }
- }
- }
- function hideSinglePost(postElement) {
- postElement.style.display = 'none';
- const postId = postElement.id || 'unknown';
- const unhideStub = document.createElement('span');
- unhideStub.className = 'unhideButton glowOnHover';
- unhideStub.textContent = `[Unhide post /${postId}]`;
- unhideStub.onclick = () => {
- postElement.style.display = '';
- unhideStub.remove();
- };
- postElement.parentNode.insertBefore(unhideStub, postElement);
- }
- function hideId(idHex) {
- if (!idHex) return;
- hiddenFilters.ids.add(idHex); // ✅ Remember this ID
- saveHiddenFilters(); // ✅ Save
- const posts = QSA('.postCell, .opCell, .post')
- .filter(p => idNorm(p.querySelector('.labelId')?.textContent) === idHex);
- posts.forEach(hidePost);
- }
- function applyHiddenFilters(postEl = null) {
- const posts = postEl ? [postEl] : QSA('.postCell, .opCell, .post');
- posts.forEach(post => {
- if (!post) return;
- const postId = post.id.replace(/^p/, '');
- const idEl = post.querySelector('.labelId');
- const idHex = idNorm(idEl?.textContent);
- if (hiddenFilters.posts.has(postId) || hiddenFilters.ids.has(idHex)) {
- hidePost(post);
- }
- });
- }
- function hideIdPlus(idHex) {
- if (!idHex) return;
- hideId(idHex);
- // Also hide replies to posts by this ID
- const posts = QSA('.postCell, .opCell, .post');
- posts.forEach(post => {
- const links = QSA('a[href^="#"], a[href^="#q"]', post);
- for (const link of links) {
- const refId = link.getAttribute('href')?.replace(/^#q?/, '');
- const target = document.getElementById(refId);
- if (!target) continue;
- const targetId = idNorm(target.querySelector('.labelId')?.textContent);
- if (targetId === idHex) {
- hidePost(post);
- break;
- }
- }
- });
- }
- function forceClearAllMedia(threadElement) {
- try {
- if (!threadElement || !threadElement.querySelectorAll) {
- console.warn('[FCX] No valid thread element for clearing media');
- return;
- }
- for (const el of threadElement.querySelectorAll('img, video, audio, source')) {
- safeDestroy(el);
- }
- } catch (err) {
- console.error('[FCX] Error force clearing media:', err);
- }
- }
- function neuterNativeLeaks() {
- const kill = (name) => {
- if (window[name]) {
- for (const key of Object.keys(window[name])) {
- window[name][key] = () => {
- //console.info(`[FCX] ${name}.${key}() blocked`);
- };
- }
- }
- };
- // Specifically override init methods (safer fallback if objects undefined)
- if (window.posting?.init) {
- window.posting.init = () => {
- console.info('[FCX] posting.init() prevented');
- };
- }
- if (window.interfaceUtils) kill('interfaceUtils');
- if (window.thumbs) kill('thumbs');
- if (window.hiding) kill('hiding')
- if (window.posting) kill('posting');
- // Remove leaked native UI if it already exists
- }
- // Run now and defer one in case native code loads late
- neuterNativeLeaks();
- setTimeout(neuterNativeLeaks, 1000);
- function destroyNode(el) {
- if (!el) return;
- try {
- // Stop media
- el.pause?.();
- // Null event handlers
- el.onload = el.onerror = el.onmouseenter = el.onmouseleave = null;
- el.onclick = el.onmousemove = el.onmousedown = el.onmouseup = null;
- el.onscroll = el.onwheel = null;
- el.oninput = el.onchange = null;
- // Remove attributes
- if (el.tagName === 'IMG' || el.tagName === 'VIDEO' || el.tagName === 'AUDIO') {
- el.removeAttribute('src');
- el.load?.();
- }
- // Special handling for anchor/link
- if (el.tagName === 'A') {
- el.href = 'javascript:void(0)';
- }
- // Disconnect observers if you stored them (optional)
- if (el._fcxObserver) {
- el._fcxObserver.disconnect();
- el._fcxObserver = null;
- }
- // Remove from preloadCache/mediaMap/etc
- preloadCache.delete(el);
- mediaMap.delete(el);
- // Remove from DOM
- el.remove();
- } catch (err) {
- console.error('[FCX] destroyNode failed:', err);
- }
- }
- function safeDestroy(el) {
- if (!el) return;
- try {
- if (el instanceof HTMLElement || el instanceof HTMLImageElement || el instanceof HTMLVideoElement || el instanceof HTMLAudioElement) {
- el.pause?.();
- if (el.tagName === 'IMG' || el.tagName === 'VIDEO' || el.tagName === 'AUDIO') {
- el.removeAttribute('src');
- el.load?.();
- }
- if (el._fcxObserver) {
- el._fcxObserver.disconnect();
- el._fcxObserver = null;
- }
- el.remove?.();
- }
- preloadCache.delete(el);
- mediaMap.delete(el);
- } catch (err) {
- console.error('[FCX] safeDestroy error', err);
- }
- }
- function handleIdHover(idText, event) {
- const normId = idText.trim().toLowerCase();
- if (!normId || !fcxInstance?.threadJson?.length) return;
- const count = fcxInstance.threadJson.filter(post =>
- post?.id?.toLowerCase() === normId
- ).length;
- if (!count) return;
- const tooltip = document.createElement('div');
- tooltip.className = 'fcx-id-tooltip';
- tooltip.textContent = `Posts by ID: ${count}`;
- Object.assign(tooltip.style, {
- position: 'absolute',
- top: (event.clientY + 10) + 'px',
- left: (event.clientX + 10) + 'px',
- background: 'rgba(0,0,0,0.8)',
- color: 'white',
- padding: '4px 8px',
- borderRadius: '4px',
- fontSize: '13px',
- zIndex: 10000,
- pointerEvents: 'none'
- });
- document.body.appendChild(tooltip);
- const remove = () => tooltip.remove();
- event.target.addEventListener('mouseleave', remove, { once: true });
- }
- let isHoveringPreview = false; // Place near top of your script
- class FullchanX extends HTMLElement {
- quoteMap = new WeakMap();
- idleSince = Date.now();
- isIdle = false;
- idleTimeout = 5000; // 5 seconds
- async fetchAndRenderSinglePost(postId) {
- const meta = this.getThreadMeta?.();
- if (!meta) return;
- const { board, tid } = meta;
- try {
- const url = `/${board}/res/${tid}.json`;
- const res = await fetch(url);
- const json = await res.json();
- const postData = json.posts?.find(p => p.postId == postId);
- if (!postData) {
- console.warn('[FCX] New post ID not found in thread JSON:', postId);
- return;
- }
- // 👉 Check if post already exists
- if (document.getElementById(postId)) {
- return;
- }
- // 👉 1. Add post to internal data
- this.threadJson.push(postData);
- this.totalPages = Math.ceil(this.threadJson.length / this.postsPerPage);
- // 👉 2. Update backlinks map
- this.backlinksMap = this.buildBacklinksMap(this.threadJson);
- // 👉 3. Render and insert
- const el = this.renderPostFromJson(postData);
- this.thread.appendChild(el);
- shortenOriginalFilenames(el);
- this.markCrossThreadQuotes(el);
- // After you render the new post (el)...
- const newPostId = postData.postId;
- // For every post that is quoted by this new post
- const quoteLinks = el.querySelectorAll('a.quoteLink, a.quotelink');
- quoteLinks.forEach(link => {
- const match = link.textContent.match(/^>>(\d+)$/);
- if (!match) return;
- const quotedId = match[1];
- const quotedPost = document.getElementById(quotedId);
- if (!quotedPost) return; // Post not on page (yet)
- const backlinksContainer = quotedPost.querySelector('.panelBacklinks');
- if (!backlinksContainer) return;
- // Check if backlink already exists
- if (!backlinksContainer.querySelector(`a[href="#q${newPostId}"]`)) {
- const backlink = document.createElement('a');
- backlink.href = `#q${newPostId}`;
- backlink.textContent = `>>${newPostId}`;
- backlinksContainer.appendChild(backlink);
- }
- });
- // 👉 4. Update cached post list
- this.postElements.push(el);
- // 👉 5. Update "You"s
- this.refreshYous(el);
- // 👉 6. Update single posters
- scanForSinglePosters(postData);
- // 👉 7. Update pagination controls
- this.updatePaginationNav();
- if (this._pageIndicator && this.totalPages > 0) {
- this._pageIndicator.textContent = `${this.currentPage + 1} / ${this.totalPages}`;
- }
- // 👉 10. Apply hidden filters
- applyHiddenFilters(el);
- markQuotesToYou(el);
- const nearBottom = (window.innerHeight + window.scrollY) >= (document.body.offsetHeight - 120);
- if (this.settings.autoScrollOnIdle && (this.isIdle || nearBottom)) {
- el.scrollIntoView({ behavior: 'smooth', block: 'center' });
- }
- } catch (err) {
- console.error('[FCX] Failed to fetch/render new post:', err);
- }
- }
- getIdle() {
- const resetIdle = () => {
- this.idleSince = Date.now();
- this.isIdle = false;
- };
- this._idleListeners = ['scroll', 'mousemove', 'keydown', 'click', 'touchstart'].map(evt => {
- const handler = resetIdle;
- window.addEventListener(evt, handler, { passive: true });
- return { evt, handler };
- });
- let wasIdle = false;
- this._idleInterval = setInterval(() => {
- const nowIdle = (Date.now() - this.idleSince) >= this.idleTimeout;
- if (nowIdle && !wasIdle) {
- this.fillPreloadCacheUntilFull(); // 🔁 Make sure this function is available in scope
- pruneCachesByDistance();
- pruneMediaMapByDistance();
- }
- wasIdle = nowIdle;
- this.isIdle = nowIdle;
- }, 1000);
- intervals.add(this._idleInterval);
- }
- createYouBox() {
- if (this.youBox) return; // Already exists
- this.youBox = this.panel('fcx-yous', 'My (You)s', { right: '1rem', top: '3rem' });
- }
- connectedCallback() {
- document.getElementById('dynamicAnnouncement')?.remove();
- document.getElementById('navTopBoardsSpan')?.remove();
- document.getElementById('navBoardsSpan')?.remove();
- this.currentPage = 0;
- this.postsPerPage = 250;
- this.threadJson = [];
- window.fcxInstance = this;
- this.totalPages = 1;
- if (this._fcxConnected) return;
- this._fcxConnected = true;
- this.initCritical();
- loadHiddenFilters();
- this._aRaf11 = requestAnimationFrame(() => this.initAfterPaint());
- this._request4 = requestIdleCallback(() => this.initIdleExtras());
- shortenOriginalFilenames();
- this._threadClickHandler = e => {
- const link = e.target.closest('a.quoteLink, a.quotelink');
- if (!link || !link.href.includes('#')) return;
- const { postId, threadId } = parseQuoteHref(link) || {};
- if (!postId) return;
- e.preventDefault();
- const inPanel = link.closest('[id^="fcx-ids-"]');
- if (inPanel) {
- this.jump(postId);
- } else {
- this.toggleQuote(postId, link, false, threadId);
- }
- };
- document.addEventListener('click', this._threadClickHandler, true);
- // 🧠 ID click
- this._clickHandler = e => {
- const pn = e.target.closest('a.postNum, a.post_no, a.no, a.linkQuote');
- if (pn) {
- e.preventDefault();
- const id = pn.hash.slice(1).replace(/^q/, '');
- const quoteLine = '>>' + id;
- // Show the reply box
- this.qBox.style.display = 'block';
- // Delay to allow site-native quote handler to run
- const timeout = setTimeout(() => {
- const alreadyThere = this.qBody.value.includes(quoteLine);
- if (!alreadyThere) {
- this.qBody.value += quoteLine + '\n';
- }
- this.qBody.focus();
- }, 100);
- timeouts.add(timeout);
- return;
- }
- const idEl = e.target.closest('.labelId');
- if (idEl) {
- e.preventDefault();
- this.openIdBox(idEl.textContent);
- }
- };
- document.addEventListener('click', this._clickHandler, true);
- document.addEventListener('mouseover', e => {
- const label = e.target.closest('.labelId');
- if (!label) return;
- const originalText = label.textContent.trim();
- const normId = originalText.toLowerCase();
- if (!normId || !fcxInstance?.threadJson?.length) return;
- const count = fcxInstance.threadJson.filter(post =>
- post?.id?.toLowerCase() === normId
- ).length;
- if (count <= 1) return; // optional: don't bother showing (1) if only 1 post
- label.textContent = `${originalText} (${count})`;
- label.addEventListener('mouseleave', () => {
- label.textContent = originalText;
- }, { once: true });
- });
- // 🧠 Quote fallback preview
- document.addEventListener('pointerover', e => {
- const qLink = e.target.closest('a[href*="#"]');
- if (!qLink || qLink.classList.contains('linkQuote')) return; // skip post number links
- this._request1 = requestIdleCallback(async () => {
- if (qLink._fcxHoverQuote) return;
- const href = qLink.getAttribute('href') || '';
- const cross = href.match(/\/res\/(\d+)\.html#q?(\d+)/);
- const local = href.match(/#q?(\d+)/);
- let threadId = null, postId = null;
- if (cross) {
- threadId = cross[1];
- postId = cross[2];
- } else if (local) {
- threadId = fcxInstance?.tid;
- postId = local[1];
- }
- if (!postId) return;
- if (document.querySelector('.quoteTooltip')) return;
- let src = document.getElementById(postId);
- // Try current threadJson
- if (!src && threadId === fcxInstance?.tid && fcxInstance?.threadJson?.length) {
- const postData = fcxInstance.threadJson.find(p => String(p.postId) === String(postId));
- if (postData) {
- src = fcxInstance.renderPostFromJson(postData);
- }
- }
- // Try cache
- if (!src && threadId && threadId !== fcxInstance?.tid) {
- const cached = getCachedCrossPost(threadId, postId);
- if (cached) {
- src = fcxInstance.renderPostFromJson(cached);
- }
- }
- // Fetch cross-thread JSON if not cached
- if (!src && threadId && threadId !== fcxInstance?.tid) {
- try {
- const board = fcxInstance.getThreadMeta()?.board;
- const res = await fetch(`/${board}/res/${threadId}.json`);
- const json = await res.json();
- let postData = json.posts?.find(p => String(p.postId) === String(postId));
- // Special case: OP post is not in posts[] array
- if (!postData && String(threadId) === String(postId)) {
- postData = {
- postId: threadId,
- threadId: threadId,
- subject: json.subject,
- markdown: json.markdown,
- message: json.message,
- name: json.name,
- email: json.email,
- files: json.files || []
- };
- }
- if (postData) {
- cacheCrossPost(threadId, postId, postData); // ✅ cache it
- src = fcxInstance.renderPostFromJson(postData);
- }
- } catch (err) {
- console.warn('[FCX] Hover preview cross-thread fetch failed:', threadId, postId, err);
- return;
- }
- }
- if (!src) return;
- const body = src.querySelector('.innerPost, .postMessage, .divMessage');
- if (!body) return;
- const preview = document.createElement('div');
- preview.className = 'fcx-hover-quote';
- Object.assign(preview.style, {
- position: 'absolute',
- zIndex: '10000',
- pointerEvents: 'none',
- maxWidth: '60vw',
- maxHeight: '50vh',
- overflow: 'auto',
- background: 'var(--contrast-color)',
- border: '1px solid var(--navbar-text-color)',
- padding: '6px',
- borderRadius: '6px',
- font: '14px sans-serif',
- boxShadow: '0 2px 8px rgba(0,0,0,0.3)'
- });
- preview.appendChild(body.cloneNode(true));
- document.body.appendChild(preview);
- qLink._fcxHoverQuote = preview;
- const rect = qLink.getBoundingClientRect();
- let left = rect.right + 10;
- const maxLeft = window.innerWidth - preview.offsetWidth - 10;
- if (left > maxLeft) {
- left = Math.max(rect.left - preview.offsetWidth - 10, 10);
- }
- preview.style.left = `${left}px`;
- preview.style.top = `${rect.top + window.scrollY}px`;
- const cleanup = () => {
- cleanupHoverQuote();
- if (qLink._fcxHoverQuote) {
- qLink._fcxHoverQuote.remove();
- delete qLink._fcxHoverQuote;
- }
- const nested = document.querySelectorAll('.nestedPost');
- nested.forEach(n => {
- if (!document.body.contains(qLink)) n.remove();
- });
- };
- const checkMouse = () => {
- if (!qLink.matches(':hover') && !preview.matches(':hover')) {
- cleanup();
- window.removeEventListener('mousemove', checkMouse);
- }
- };
- this._hoverQuoteMouseHandler = checkMouse;
- window.addEventListener('mousemove', this._hoverQuoteMouseHandler);
- }, { timeout: 150 });
- }, { passive: true });
- // 🖼️ Hover preview
- this._pointerOverHandler = e => {
- let a = e.target.closest('a[data-filemime]');
- if (!a && e.target.matches('.spoiler, .spoiler *')) {
- a = e.target.closest('.spoiler')?.querySelector('a[data-filemime]');
- }
- if (!a || a.dataset._fcxHoverActive === 'true') return;
- const url = a.href;
- const mime = a.dataset.filemime?.toLowerCase() || '';
- if (!OK_MIME.test(mime) && !OK_EXT.test(url)) return;
- if (preloadFailures.get(url) >= 2) {
- showHoverLoadError(a);
- return;
- }
- a.dataset._fcxHoverActive = 'true';
- isHoveringPreview = true;
- this._activePreviewAnchor?.dispatchEvent(new Event('mouseleave'));
- this._activePreviewAnchor = a;
- const cancelHover = () => {
- isHoveringPreview = false;
- // ✨ SAFETY: Do NOT cancel any in-progress preload
- const cached = preloadCache.get(url);
- if (cached?._fcxReady) {
- cleanupHoverMedia(a); // ✅ Safe to remove displayed media
- } else {
- const meta = mediaMap.get(a);
- if (meta?.spinner) meta.spinner.remove?.(); // Hide spinner if still loading
- mediaMap.delete(a); // Don't clear preloadCache or activePreloads!
- }
- };
- const checkLeave = e => {
- if (!a.matches(':hover')) {
- cancelHover();
- window.removeEventListener('mousemove', checkLeave);
- }
- };
- let rafPending = false;
- function throttledCheckLeave() {
- if (rafPending) return;
- rafPending = true;
- this._aRaf5 = requestAnimationFrame(() => {
- checkLeave();
- rafPending = false;
- });
- }
- window.addEventListener('mousemove', throttledCheckLeave);
- a.addEventListener('mouseleave', cancelHover, { once: true });
- a.addEventListener('pointercancel', cancelHover, { once: true });
- const applyMediaStyles = (el) => {
- Object.assign(el.style, STYLE_HOVER_MEDIA);
- };
- // Show loading spinner immediately on hover
- const spinner = document.createElement('div');
- Object.assign(spinner.style, STYLE_SPINNER);
- spinner.className = 'fcx-spinner';
- spinner.textContent = '⏳ Loading ⏳';
- document.body.appendChild(spinner);
- const limit = fcxInstance?.settings?.mediaCacheSize ?? 50;
- while (mediaMap.size >= limit) {
- for (const [anchor] of mediaMap.entries()) {
- if (!isProtected(anchor)) {
- cleanupHoverMedia(anchor);
- mediaMap.delete(anchor);
- break;
- }
- }
- }
- mediaMap.set(a, { ...mediaMap.get(a), spinner });
- const anchorRect = a.getBoundingClientRect();
- spinner.style.top = `${anchorRect.top + window.scrollY}px`;
- spinner.style.left = `${anchorRect.left + window.scrollX}px`;
- let tries = 0;
- const tryShow = () => {
- const data = mediaMap.get(a);
- let el = data?.img || data?.media;
- if (!el && document.body.contains(a)) {
- // Inject new element if none exists
- const cachedEl = preloadCache.get(url);
- const srcToUse = (cachedEl && cachedEl._fcxReady && cachedEl.src) ? cachedEl.src : url;
- if (mime.startsWith('video/')) {
- el = document.createElement('video');
- el.src = srcToUse;
- el.controls = false;
- el.autoplay = true;
- el.loop = true;
- } else if (mime.startsWith('audio/')) {
- el = document.createElement('audio');
- el.src = srcToUse;
- el.controls = true;
- el.autoplay = true;
- el.loop = true;
- } else {
- el = document.createElement('img');
- el.src = srcToUse;
- }
- mediaMap.set(a, { ...data, media: el });
- }
- if (el && document.body.contains(a)) {
- if (!document.body.contains(el)) {
- el.dataset.fcxHover = 'true';
- if (el.tagName === 'VIDEO' || el.tagName === 'AUDIO') {
- el.style.display = 'block'; // important for video/audio
- el.autoplay = true;
- el.muted = false;
- el.volume = this.settings.volume;
- el.play()?.catch(err => {
- console.warn('[FCX] play() failed:', err);
- });
- }
- applyMediaStyles(el);
- // 💡 Optional spoiler blur
- if (this.settings.blurSpoilerHover) {
- const thumb = a.querySelector('img');
- const isSpoiler = thumb?.src?.includes('custom.spoiler') || false;
- if (isSpoiler) {
- el.classList.add('fcx-hover-blurred');
- if (!this._blurredHoverEls) this._blurredHoverEls = new Set();
- this._blurredHoverEls.add(el);
- }
- }
- this._aRaf6 = requestAnimationFrame(() => {
- document.body.appendChild(el);
- });
- // ✅ Remove spinner once media is shown
- mediaMap.get(a)?.spinner?.remove?.();
- }
- } else if (++tries < 25) {
- const timeout = setTimeout(tryShow, 200);
- timeouts.add(timeout);
- } else {
- console.warn('[FCX] Hover media failed after retries:', a.href);
- showHoverLoadError(a);
- }
- };
- const cached = preloadCache.get(url);
- if (cached && cached?._fcxReady) {
- tryShow();
- } else {
- const rect = a.closest('.post, .postCell, .opCell')?.getBoundingClientRect();
- const viewportCenter = window.innerHeight / 2;
- const dist = rect ? Math.abs((rect.top + rect.bottom) / 2 - viewportCenter) : Infinity;
- queuePreloadImage(url, mime, undefined, 'high', dist);
- }
- };
- document.addEventListener('pointerover', this._pointerOverHandler, { passive: true });
- }
- async initCritical() {
- this._pendingPreloads = new Map();
- this._lastRefreshAttempt = 0;
- this.lastKnownPostTime = Date.now();
- this.thread = QS('.divPosts');
- if (!this.thread) {
- console.warn('[FCX] Thread container not found');
- return;
- }
- this.paginationNav = document.createElement('div');
- this.paginationNav.id = 'fcx-pagination';
- Object.assign(this.paginationNav.style, {
- textAlign: 'center',
- margin: '16px 0',
- fontSize: '14px'
- });
- this.thread.before(this.paginationNav);
- const op = QS('.opCell');
- if (!op?.id) {
- console.warn('[FCX] OP post not found');
- return;
- }
- this.tid = op.id.replace(/^p/, '') || 'thread';
- this.seenKey = `${this.tid}-seen-yous`;
- this.loadSettings(); // << important, before using settings!
- if (this.settings.hideStubs) document.body.classList.add('fcx-hide-stubs');
- if (this.settings.anonymize) document.body.classList.add('fcx-anonymize');
- this.staleAge = this.settings.staleAge ?? 10000;
- this.refreshPoll = this.settings.refreshPoll ?? 5000;
- if (this.settings.showYous) {
- this.createYouBox();
- }
- // ✅ Only after setting up thread + settings → now fetch the JSON
- const meta = this.getThreadMeta?.();
- if (!meta) {
- console.warn('[FCX] Could not parse thread meta');
- return;
- }
- const { board, tid } = meta;
- try {
- const res = await fetch(`/${board}/res/${tid}.json`);
- const json = await res.json();
- if (!Array.isArray(json.posts)) {
- console.warn('[FCX] Thread JSON malformed');
- return;
- }
- this.createOp(json);
- this.threadJson = json.posts;
- this.totalPages = Math.ceil(this.threadJson.length / this.postsPerPage);
- if (this._pageIndicator && this.totalPages > 0) {
- this._pageIndicator.textContent = `${this.currentPage + 1} / ${this.totalPages}`;
- }
- if ( !window.location.hash) {
- this.renderPage(0); // ✅ safe to render now!
- } else {
- this.handleInitialAnchor();
- }
- } catch (err) {
- console.error('[FCX] Failed to fetch thread JSON:', err);
- }
- }
- createOp(json) {
- // Extract OP fields
- const { subject, message, markdown, name, email, id, files, threadId, creation } = json;
- // Create OP post object
- this.opPost = {
- postId: threadId,
- threadId,
- subject,
- message,
- markdown,
- name: name || "Anonymous",
- email,
- id,
- files: files || [],
- creation: creation || new Date().toISOString()
- };
- }
- initAfterPaint() {
- this.createSettingsIcon();
- this.createFloatingPaginationNav();
- this.bindFloatingPaginationNav();
- this.getIdle();
- document.addEventListener('click', e => {
- const link = e.target.closest('a[data-filemime^="video/"]');
- if (!link) return;
- const existingFix = link.parentNode.querySelector(`video[data-fcx-manual="true"][src="${link.href}"]`);
- if (existingFix) {
- existingFix.remove(); // toggle off
- return;
- }
- const timeout = setTimeout(() => {
- const broken = !document.querySelector(`video[src="${link.href}"]`);
- if (broken) {
- injectMediaFallback(link);
- }
- }, 300);
- timeouts.add(timeout);
- });
- }
- initIdleExtras() {
- this.postElements = QSA('.postCell, .opCell, .post', this.thread);
- pruneCachesByDistance();
- pruneMediaMapByDistance();
- }
- updateFloatingPaginationText() {
- if (this._pageIndicator && this.totalPages > 0) {
- this._pageIndicator.textContent = `${this.currentPage + 1} / ${this.totalPages}`;
- }
- }
- createFloatingPaginationNav() {
- const existing = document.getElementById('fcx-floating-pagination');
- if (existing) existing.remove();
- const nav = document.createElement('div');
- nav.id = 'fcx-floating-pagination';
- Object.assign(nav.style, {
- position: 'fixed',
- bottom: '1rem',
- right: '3.5rem',
- zIndex: 10000,
- display: 'flex',
- gap: '8px',
- alignItems: 'center',
- background: 'var(--contrast-color)',
- border: '1px solid var(--navbar-text-color)',
- borderRadius: '6px',
- padding: '4px 8px',
- font: '14px sans-serif'
- });
- nav.innerHTML = `
- <button id="fcx-page-prev">⬅</button>
- <span id="fcx-page-indicator">1 / ?</span> <!-- placeholder initially -->
- <button id="fcx-page-next">➡</button>
- `;
- document.body.appendChild(nav);
- this._floatingPagination = nav;
- this._pageIndicator = nav.querySelector('#fcx-page-indicator');
- this.bindFloatingPaginationNav();
- this.updateFloatingPaginationText(); // ✅ Immediately update number if possible
- }
- bindFloatingPaginationNav() {
- const prevBtn = document.getElementById('fcx-page-prev');
- const nextBtn = document.getElementById('fcx-page-next');
- if (prevBtn) prevBtn.disabled = (this.currentPage === 0);
- if (nextBtn) nextBtn.disabled = (this.currentPage + 1 >= this.totalPages);
- if (prevBtn) prevBtn.onclick = () => this.renderPage(this.currentPage - 1);
- if (nextBtn) nextBtn.onclick = () => this.renderPage(this.currentPage + 1);
- }
- initWebSocket() {
- const meta = this.getThreadMeta?.();
- if (!meta) return;
- const { board, tid } = meta;
- const hostname = location.hostname.replace(/^www\./, '');
- const wsUrl = `wss://${hostname}:8888/`;
- try {
- this.ws = new WebSocket(wsUrl);
- this.ws.onopen = () => {
- const joinKey = `${board}-${tid}`;
- try {
- this.ws.send(joinKey);
- // 👉 Stop the fallback poller if running
- if (this._refreshTimeout) {
- clearTimeout(this._refreshTimeout);
- this._refreshTimeout = null;
- }
- } catch (err) {
- console.warn('[FCX] WebSocket send failed:', err);
- }
- };
- this.ws.onmessage = (event) => {
- console.debug('[FCX] WebSocket message:', event.data);
- let data;
- try {
- data = JSON.parse(event.data);
- } catch (err) {
- console.warn('[FCX] WebSocket invalid message:', err);
- return;
- }
- if (data?.action === 'post' && Array.isArray(data.target)) {
- for (const postId of data.target) {
- this.fetchAndRenderSinglePost(postId);
- }
- }
- };
- this.ws.onclose = () => {
- console.warn('[FCX] WebSocket closed.');
- this.ws = null;
- this.tryReconnectWebSocket();
- this.startPollingFallback();
- };
- this.ws.onerror = (err) => {
- console.error('[FCX] WebSocket error:', err);
- if (this.ws) this.ws.close(); // Ensure it's closed properly
- this.tryReconnectWebSocket();
- this.startPollingFallback(); // 👉 Only start fallback if WebSocket really fails
- };
- } catch (err) {
- console.error('[FCX] WebSocket setup failed:', err);
- this.startPollingFallback();
- }
- }
- tryReconnectWebSocket() {
- const now = Date.now();
- const MIN_RECONNECT_INTERVAL = 60000; // 60 seconds
- if (now - this._lastWsReconnectAttempt < MIN_RECONNECT_INTERVAL) {
- return;
- }
- this._lastWsReconnectAttempt = now;
- // 👉 Before reconnecting, clear fallback polling if active
- if (this._refreshTimeout) {
- clearTimeout(this._refreshTimeout);
- this._refreshTimeout = null;
- }
- setTimeout(() => {
- this.initWebSocket();
- }, 500); // tiny delay to give browser time to close old socket
- }
- startPollingFallback() {
- if (this._refreshTimeout) {
- return;
- }
- if (this._refreshTimeout) clearTimeout(this._refreshTimeout);
- const loop = async () => {
- await this.checkThreadFreshness();
- this._refreshTimeout = setTimeout(loop, 20000); // every 30s
- };
- loop();
- }
- updatePaginationNav() {
- if (!this.paginationNav) return;
- this.paginationNav.innerHTML = `
- <button ${this.currentPage === 0 ? 'disabled' : ''}>⬅ Prev</button>
- Page ${this.currentPage + 1} of ${this.totalPages}
- <button ${this.currentPage + 1 >= this.totalPages ? 'disabled' : ''}>Next ➡</button>
- `;
- const [prevBtn, nextBtn] = this.paginationNav.querySelectorAll('button');
- if (prevBtn) prevBtn.onclick = () => this.renderPage(this.currentPage - 1);
- if (nextBtn) nextBtn.onclick = () => this.renderPage(this.currentPage + 1);
- }
- async handleInitialAnchor() {
- const hash = window.location.hash;
- if (!hash) {
- // No hash → stay at top, no special action needed
- return;
- }
- if (hash === '#footer') {
- await this.renderPage(this.totalPages - 1);
- this._aRaf12 = requestAnimationFrame(() => {
- window.scrollTo({ top: document.body.scrollHeight, behavior: 'smooth' });
- });
- return;
- }
- const postMatch = hash.match(/^#q?(\d+)$/);
- if (postMatch) {
- const postId = postMatch[1];
- if (!this.threadJson?.length) {
- // Wait for thread JSON if not ready
- await new Promise(resolve => setTimeout(resolve, 250));
- }
- let pageIndex = 0;
- const postIndex = this.threadJson.findIndex(p => String(p.postId) === String(postId));
- if (postIndex !== -1) {
- pageIndex = Math.floor(postIndex / this.postsPerPage);
- }
- await this.renderPage(pageIndex);
- this._aRaf13 = requestAnimationFrame(() => {
- const el = document.getElementById(postId);
- if (el) {
- el.scrollIntoView({ behavior: 'smooth', block: 'center' });
- el.style.outline = '2px solid var(--link-color)';
- setTimeout(() => el.style.outline = '', 1500);
- }
- });
- }
- }
- buildBacklinksMap(posts) {
- const backlinks = new Map(); // postId → array of replyPostIds
- for (const post of posts) {
- if (!post.markdown && !post.message) continue;
- const html = post.markdown || post.message;
- const matches = [...html.matchAll(/>>(\d{5,10})/g)];
- for (const [, targetIdStr] of matches) {
- const targetId = parseInt(targetIdStr);
- if (!targetId) continue;
- if (!backlinks.has(targetId)) {
- backlinks.set(targetId, []);
- }
- backlinks.get(targetId).push(post.postId);
- }
- }
- return backlinks;
- }
- fillPreloadCacheUntilFull() {
- const mediaLimit = fcxInstance.settings.preloadPosts ?? 20;
- if (preloadCache.size >= mediaLimit) return;
- const posts = fcxInstance.postElements?.filter(post =>
- post.querySelector('a[data-filemime]')
- );
- if (!posts?.length) return;
- const viewportCenter = window.scrollY + window.innerHeight / 2;
- const mediaPosts = posts.map(post => {
- const rect = post.getBoundingClientRect();
- const postCenter = window.scrollY + rect.top + rect.height / 2;
- const dist = Math.abs(postCenter - viewportCenter);
- return { post, dist };
- }).sort((a, b) => a.dist - b.dist); // Closest first
- let mediaQueued = 0;
- for (const { post, dist } of mediaPosts) {
- const priority = dist < 400 ? 'high' : 'low';
- QSA('a[data-filemime]', post).forEach(anchor => {
- const url = anchor.href;
- const mime = anchor.dataset.filemime?.toLowerCase() || '';
- if (!OK_MIME.test(mime) && !OK_EXT.test(url)) return;
- if (preloadCache.has(url)) return;
- if (mediaQueued >= mediaLimit) return;
- mediaQueued++;
- queuePreloadImage(url, mime, undefined, priority, dist); // 🛠 Pass mime correctly now
- });
- if (mediaQueued >= mediaLimit) break;
- }
- pruneCachesByDistance();
- }
- autoCloseReplyOnPost() {
- const form = this.qBox?.querySelector('form');
- const container = this.thread;
- const body = this.qBody;
- if (!form || !container || !body) return;
- if (!myPosterId) return; // 🚨 Require known poster ID now
- if (this._replySubmitHooked) return;
- this._replySubmitHooked = true;
- form.addEventListener('submit', () => {
- // Save current post IDs by your poster ID
- const priorYourPosts = new Set(
- QSA('.postCell, .opCell').filter(p => {
- const idLabel = p.querySelector('.labelId');
- return idLabel && idLabel.textContent.trim().toLowerCase() === myPosterId;
- }).map(p => p.id)
- );
- const checkForNewYouPost = () => {
- const newYourPost = QSA('.postCell, .opCell').find(p => {
- const idLabel = p.querySelector('.labelId');
- const matchesYou = idLabel && idLabel.textContent.trim().toLowerCase() === myPosterId;
- return matchesYou && !priorYourPosts.has(p.id);
- });
- if (newYourPost) {
- console.debug('[FCX] Detected new (You) post, closing QR.');
- this.qBox.style.display = 'none';
- this.qBody.value = '';
- const fileDropzone = this.qBox.querySelector('#dropzoneQr');
- const selectedDiv = this.qBox.querySelector('#selectedDivQr');
- if (fileDropzone) fileDropzone.innerHTML = 'Drag files here to upload or <br>click here to select them';
- if (selectedDiv) selectedDiv.innerHTML = '';
- clearInterval(timer);
- clearTimeout(timeout);
- }
- };
- const timer = setInterval(checkForNewYouPost, 500); // every 500ms
- intervals.add(timer);
- const timeout = setTimeout(() => {
- console.warn('[FCX] Auto-close fallback triggered.');
- clearInterval(timer);
- if (this.settings.closeReplyBox) {
- this.qBox.style.display = 'none';
- this.qBody.value = '';
- const fileDropzone = this.qBox.querySelector('#dropzoneQr');
- const selectedDiv = this.qBox.querySelector('#selectedDivQr');
- if (fileDropzone) fileDropzone.innerHTML = 'Drag files here to upload or <br>click here to select them';
- if (selectedDiv) selectedDiv.innerHTML = '';
- }
- }, 10000);
- timeouts.add(timeout);
- });
- }
- getThreadMeta() {
- const match = location.pathname.match(/^\/([^/]+)\/res\/(\d+)\.html/);
- if (!match) return null;
- const [, board, tid] = match;
- return { board, tid };
- }
- appendNewPosts(lastRenderedId) {
- if (!Array.isArray(this.threadJson)) return;
- const newPosts = this.threadJson.filter(p => p.postId > lastRenderedId);
- if (!newPosts.length) return;
- console.debug(`[FCX] Appending ${newPosts.length} new post(s)`);
- for (const post of newPosts) {
- const el = this.renderPostFromJson(post);
- this.thread.appendChild(el);
- shortenOriginalFilenames(el);
- this.markCrossThreadQuotes(el);
- }
- this.postElements = QSA('.postCell, .opCell, .post', this.thread);
- scanForSinglePosters();
- }
- async checkThreadFreshness() {
- if (!this.thread || !Array.isArray(this.threadJson)) return;
- // Skip freshness check unless on last page
- if (this.currentPage + 1 < this.totalPages) return;
- const now = Date.now();
- if (this._refreshInFlight) return;
- this._refreshInFlight = true;
- try {
- if (now - this._lastRefreshAttempt < this.staleAge) return;
- this._lastRefreshAttempt = now;
- const meta = this.getThreadMeta?.();
- if (!meta) return;
- const { board, tid } = meta;
- const res = await fetch(`/${board}/res/${tid}.json`);
- if (!res.ok) {
- console.warn('[FCX] Thread JSON fetch failed:', res.status);
- return; // STOP safely
- }
- const json = await res.json().catch(() => null); // ⬅️ Catch parse error too!
- if (!json || !Array.isArray(json.posts)) {
- console.warn('[FCX] Thread JSON malformed');
- return; // STOP safely
- }
- const newPosts = json.posts;
- const oldLastId = this.threadJson.at(-1)?.postId || 0;
- const newLastId = newPosts.at(-1)?.postId || 0;
- // No new posts to add
- if (newLastId <= oldLastId) return;
- const fresh = newPosts.filter(p => p.postId > oldLastId);
- this.threadJson = newPosts;
- this.totalPages = Math.ceil(newPosts.length / this.postsPerPage);
- if (this.currentPage + 1 === this.totalPages) { // Only if on last page
- const lastRenderedPost = this.thread.querySelector('.postCell:last-child, .opCell:last-child, .post:last-child');
- const lastJsonPost = this.threadJson.at(-1);
- if (lastRenderedPost && lastJsonPost) {
- const renderedId = parseInt(lastRenderedPost.id.replace(/^p/, ''), 10);
- const jsonId = lastJsonPost.postId;
- if (renderedId !== jsonId) {
- this.appendNewPosts(renderedId);
- }
- }
- }
- if (this.currentPage + 1 === this.totalPages) {
- //this.renderPage(this.currentPage);
- preloadQueue.length = 0;
- activePreloads = 0;
- preloadCache.clear();
- mediaMap.clear();
- timeouts.forEach(t => clearTimeout(t));
- timeouts.clear();
- this.fillPreloadCacheUntilFull();
- pruneCachesByDistance();
- pruneMediaMapByDistance();
- }
- this.postElements = QSA('.postCell, .opCell, .post', this.thread);
- this.refreshYous();
- scanForSinglePosters();
- if (this._pageIndicator && this.totalPages > 0) {
- this._pageIndicator.textContent = `${this.currentPage + 1} / ${this.totalPages}`;
- }
- this.lastKnownPostId = newLastId;
- this.lastKnownPostTime = new Date(newPosts.at(-1)?.creation).getTime();
- if (this.currentPage + 1 === this.totalPages) { // Only if on last page
- const lastRenderedPost = this.thread.querySelector('.postCell:last-child, .opCell:last-child, .post:last-child');
- const lastJsonPost = this.threadJson.at(-1);
- if (lastRenderedPost && lastJsonPost) {
- const renderedId = parseInt(lastRenderedPost.id.replace(/^p/, ''), 10);
- const jsonId = lastJsonPost.postId;
- if (renderedId !== jsonId) {
- this.appendNewPosts(renderedId);
- }
- }
- }
- const nearBottom = (window.innerHeight + window.scrollY) >= (document.body.offsetHeight - 120);
- if (this.settings.autoScrollOnIdle && (this.isIdle || nearBottom)) {
- const timeout = requestAnimationFrame(() =>
- setTimeout(() => {
- window.scrollTo({ top: document.body.scrollHeight, behavior: 'smooth' });
- }, 100)
- );
- timeouts.add(timeout);
- }
- } catch (err) {
- console.error('[FCX] checkThreadFreshness error:', err);
- } finally {
- if (this._refreshTimeout) {
- clearTimeout(this._refreshTimeout);
- this._refreshTimeout = null;
- }
- this._refreshInFlight = false;
- }
- }
- disconnectedCallback() {
- this.cleanup();
- }
- superWipeAllBeforeRender() {
- // 1. Brutally remove all post-related structure
- document.querySelectorAll(
- 'div.postCell, span.panelBacklinks, div.replyPreview, span.fcx-hover-quote, iframe, a.fcx-hover-quote, figure.uploadCell, div.divMessage, div.panelUploads, div.altBacklinks'
- ).forEach(el => {
- try {
- el.remove();
- } catch {}
- });
- // 2. Destroy all <img> with src attributes
- document.querySelectorAll('img[src]').forEach(img => {
- try {
- img.src = '';
- img.removeAttribute('src');
- img.remove();
- } catch {}
- });
- // 3. Extra: kill deep orphaned .postCell nodes and their children
- document.querySelectorAll('.postCell').forEach(post => {
- try {
- post.querySelectorAll('input, a, span, summary, details, figure, div, img, iframe').forEach(child => {
- child.remove();
- });
- post.remove();
- } catch {}
- });
- // 4. Extra: kill leftover backlinks (deep)
- document.querySelectorAll('.panelBacklinks a[href], .panelBacklinks iframe').forEach(el => {
- try {
- el.remove();
- } catch {}
- });
- // 5. Clear preload caches
- if (typeof preloadCache !== 'undefined') preloadCache.clear();
- if (typeof mediaMap !== 'undefined') mediaMap.clear();
- if (typeof preloadFailures !== 'undefined') preloadFailures.clear();
- // 6. Clear quote and backlink caches
- if (typeof recentQuoteStore !== 'undefined') recentQuoteStore.clear();
- if (typeof fcxInstance !== 'undefined' && fcxInstance.quoteMap) {
- if (typeof fcxInstance.quoteMap.clear === 'function') {
- fcxInstance.quoteMap.clear();
- } else {
- fcxInstance.quoteMap = new WeakMap();
- }
- }
- // 7. Clear ID-related maps
- if (typeof idUsageCountMap !== 'undefined') idUsageCountMap.clear();
- if (typeof singlePostIdMap !== 'undefined') singlePostIdMap.clear();
- // 8. Clear intervals and timeouts
- if (typeof timeouts !== 'undefined') {
- timeouts.forEach(t => clearTimeout(t));
- timeouts.clear();
- }
- if (typeof intervals !== 'undefined') {
- intervals.forEach(i => clearInterval(i));
- intervals.clear();
- }
- // 9. Clear seen posts and postElements
- if (typeof fcxInstance !== 'undefined') {
- if (Array.isArray(fcxInstance.postElements)) fcxInstance.postElements = [];
- if (Array.isArray(fcxInstance.seen)) fcxInstance.seen = [];
- if (typeof fcxInstance.existingPosts === 'object') fcxInstance.existingPosts = {};
- if (typeof fcxInstance.knownPosts === 'object') fcxInstance.knownPosts = {};
- }
- }
- renderPage(page) {
- this.superWipeAllBeforeRender();
- prepareToRenderPage();
- if (window.validationWorker?.running) {
- console.warn('[FCX] ValidationWorker running, delaying page wipe...');
- setTimeout(() => this.renderPage(page), 2000);
- return;
- }
- // Remove backlink and quote-related DOM elements
- document.querySelectorAll('.panelBacklinks, .altBacklinks, .replyPreview, .fcx-hover-quote .linkSelf').forEach(el => el.remove());
- // Clear internal backlink/quote caches
- if (typeof recentQuoteStore !== 'undefined' && recentQuoteStore.clear) {
- recentQuoteStore.clear();
- }
- if (typeof fcxInstance !== 'undefined' && fcxInstance.quoteMap) {
- if (typeof fcxInstance.quoteMap.clear === 'function') {
- fcxInstance.quoteMap.clear();
- } else if (fcxInstance.quoteMap instanceof WeakMap) {
- fcxInstance.quoteMap = new WeakMap(); // replace with a fresh WeakMap
- }
- }
- // 🧹 Destroy lingering detached nodes
- QSA('img, video, audio, source, iframe, figure, details, summary, a, span, input, div').forEach(el => {
- if (!document.contains(el)) safeDestroy(el);
- });
- // 🧹 Clear stray event handlers
- QSA('*').forEach(el => {
- if (!document.contains(el)) {
- el.onclick = el.onmouseenter = el.onmouseleave = null;
- el.onmousemove = el.onmousedown = el.onmouseup = null;
- el.onscroll = el.onwheel = null;
- }
- });
- // 🛑 Cancel idle callbacks
- for (let i = 1; i <= 4; i++) {
- if (this[`_request${i}`]) {
- cancelIdleCallback(this[`_request${i}`]);
- this[`_request${i}`] = null;
- }
- }
- // 🛑 Cancel animation frames
- for (let i = 2; i <= 13; i++) {
- if (this[`_aRaf${i}`]) {
- cancelAnimationFrame(this[`_aRaf${i}`]);
- this[`_aRaf${i}`] = null;
- }
- }
- // 🛑 Cancel mutation debounce
- if (typeof this.mutationDebounce !== 'undefined' && this.mutationDebounce) {
- cancelIdleCallback(this.mutationDebounce);
- this.mutationDebounce = null;
- }
- // 🛑 Cancel normal timeouts
- timeouts.forEach(t => clearTimeout(t));
- timeouts.clear();
- // 🛑 Clear preload jobs
- preloadQueue.length = 0;
- activePreloads = 0;
- preloadCache.forEach(el => safeDestroy(el));
- preloadCache.clear();
- // 🛑 Clear mediaMap
- mediaMap.forEach(data => {
- safeDestroy(data?.img);
- safeDestroy(data?.media);
- });
- mediaMap.clear();
- // 🧹 Extra clearing: hover previews, fallback videos, quotes
- QSA('[data-fcxHover]').forEach(safeDestroy);
- QSA('.nestedPost').forEach(safeDestroy);
- QSA('video[data-fcxManual]').forEach(safeDestroy);
- recentQuoteStore.clear();
- // 🧹 Clear old thread DOM
- if (this.thread) {
- QSA('img, video, audio, source, a, figure', this.thread).forEach(safeDestroy);
- forceClearAllMedia(this.thread);
- if (!this.threadJson || !Array.isArray(this.threadJson) || this.threadJson.length === 0) {
- console.error('[FCX-DEBUG] Thread JSON missing or empty before renderPage wipe!');
- }
- if (this.thread.nodeName === 'DIV' && !['BODY', 'HTML'].includes(this.thread.nodeName)) {
- // 🧹 Fully clear all media and links inside thread first
- QSA('img, video, audio, source, a', this.thread).forEach(el => {
- safeDestroy(el);
- });
- QSA('div.postCell').forEach(el => safeDestroy(el));
- //console.log('[FCX-DEBUG] Wiping thread innerHTML now...');
- //console.log('[FCX-DEBUG] About to clear thread:', this.thread, 'nodeName:', this.thread?.nodeName, 'id:', this.thread?.id, 'class:', this.thread?.className);
- this.thread.innerHTML = '';
- } else {
- console.warn('[FCX] Refusing to clear suspicious thread node:', this.thread?.nodeName);
- }
- } else {
- console.warn('[FCX-DEBUG] Thread missing before wipe!');
- }
- // 🛡️ Only after DOM wipe, clean memory
- //wipeAllListeners();
- fullMemoryReset.call(this);
- // ✅ Now safe to render new page
- if (!this.thread || !Array.isArray(this.threadJson)) return;
- if (!this.paginationNav) {
- this.paginationNav = document.createElement('div');
- this.paginationNav.id = 'fcx-pagination';
- Object.assign(this.paginationNav.style, {
- textAlign: 'center',
- margin: '16px 0',
- fontSize: '14px'
- });
- this.thread.before(this.paginationNav);
- }
- this.currentPage = Math.max(0, Math.min(page, this.totalPages - 1));
- const start = this.currentPage * this.postsPerPage;
- const end = Math.min(start + this.postsPerPage, this.threadJson.length);
- const slice = this.threadJson.slice(start, end);
- this.backlinksMap = this.buildBacklinksMap(this.threadJson);
- if (slice.length === 0) {
- console.warn(`[FCX] No posts to display on page ${this.currentPage + 1}`);
- }
- myPosterId = detectMyPosterIdFromYous();
- const frag = document.createDocumentFragment();
- if (this.opPost) {
- const opEl = renderOP(this.opPost);
- frag.appendChild(opEl);
- }
- for (const post of slice) {
- const el = this.renderPostFromJson(post);
- frag.appendChild(el);
- }
- // After loop finishes
- this.thread.appendChild(frag);
- markQuotesToYou();
- this.postElements = QSA('.postCell, .opCell, .post', this.thread);
- scanForSinglePosters();
- this.updatePaginationNav();
- shortenOriginalFilenames();
- this.markCrossThreadQuotes();
- if (this._pageIndicator && this.totalPages > 0) {
- this._pageIndicator.textContent = `${this.currentPage + 1} / ${this.totalPages}`;
- }
- // ✅ Restore UI elements
- this.createSettingsIcon();
- this.createFloatingPaginationNav();
- this.bindFloatingPaginationNav();
- this.qBox = QS('#quick-reply');
- this.qBody = QS('#qrbody');
- drag(this.qBox, this.qBox.querySelector('.handle'));
- // ✅ Rebind functionality
- this.bindGlobal();
- this.setupMediaPreloader();
- applyHiddenFilters();
- if (this.settings.closeReplyBox) this.autoCloseReplyOnPost();
- // 🧹 Final cleanup in case of detached leftovers
- setTimeout(() => {
- QSA('img, video, audio, source, span.panelBacklinks, div.postCell').forEach(el => {
- if (!document.contains(el)) safeDestroy(el);
- });
- }, 500);
- setTimeout(() => {
- QSA('span.panelBacklinks, span.altBacklinks').forEach(el => {
- if (!el.closest('.postCell, .opCell, .post')) {
- safeDestroy(el);
- }
- });
- }, 500);
- this.updateFloatingPaginationText();
- this.initWebSocket();
- }
- panel(id, title, pos) {
- const box = Object.assign(document.createElement('div'), { id });
- box.innerHTML = `
- <div class="hdr" style="display: flex; align-items: center; justify-content: space-between; gap: 8px;">
- <span class="hdrText">${title}</span>
- <div class="fcx-nav" style="display: flex; gap: 4px;"></div>
- </div>
- <div class="lst"></div>
- `;
- Object.assign(box.style, { ...STYLE_PANEL_BASE, ...pos });
- const hdr = box.querySelector('.hdr'),
- text = box.querySelector('.hdrText'),
- lst = box.querySelector('.lst');
- Object.assign(lst.style, {
- lineHeight: '1.5',
- display: 'block',
- minWidth: '100%',
- whiteSpace: 'normal',
- overflowX: 'hidden',
- overflowY: 'auto'
- });
- this._aRaf2 = requestAnimationFrame(() => box.dataset.baseWidth = box.offsetWidth);
- // Fit box to content when children change
- const mo = new MutationObserver(() => fit(box));
- mo.observe(lst, { childList: true });
- // Auto-clean on removal
- const cleanup = () => {
- mo.disconnect();
- box.remove();
- };
- text.onclick = () => {
- if (id.startsWith('fcx-ids-')) {
- cleanup(); // Click to close
- } else if (id === 'fcx-yous') {
- this.toggleYou(box);
- }
- };
- drag(box, hdr);
- document.body.appendChild(box);
- return box;
- }
- toggleYou(box) {
- const lst = box.querySelector('.lst');
- const isVisible = lst.style.display !== 'none';
- lst.style.display = isVisible ? 'none' : 'block';
- box.style.width = box.style.height = isVisible ? 'auto' : '';
- if (!isVisible) fit(box);
- }
- pid(post) {
- const a = QSA('a', post).find(x => /^\d+$/.test(x.textContent?.trim()));
- return a?.textContent?.trim() || (post.id || '').replace(/^p/, '');
- }
- jump(id) {
- const p = document.getElementById(id);
- if (!p) return;
- try {
- p.scrollIntoView({ behavior: 'smooth', block: 'center' });
- } catch {
- p.scrollIntoView(true);
- }
- p.style.outline = '2px solid var(--link-color)';
- const timeout = setTimeout(() => p.style.outline = '', 1500);
- timeouts.add(timeout);
- }
- refreshYous(postEl = null) {
- if (!this.youBox) return;
- if (!Array.isArray(this.seen)) this.seen = [];
- const lst = this.youBox.querySelector('.lst');
- const seenIds = new Set(this.seen);
- const existingIds = new Set([...lst.querySelectorAll('a')].map(a => a.dataset.pid));
- const frag = document.createDocumentFragment();
- const posts = postEl ? [postEl] : QSA('.postCell, .post', this.thread);
- posts.forEach(post => {
- if (!post) return;
- const youQuotes = post.querySelectorAll('.quoteLink.you, .quotelink.you');
- if (!youQuotes.length) return;
- const id = post.id;
- if (!id || existingIds.has(id)) return;
- const a = document.createElement('a');
- a.textContent = '>>' + id;
- a.href = '#' + id;
- a.dataset.pid = id;
- a.className = 'fcx-youlink';
- a.style.display = 'block';
- a.style.fontWeight = seenIds.has(id) ? 'normal' : '700';
- a.onclick = e => {
- e.preventDefault();
- this.jump(id);
- };
- frag.appendChild(a);
- });
- if (frag.childNodes.length) lst.appendChild(frag);
- if (!this.io) {
- this.io = new IntersectionObserver(entries => {
- let updated = false;
- for (const ent of entries) {
- if (ent.isIntersecting) {
- const id = ent.target.id;
- if (!seenIds.has(id)) {
- this.seen.push(id);
- seenIds.add(id);
- updated = true;
- if (this.seen.length > 500) {
- this.seen = this.seen.slice(-500);
- }
- this.io.unobserve(ent.target);
- }
- }
- }
- if (updated) {
- localStorage.setItem(this.seenKey, JSON.stringify(this.seen));
- const links = lst.querySelectorAll('a.fcx-youlink');
- links.forEach(a => {
- if (seenIds.has(a.dataset.pid)) a.style.fontWeight = 'normal';
- });
- }
- }, { threshold: 0.15 });
- }
- posts
- .filter(p => !seenIds.has(p.id))
- .slice(0, 250)
- .forEach(p => {
- if (!p.dataset.fcxYouObserved) {
- this.io.observe(p);
- p.dataset.fcxYouObserved = '1';
- }
- });
- }
- openIdBox(raw) {
- const id = idNorm(raw); // Normalize input to 6-char hex ID
- const boxId = `fcx-ids-${id}`;
- const prevOpen = QS('[id^="fcx-ids-"]');
- // Close previously open box if different
- if (prevOpen && prevOpen.id !== boxId) {
- this.cleanupIdClones?.();
- prevOpen.remove();
- } else if (prevOpen) {
- // Close if clicking the same ID again
- this.cleanupIdClones?.();
- prevOpen.remove();
- return;
- }
- // Build map of posts with this ID
- const posts = fcxInstance.threadJson.filter(post =>
- post?.id?.toLowerCase() === id
- );
- // Create floating panel
- const box = this.panel(boxId, `Posts by ID: ${id}`, { right: '1rem', top: '12rem' });
- box.classList.add('fcx-idview');
- this._openIdBox = box;
- const hdr = box.querySelector('.hdr');
- const lst = box.querySelector('.lst');
- // Nav buttons
- const nav = document.createElement('div');
- nav.className = 'fcx-nav';
- nav.innerHTML = `<button style="width:2em;">◀</button><button style="width:2em;">▶</button>`;
- hdr.appendChild(nav);
- const [prev, next] = nav.querySelectorAll('button');
- // Container for post clones
- const wrap = document.createElement('div');
- Object.assign(wrap.style, {
- display: 'flex',
- flexDirection: 'column',
- gap: '8px',
- alignItems: 'stretch',
- });
- wrap.className = 'fcx-id-posts';
- lst.appendChild(wrap);
- const clones = new Array(posts.length).fill(null);
- this._idPanelClones = clones;
- const loadPost = i => {
- const post = posts[i];
- if (!post || clones[i]) return;
- const container = document.createElement('div');
- Object.assign(container.style, {
- border: '1px solid var(--navbar-text-color)',
- padding: '6px',
- borderRadius: '4px',
- backgroundColor: 'rgba(255,255,255,0.02)',
- boxSizing: 'border-box',
- overflow: 'auto',
- overflowWrap: 'break-word',
- maxHeight: '60vh'
- });
- const src = this.renderPostFromJson(post);
- if (src) {
- container.appendChild(src);
- } else {
- container.innerHTML = '<div class="postBody">(Failed to render post)</div>';
- }
- clones[i] = container;
- };
- let idx = 0;
- const show = i => {
- idx = (i + posts.length) % posts.length;
- const nextIdx = (idx + 1) % posts.length;
- // Remove old DOM nodes
- while (wrap.firstChild) wrap.removeChild(wrap.firstChild);
- loadPost(idx);
- loadPost(nextIdx); // Preload next
- if (clones[idx]) wrap.appendChild(clones[idx]);
- if (clones[nextIdx] && posts.length > 1) wrap.appendChild(clones[nextIdx]);
- this._aRaf3 = requestAnimationFrame(() => {
- wrap.style.display = 'flex';
- this._aRaf4 = requestAnimationFrame(() => fit(box));
- });
- };
- prev.onclick = () => show(idx - 2);
- next.onclick = () => show(idx + 2);
- box.tabIndex = 0;
- box.onkeydown = e => {
- if (e.key === 'ArrowLeft') { e.preventDefault(); show(idx - 2); }
- if (e.key === 'ArrowRight') { e.preventDefault(); show(idx + 2); }
- };
- const text = box.querySelector('.hdrText');
- text.onclick = () => {
- this.cleanupIdClones?.();
- box.remove();
- };
- drag(box, hdr);
- show(0);
- box.focus();
- }
- cleanupIdClones() {
- if (!this._idPanelClones) return;
- this._idPanelClones.forEach(el => {
- try {
- el?.remove?.();
- } catch {}
- });
- this._idPanelClones = null;
- }
- async toggleQuote(pid, link, small = false, threadId = null) {
- if (this.quoteMap.has(link)) {
- const wrap = this.quoteMap.get(link);
- if (wrap && wrap.isConnected) {
- let current = wrap.nextElementSibling;
- while (current && current.classList.contains('nestedPost')) {
- const next = current.nextElementSibling;
- safeDestroy(current);
- current = next;
- }
- safeDestroy(wrap);
- }
- this.quoteMap.delete(link);
- return;
- }
- let src = document.getElementById(pid);
- if (!src) {
- const postData = this.threadJson.find(p => String(p.postId) === String(pid));
- if (postData) {
- src = this.renderPostFromJson(postData);
- }
- }
- if (!src) {
- console.warn('[TOGGLE-QUOTE] Could not find source post:', pid);
- return;
- }
- const inPanel = !!link.closest(PANEL_SEL);
- const depth = +(link.closest('.nestedPost')?.dataset.depth || 0);
- const wrap = document.createElement('div');
- wrap.className = 'nestedPost';
- wrap.dataset.depth = depth + 1;
- Object.assign(wrap.style, {
- marginLeft: `${(depth + 1) * 20}px`,
- padding: '2px',
- boxSizing: 'border-box',
- fontSize: small ? '90%' : ''
- });
- const inner = document.createElement('div');
- Object.assign(inner.style, {
- display: 'inline-block',
- maxWidth: '100%',
- border: '1px solid var(--navbar-text-color)',
- borderRadius: '4px',
- padding: '4px',
- background: 'var(--contrast-color)'
- });
- const innerPost = src.querySelector('.innerPost');
- if (!innerPost) {
- console.warn('[TOGGLE-QUOTE] No .innerPost found inside post:', src);
- return;
- }
- const fullPostClone = innerPost.cloneNode(true);
- fullPostClone.id = ''; // Prevent ID duplication
- inner.appendChild(fullPostClone);
- wrap.appendChild(inner);
- this.quoteMap.set(link, wrap);
- link.dataset.depth = depth + 1;
- const parent = link.closest('.nestedPost, .postCell, .post');
- if (parent?.isConnected) {
- parent.appendChild(wrap);
- }
- if (inPanel) {
- const panel = link.closest(PANEL_SEL);
- if (panel) fit(panel);
- }
- }
- createSettingsIcon() {
- const existing = document.getElementById('fcx-settings-icon');
- if (existing) existing.remove();
- const icon = document.createElement('div');
- icon.id = 'fcx-settings-icon';
- icon.innerHTML = '⚙️';
- Object.assign(icon.style, {
- position: 'fixed',
- bottom: '1rem',
- right: '1rem',
- fontSize: '20px',
- cursor: 'pointer',
- zIndex: 10000
- });
- icon.onclick = () => this.toggleSettingsPanel();
- document.body.appendChild(icon);
- }
- toggleSettingsPanel() {
- const existing = document.getElementById('fcx-settings-panel');
- if (existing) {
- existing.remove();
- } else {
- this.createSettingsPanel();
- }
- }
- createSettingsPanel() {
- const panel = document.createElement('div');
- panel.id = 'fcx-settings-panel';
- Object.assign(panel.style, {
- position: 'fixed',
- bottom: '3rem',
- right: '1rem',
- background: 'var(--contrast-color)',
- color: 'var(--link-color)',
- border: '1px solid var(--navbar-text-color)',
- padding: '10px',
- borderRadius: '6px',
- zIndex: 10000,
- font: '14px sans-serif',
- width: '240px',
- maxWidth: '90vw',
- boxSizing: 'border-box',
- });
- panel.innerHTML = `
- <label><input type="checkbox" id="fcx-toggle-yous"> Show My (You)s</label>
- <label><input type="checkbox" id="fcx-close-reply-box"> Auto-close Quick Reply after posting</label>
- <label>Media Volume: <input type="range" id="fcx-volume-slider" min="0" max="100" style="width: 100%;"></label>
- <label><input type="checkbox" id="fcx-blur-spoilers"> Blur Spoiler Hover Previews</label>
- <label><input type="checkbox" id="fcx-hide-stubs"> Hide Filtered Stubs</label>
- <label><input type="checkbox" id="fcx-hide-single-posters"> Hide Single-Post IDs</label>
- <label><input type="checkbox" id="fcx-auto-scroll"> Auto-scroll on New Posts (when idle)</label>
- <label><input type="checkbox" id="fcx-anonymize"> Anonymize (hide IDs)</label>
- <label><input type="checkbox" id="fcx-preload-images"> Preload Images</label>
- <label>Max Posts to Preload: <input type="number" id="fcx-preload-posts" min="1" max="50" style="width: 100%;"></label>
- <label>Refresh Interval (ms): <input type="number" id="fcx-refresh-poll" min="1000" max="60000" style="width: 100%;"></label>
- <label>Stale Threshold (ms): <input type="number" id="fcx-stale-age" min="2000" max="120000" style="width: 100%;"></label>
- <label>Max Media Cache Size: <input type="number" id="fcx-cache-size" min="5" max="200" style="width: 100%;"></label>
- <button id="fcx-stop-previews" style="margin-top:10px; width: 100%;">⏹️ Stop All Previews</button>
- `;
- document.body.appendChild(panel);
- // === Helpers ===
- const QS = (s) => panel.querySelector(s);
- const clamp = (val, min, max, fallback) => {
- const n = parseInt(val);
- return isNaN(n) ? fallback : Math.max(min, Math.min(max, n));
- };
- const save = () =>
- localStorage.setItem('fcx-settings', JSON.stringify(this.settings));
- const bindCheckbox = (id, key, onChange) => {
- const el = QS(id);
- el.checked = this.settings[key];
- el.onchange = (e) => {
- this.settings[key] = e.target.checked;
- save();
- onChange?.(e);
- };
- };
- const bindNumber = (id, key, min, max, fallback = 0, onChange) => {
- const el = QS(id);
- el.value = this.settings[key];
- el.oninput = (e) => {
- this.settings[key] = clamp(e.target.value, min, max, fallback);
- save();
- onChange?.(e);
- };
- };
- // === Bind Controls ===
- bindCheckbox('#fcx-toggle-yous', 'showYous', () => {
- if (this.settings.showYous) {
- this.createYouBox();
- this.youBox.style.display = 'block';
- fit(this.youBox);
- this.refreshYous();
- } else if (this.youBox) {
- this.youBox.style.display = 'none';
- }
- });
- bindCheckbox('#fcx-hide-single-posters', 'hideSinglePosters', () => {
- if (this.settings.hideSinglePosters) {
- scanForSinglePosters(); // ✅ Re-apply the filter when toggled ON
- } else {
- // ✅ Unhide all single-posters and clear the map
- singlePostIdMap.forEach(entry => {
- const post = document.getElementById(entry.postId);
- if (post) post.style.display = '';
- });
- singlePostIdMap.clear();
- }
- });
- bindCheckbox('#fcx-close-reply-box', 'closeReplyBox');
- bindCheckbox('#fcx-hide-stubs', 'hideStubs', () =>
- document.body.classList.toggle('fcx-hide-stubs', this.settings.hideStubs)
- );
- bindCheckbox('#fcx-anonymize', 'anonymize', () =>
- document.body.classList.toggle('fcx-anonymize', this.settings.anonymize)
- );
- bindCheckbox('#fcx-auto-scroll', 'autoScrollOnIdle');
- bindCheckbox('#fcx-preload-images', 'preloadImages');
- bindCheckbox('#fcx-blur-spoilers', 'blurSpoilerHover', () => {
- if (!this.settings.blurSpoilerHover && this._blurredHoverEls?.size > 0) {
- this._blurredHoverEls.forEach(el => {
- el.classList.remove('fcx-hover-blurred');
- delete el.dataset.fcxWasSpoiler;
- });
- this._blurredHoverEls.clear();
- }
- });
- QS('#fcx-volume-slider').value = Math.round(this.settings.volume * 100);
- QS('#fcx-volume-slider').oninput = (e) => {
- this.settings.volume = +e.target.value / 100;
- save();
- };
- bindNumber('#fcx-cache-size', 'mediaCacheSize', 5, 100, 20);
- bindNumber('#fcx-preload-posts', 'preloadPosts', 1, 50, 20);
- bindNumber('#fcx-refresh-poll', 'refreshPoll', 1000, 60000, 10000, () =>
- this.refreshPoll = this.settings.refreshPoll
- );
- bindNumber('#fcx-stale-age', 'staleAge', 2000, 120000, 200000, () =>
- this.staleAge = this.settings.staleAge
- );
- // Stop all previews
- QS('#fcx-stop-previews').onclick = () => this.stopAllPreviews();
- }
- renderPostFromJson(post) {
- const div = document.createElement('div');
- const board = this.getThreadMeta()?.board ?? '';
- const isOP = post.postId === parseInt(this.tid);
- const replies = this.backlinksMap?.get(post.postId) || [];
- const repliesHtml = replies.map(no => {
- let href = `#${no}`;
- let label = '';
- if (String(no) === String(this.tid)) {
- // Same-thread OP → normal
- } else {
- const postInSameThread = this.threadJson?.some(p => String(p.postId) === String(no));
- if (!postInSameThread) {
- const board = this.getThreadMeta()?.board ?? '';
- href = `/${board}/res/${no}.html#${no}`;
- label = ' (Cross Thread)';
- }
- }
- return `<a href="${href}">>>${no}${label}</a>`;
- }).join(' ');
- div.id = post.postId;
- div.className = isOP ? 'opCell' : 'postCell';
- div.dataset.boarduri = board;
- div.dataset.fcxObserved = '1';
- const createdDate = new Date(post.creation);
- const formattedTime = createdDate.toLocaleString('en-US', {
- timeZone: 'UTC',
- weekday: 'short',
- year: 'numeric',
- month: '2-digit',
- day: '2-digit',
- hour: '2-digit',
- minute: '2-digit',
- second: '2-digit'
- });
- const idHex = post.id?.slice(0, 6).toLowerCase() || '';
- // Build file section
- const fileSection = Array.isArray(post.files) && post.files.length > 0
- ? `<div class="panelUploads">${post.files.map(file => `
- <figure class="uploadCell"><details open>
- <summary>
- <div class="uploadDetails">
- <a class="nameLink coloredIcon" target="_blank" href="${file.path}"></a>
- <span class="hideFileButton glowOnHover coloredIcon"></span>
- <span class="hideMobile">(</span>
- <span class="sizeLabel">${(file.size / 1024).toFixed(1)} KB</span>
- <span class="dimensionLabel">${file.width}×${file.height}</span>
- <a class="originalNameLink" href="${file.path}" download="${file.originalName}">${file.originalName}</a>
- <span class="hideMobile">)</span>
- </div>
- </summary>
- <br>
- <a class="imgLink" target="_blank" href="${file.path}" data-filemime="${file.mime}">
- <img src="${file.thumb || file.path}">
- </a>
- </details></figure>
- `).join('')}</div>`
- : '';
- const msg = post.markdown || post.message || '';
- div.innerHTML = `
- <div class="innerPost">
- <div class="postInfo title">
- <input type="checkbox" class="deletionCheckBox" name="${board}-${this.tid}-${post.postId}">
- <label class="hideButton glowOnHover coloredIcon" title="Hide"></label>
- <label class="extraMenuButton glowOnHover coloredIcon" title="Post Menu"></label>
- <a class="linkName noEmailName">${post.name || 'Anonymous'}</a>
- <span class="labelCreated">${formattedTime}</span>
- ${idHex ? `<span class="spanId"> Id: <span class="labelId" style="background-color: #${idHex}">${idHex}</span></span>` : ''}
- <span class="labelCreated">No.</span>
- <a class="linkQuote" href="#q${post.postId}">${post.postId}</a>
- ${post.postId == this.tid ? '<span style="font-size:90%;color:var(--link-color);"> (OP)</span>' : ''}
- <span class="panelBacklinks">${repliesHtml}</span>
- </div>
- ${fileSection}
- <div class="divMessage">${msg}</div>
- <div class="altBacklinks"><span class="panelBacklinks">${repliesHtml}</span></div>
- <div class="replyPreview"></div>
- </div>`;
- const hideBtn = div.querySelector('.hideButton');
- if (hideBtn) {
- hideBtn.onclick = (e) => {
- e.preventDefault();
- e.stopPropagation();
- // Remove old open menus
- document.querySelectorAll('.floatingList.extraMenu').forEach(m => m.remove());
- const extraMenu = document.createElement('div');
- extraMenu.className = 'floatingList extraMenu';
- extraMenu.style.position = 'absolute';
- extraMenu.style.zIndex = '10000';
- extraMenu.style.background = 'var(--contrast-color)';
- extraMenu.style.border = '1px solid var(--navbar-text-color)';
- extraMenu.style.padding = '6px';
- extraMenu.style.borderRadius = '6px';
- extraMenu.style.font = '14px sans-serif';
- const list = document.createElement('ul');
- list.style.margin = 0;
- list.style.padding = 0;
- list.style.listStyle = 'none';
- extraMenu.appendChild(list);
- const menuItems = buildHideMenu(div);
- menuItems.forEach(option => {
- const li = document.createElement('li');
- li.textContent = option.name;
- li.style.padding = '4px 8px';
- li.style.cursor = 'pointer';
- li.onmouseenter = () => li.style.background = 'var(--link-hover-color)';
- li.onmouseleave = () => li.style.background = '';
- li.onclick = (ev) => {
- ev.stopPropagation();
- extraMenu.remove();
- option.callback(ev);
- };
- list.appendChild(li);
- });
- hideBtn.appendChild(extraMenu);
- // Optional: Close menu if clicking outside
- const closeOnOutside = (event) => {
- if (!extraMenu.contains(event.target)) {
- extraMenu.remove();
- document.removeEventListener('click', closeOnOutside);
- }
- };
- document.addEventListener('click', closeOnOutside);
- };
- }
- return div;
- }
- loadSettings() {
- const defaults = {
- showYous: false,
- volume: 0.5,
- hideStubs: false,
- anonymize: false,
- preloadImages: true,
- preloadPosts: 5,
- mediaCacheSize: 50,
- closeReplyBox: true,
- staleAge: 10000,
- refreshPoll: 5000,
- autoScrollOnIdle: false,
- blurSpoilerHover: false,
- hideSinglePosters: true,
- };
- const saved = JSON.parse(localStorage.getItem('fcx-settings') || '{}');
- // Merge with defaults to ensure all keys are present
- this.settings = { ...defaults, ...saved };
- // Reflect relevant settings in DOM classes
- document.body.classList.toggle('fcx-hide-stubs', this.settings.hideStubs);
- document.body.classList.toggle('fcx-anonymize', this.settings.anonymize);
- }
- stopAllPreviews() {
- // Remove all hover-related media elements
- QSA('[data-fcx-hover], .fcx-spinner').forEach(el => {
- if (el.dataset.fcxManual) return; // 🛡️ Don't remove manually fixed video
- try {
- el.pause?.();
- el.currentTime = 0;
- } catch {}
- el.remove();
- });
- // Extra safety cleanup for stragglers
- QSA('img, video, audio').forEach(el => {
- const style = getComputedStyle(el);
- if (style.position === 'fixed' || el.style.pointerEvents === 'none') {
- el.pause?.();
- el.currentTime = 0;
- el.remove();
- }
- });
- // Clean up tracking attributes from anchors
- QSA('a[data-filemime]').forEach(a => {
- delete a._prev;
- delete a._preloaded;
- delete a._fcxFailed;
- delete a.dataset._fcxHoverActive;
- });
- // Reset mediaMap if needed
- mediaMap.clear();
- }
- bindGlobal() {
- const thread = this.thread;
- if (!thread) return;
- const applyMediaStyles = el => {
- Object.assign(el.style, STYLE_HOVER_MEDIA);
- };
- const getSharedSpinner = () => {
- if (!this._spinner) {
- this._spinner = document.createElement('div');
- Object.assign(this._spinner.style, STYLE_SPINNER);
- this._spinner.className = 'fcx-spinner';
- this._spinner.textContent = '⏳ Loading ⏳';
- }
- return this._spinner.cloneNode(true);
- };
- // ❌ Cancel previews on scroll or touch
- let scrollRaf = false;
- const throttledStopHoverPreviews = () => {
- if (scrollRaf) return;
- scrollRaf = true;
- this._aRaf7 = requestAnimationFrame(() => {
- fcxInstance?.stopHoverPreviews();
- scrollRaf = false;
- });
- };
- this._hoverPreviewStopHandler = throttledStopHoverPreviews;
- ['scroll', 'wheel', 'touchmove'].forEach(evt =>
- window.addEventListener(evt, this._hoverPreviewStopHandler, { passive: true })
- );
- let hoverTries = 0;
- }
- async showCrossThreadQuote(link, threadId, postId) {
- const board = this.getThreadMeta()?.board;
- try {
- const res = await fetch(`/${board}/res/${threadId}.json`);
- if (!res.ok) throw new Error('Cross-thread fetch failed');
- const json = await res.json();
- const post = json.posts.find(p => String(p.postId) === String(postId));
- if (!post) return;
- const el = this.renderPostFromJson(post);
- el.classList.add('nestedPost', 'cross-thread');
- el.style.marginLeft = '20px';
- link.insertAdjacentElement('afterend', el);
- this.quoteMap.set(link, el);
- } catch (err) {
- console.warn('[FCX] Error loading cross-thread post:', err);
- }
- }
- stopHoverPreviews() {
- if (isHoveringPreview) return;
- for (const [anchor] of mediaMap.entries()) {
- cleanupHoverMedia(anchor);
- mediaMap.delete(anchor); // ⬅️ ensure full cleanup
- }
- // Extra fallback: remove any lingering media
- QSA('img[data-fcx-hover], video[data-fcx-hover], audio[data-fcx-hover]').forEach(el => {
- el.pause?.();
- el.src = '';
- el.removeAttribute('src');
- el.load?.(); // forces to stop fetching/loading
- el.remove?.();
- });
- // Just in case some untagged audio/video slipped through
- QSA('video, audio').forEach(el => {
- if (!document.contains(el) || el.dataset.fcxManual) return;
- el.pause?.();
- el.src = '';
- el.removeAttribute('src');
- el.load?.(); // forces to stop fetching/loading
- el.remove?.();
- });
- // Kill spinners
- QSA('.fcx-spinner').forEach(s => s.remove());
- isHoveringPreview = false; // ✅ reinforce reset
- }
- setupMediaPreloader() {
- if (!this.settings?.preloadImages) return;
- if (this._mediaPreloaderBound) return; // ✅ Already bound, skip
- this._mediaPreloaderBound = true; // ✅ Mark as bound
- let lastPrune = 0;
- const pruneScrollHandler = () => {
- const now = Date.now();
- if (now - lastPrune > 500) {
- pruneCachesByDistance();
- pruneMediaMapByDistance();
- lastPrune = now;
- }
- };
- window.addEventListener('scroll', pruneScrollHandler, { passive: true });
- this._pruneScrollHandler = pruneScrollHandler; // ✅ Save reference if ever needed for cleanup
- const LOOKAHEAD = this.settings.preloadPosts || 20;
- const SCROLL_IDLE_DELAY = 10;
- let lastScrollTop = window.scrollY;
- let scrollDir = 'down';
- const handleScroll = () => {
- const top = window.scrollY;
- scrollDir = top > lastScrollTop ? 'down' : 'up';
- lastScrollTop = top;
- maybeTriggerSmartPreload(this.doSmartPreload.bind(this), scrollDir, LOOKAHEAD);
- };
- this._handleScroll = handleScroll; // ✅ Save for cleanup if needed
- window.addEventListener('scroll', this._handleScroll, { passive: true });
- // 💡 First-pass preload for visible media
- this._request2 = requestIdleCallback(() => {
- this.scrollDir = 'down';
- this.postElements?.forEach(post => {
- const rect = post.getBoundingClientRect();
- if (rect.bottom >= 0 && rect.top <= window.innerHeight) {
- QSA('a[data-filemime]', post).forEach(a => {
- const url = a.href;
- const mime = a.dataset.filemime?.toLowerCase() || '';
- if (!OK_MIME.test(mime) && !OK_EXT.test(url)) return;
- queuePreloadImage(url, mime, undefined, 'high', 0); // 🛠 Pass mime + distance=0
- });
- }
- });
- this.doSmartPreload(scrollDir, LOOKAHEAD);
- });
- }
- doSmartPreload(dir = 'down') {
- const LOOKAHEAD = this.settings?.preloadPosts || 20;
- const MAX_PRELOAD = 20;
- const posts = this.postElements;
- if (!posts?.length) return;
- const winTop = window.scrollY;
- const winBottom = winTop + window.innerHeight;
- let centerIndex = -1;
- const visible = [];
- for (let i = 0; i < posts.length; i++) {
- const post = posts[i];
- if (!post?.parentElement) continue;
- const rect = post.getBoundingClientRect();
- const absTop = rect.top + window.scrollY;
- const absBottom = rect.bottom + window.scrollY;
- if (absBottom > winTop && absTop < winBottom) {
- visible.push({ post, index: i });
- }
- }
- if (!visible.length) return;
- centerIndex = visible[Math.floor(visible.length / 2)].index;
- const preloadPosts = new Set();
- visible.forEach(({ post }) => preloadPosts.add(post));
- const directionRange = dir === 'up'
- ? { start: centerIndex - LOOKAHEAD, end: centerIndex - 1 }
- : { start: centerIndex + 1, end: centerIndex + LOOKAHEAD };
- for (let i = directionRange.start; i <= directionRange.end; i++) {
- if (posts[i]) preloadPosts.add(posts[i]);
- }
- const opposite = dir === 'up'
- ? [centerIndex + 1, centerIndex + 2, centerIndex + 3, centerIndex + 4, centerIndex + 5]
- : [centerIndex - 1, centerIndex - 2, centerIndex - 3, centerIndex - 4, centerIndex - 5];
- for (let i of opposite) {
- if (posts[i]) preloadPosts.add(posts[i]);
- }
- const limited = Array.from(preloadPosts).slice(0, MAX_PRELOAD);
- const viewportCenter = window.innerHeight / 2;
- for (const post of limited) {
- const links = QSA('a[data-filemime]', post);
- for (const a of links) {
- const url = a.href;
- const mime = a.dataset.filemime?.toLowerCase() || '';
- if (!OK_MIME.test(mime) && !OK_EXT.test(url)) continue;
- if (preloadCache.has(url) || preloadQueue.some(q => q.url === url)) continue;
- if (preloadFailures.get(url)?.count >= MAX_FAILURES) continue;
- const rect = a.closest('.post, .postCell, .opCell')?.getBoundingClientRect();
- const dist = rect ? Math.abs((rect.top + rect.bottom) / 2 - viewportCenter) : Infinity;
- queuePreloadImage(url, mime, undefined, 'high', dist); // ✅ Pass mime
- }
- }
- prunePreloadCache(centerIndex, this.settings?.mediaCacheSize || 50);
- }
- enforcePostWindow(center, max = 200) {
- const posts = this.postElements ?? QSA('.postCell, .opCell', this.thread);
- if (!posts.length) return;
- const keep = new Set();
- const half = Math.floor(max / 2);
- for (let i = center - half; i <= center + half; i++) {
- const post = posts[i];
- if (post) keep.add(post);
- }
- const toRemove = posts.filter(p => !keep.has(p));
- toRemove.forEach(post => {
- if (!post?.parentElement) return;
- // Only clean up hover previews, don't touch preload cache
- QSA('a[data-filemime]', post).forEach(a => {
- cleanupHoverMedia(a);
- });
- // Optional: you could remove post from DOM if doing real virtual scrolling
- // post.remove();
- });
- this.postElements = Array.from(keep);
- }
- markCrossThreadQuotes(container = document) {
- if (!this.crossQuoteObserver) {
- this.crossQuoteObserver = new IntersectionObserver((entries, obs) => {
- const toTag = [];
- for (const ent of entries) {
- if (!ent.isIntersecting) continue;
- const q = ent.target;
- if (q.dataset.crossThreaded) continue;
- const text = q.textContent || '';
- const href = q.getAttribute('href') || '';
- const explicit = text.match(/^>>(\d+)\s+\((\d+)\)$/);
- const hrefMatch = href.match(/\/res\/(\d+)\.html#(\d+)/);
- let shouldTag = false;
- if (explicit) {
- const [, postId, threadId] = explicit;
- if (threadId !== this.tid) {
- q.href = `/${location.pathname.split('/')[1]}/res/${threadId}.html#${postId}`;
- shouldTag = true;
- }
- } else if (hrefMatch) {
- const [, threadId] = hrefMatch;
- if (threadId !== this.tid) {
- shouldTag = true;
- }
- }
- if (shouldTag) {
- toTag.push(q);
- }
- obs.unobserve(q);
- }
- if (toTag.length) {
- this._aRaf8 = requestAnimationFrame(() => {
- toTag.forEach(q => this._tagAsCrossThread(q));
- });
- }
- }, { threshold: 0.1 });
- }
- QSA('.quoteLink, .quotelink', container).forEach(q => {
- if (!q.dataset.crossThreaded) {
- this.crossQuoteObserver.observe(q);
- }
- });
- }
- _tagAsCrossThread(q) {
- if (!q) return; // only null check
- const href = q.getAttribute('href') || '';
- const idMatch = href.match(/#(\d+)/);
- const postId = idMatch?.[1] || '';
- const crossSpan = document.createElement('span');
- crossSpan.textContent = ' (Cross-thread)';
- // Copy basic styles from the quote itself
- crossSpan.style.color = getComputedStyle(q).color;
- crossSpan.style.fontSize = getComputedStyle(q).fontSize;
- crossSpan.style.fontFamily = getComputedStyle(q).fontFamily;
- crossSpan.style.textDecoration = getComputedStyle(q).textDecoration;
- q.after(crossSpan); // Add the new text immediately after the <a>
- q.dataset.crossThreaded = 'true';
- }
- cleanup() {
- fullMemoryReset.call(this);
- // Cleanly disconnect the IntersectionObserver when the element is removed from DOM
- if (this.io) this.io.disconnect?.();
- this._fcxConnected = false;
- if (this._refreshTimeout) clearTimeout(this._refreshTimeout);
- if (this._onVisibilityChange) document.removeEventListener('visibilitychange', this._onVisibilityChange);
- // Cleanup idle interval
- if (this._idleInterval) clearInterval(this._idleInterval);
- // Remove idle event listeners
- if (this._idleListeners) {
- this._idleListeners.forEach(({ evt, handler }) =>
- window.removeEventListener(evt, handler)
- );
- this._idleListeners = null;
- }
- if (this._onVisibilityChange) {
- document.removeEventListener('visibilitychange', this._onVisibilityChange);
- this._onVisibilityChange = null;
- }
- this._pendingNodes?.clear?.();
- if (this._handleScroll) {
- window.removeEventListener('scroll', this._handleScroll);
- this._handleScroll = null;
- }
- QSA('img[data-fcx-hover], video[data-fcx-hover], audio[data-fcx-hover]').forEach(el => {
- try {
- el.pause?.();
- el.src = '';
- el.removeAttribute('src');
- el.load?.(); // forces to stop fetching/loading
- el.remove?.();
- } catch {}
- });
- preloadCache.forEach((el, key) => {
- try {
- el.pause?.();
- el.src = '';
- el.removeAttribute('src');
- el.load?.(); // forces to stop fetching/loading
- el.remove?.();
- } catch (err) {
- console.warn('[FCX] Error removing preloadCache element:', err);
- }
- });
- this.stopAllPreviews();
- QSA('a[data-filemime]').forEach(a => {
- a._cancelPreload?.();
- delete a._preloaded;
- delete a._prev;
- delete a._fcxFailed;
- delete a._onPreloadDone;
- delete a.dataset._fcxHoverActive;
- delete a.dataset.fcxHover;
- delete a.dataset._fcxLinked;
- delete a.dataset.fcxMediaLinked;
- });
- preloadCache.clear();
- mediaMap.clear();
- preloadFailures.clear();
- const timeout = setTimeout(() => {
- console.debug('[FCX] Remaining IMG/VIDEO/AUDIO in DOM:',
- QSA('img, video, audio').length);
- console.debug('[FCX] Remaining preloadCache size:', preloadCache.size);
- console.debug('[FCX] Remaining mediaMap size:', mediaMap.size);
- }, 200); // Delay slightly to let cleanup settle
- timeouts.add(timeout);
- preloadQueue.length = 0;
- preloadCache.clear();
- mediaMap.clear();
- preloadFailures.clear();
- this._blurredHoverEls?.forEach(el => el.src = '');
- this._blurredHoverEls?.forEach(el => el.remove?.());
- this._blurredHoverEls?.clear?.();
- // Detach scroll + mouse event handlers
- window.removeEventListener('scroll', this._scrollHandler);
- window.removeEventListener('mousemove', this._mousemoveHandler);
- timeouts.forEach(id => clearTimeout(id));
- intervals.forEach(id => clearInterval(id));
- timeouts.clear();
- intervals.clear();
- this._idleListeners?.forEach(({ evt, handler }) =>
- window.removeEventListener(evt, handler)
- );
- this._idleListeners = null;
- if (this._onVisibilityChange) {
- document.removeEventListener('visibilitychange', this._onVisibilityChange);
- this._onVisibilityChange = null;
- }
- ['scroll', 'wheel', 'touchmove'].forEach(evt =>
- window.removeEventListener(evt, this._hoverPreviewStopHandler)
- );
- this._hoverPreviewStopHandler = null;
- if (this._hoverQuoteMouseHandler) {
- window.removeEventListener('mousemove', this._hoverQuoteMouseHandler);
- this._hoverQuoteMouseHandler = null;
- }
- document.removeEventListener('click', this._clickHandler, true);
- document.removeEventListener('pointerover', this._pointerOverHandler, { passive: true });
- this._clickHandler = null;
- this._pointerOverHandler = null;
- if (this.thread && this._threadClickHandler) {
- this.thread.removeEventListener('click', this._threadClickHandler, true);
- this._threadClickHandler = null;
- }
- this.youBox?.querySelector('.lst')?.replaceChildren?.(); // Fast DOM flush
- this.thread = null;
- this.postElements = null;
- }
- }
- // == FullchanX Bootstrap Script ==
- // This script registers a custom web component, manages its lifecycle,
- // observes URL changes (for SPAs), injects styles, and handles cleanup on unload.
- // 1. Register the custom element if it's not already registered
- if (!customElements.get('fullchan-x')) {
- customElements.define('fullchan-x', FullchanX);
- console.debug('[FCX] Custom element registered.');
- }
- // 2. Initialize and add the <fullchan-x> element to the DOM
- if (!window.fcxInstance) {
- const initFCX = () => {
- // Prevent duplicate insertion
- if (document.querySelector('fullchan-x')) return;
- // Create and append the custom element
- const el = document.createElement('fullchan-x');
- document.body.appendChild(el);
- window.fcxInstance = el;
- };
- // Wait until the DOM is ready, then initialize
- if (document.readyState === 'loading') {
- document.addEventListener('DOMContentLoaded', () => requestAnimationFrame(initFCX));
- } else {
- this._aRaf9 = requestAnimationFrame(initFCX);
- }
- }
- let currentPath = location.pathname;
- // Observe the entire document body for changes
- //urlChangeObserver.observe(document.body, { childList: true, subtree: true });
- // 5. Insert <link rel="preconnect"> to speed up media requests to same origin
- const preconnect = url => {
- const link = document.createElement('link');
- link.rel = 'preconnect';
- link.href = url;
- link.crossOrigin = ''; // Enables credentials for cross-origin use if needed
- document.head.appendChild(link);
- };
- // Only add preconnect if using HTTP/S
- if (location.origin.startsWith('http')) {
- preconnect(location.origin);
- }
- // 6. Inject custom CSS for the FullchanX interface and behavior
- const style = document.createElement('style');
- style.textContent = `
- /* Hide filtered post stubs when option is enabled */
- body.fcx-hide-stubs .unhideButton {
- display: none !important;
- }
- /* Hide user IDs when anonymize mode is active */
- body.fcx-anonymize .spanId {
- display: none !important;
- }
- /* Blurred hover previews */
- .fcx-hover-blurred {
- filter: blur(10px) brightness(0.8);
- backdrop-filter: blur(10px);
- transition: filter 0.2s ease;
- pointer-events: none;
- }
- /* Override visibility styles */
- .fcx-hover-visible {
- display: block !important;
- visibility: visible !important;
- opacity: 1 !important;
- }
- /* Adjust height for hidden/transitioning elements */
- .divRefresh.hidden,
- #actionsForm.hidden,
- #dynamicFooterAlt,
- .threadBottom {
- min-height: 10px;
- transition: all 0.2s ease;
- }
- /* Style number inputs in settings panel */
- #fcx-settings-panel input[type="number"] {
- background: var(--contrast-color);
- color: var(--link-color);
- border: 1px solid var(--navbar-text-color);
- padding: 4px;
- border-radius: 4px;
- box-sizing: border-box;
- max-width: 80px; /* Prevent overly wide inputs */
- }
- /* Style range inputs in settings panel */
- #fcx-settings-panel input[type="range"] {
- width: 100%;
- max-width: 100%;
- box-sizing: border-box;
- }
- /* Label spacing for better readability */
- #fcx-settings-panel label {
- display: block;
- margin-bottom: 10px;
- line-height: 1.4;
- }
- /* Truncate long filenames */
- .originalNameLink {
- max-width: 14ch;
- }
- .nestedPost .innerPost {
- transform: scale(0.98);
- transform-origin: top left;
- }
- `;
- document.head.appendChild(style);
- // 7. Cleanup before leaving the page
- window.addEventListener('beforeunload', () => {
- if (window.fcxInstance) {
- window.fcxInstance.cleanup?.();
- window.fcxInstance.remove?.();
- window.fcxInstance = null;
- }
- });
Advertisement
Add Comment
Please, Sign In to add comment