Guest User

Untitled

a guest
Apr 28th, 2025
59
0
Never
Not a member of Pastebin yet? Sign Up, it unlocks many cool features!
text 145.23 KB | None | 0 0
  1. // ==UserScript==
  2. // @name schizophrenia (v5.0)
  3. // @namespace violentmonkey‑fullchan‑x
  4. // @match https://8chan.moe/*/res/*.html*
  5. // @match https://8chan.se/*/res/*.html*
  6. // @version 5.0‑2025‑04‑28
  7. // @run-at document-start
  8. // @description Recursive inlined quotes, single posts-by-ID window, (You)s window, media previews, preloading
  9. // @author schizo75
  10. // @grant none
  11. // ==/UserScript==
  12.  
  13. const hiddenFilters = {
  14. posts: new Set(), // post IDs like '145059', '145001'
  15. ids: new Set(), // 6-char poster ID hexes like 'ab12cd'
  16. };
  17.  
  18. function prepareToRenderPage() {
  19. if (!window._fcxObservers) {
  20. //console.warn('[FCX] No MutationObserver hook installed. Skipping prepareToRenderPage.');
  21. return;
  22. }
  23.  
  24. const dangerousTargets = new Set([
  25. document.body,
  26. document.documentElement,
  27. document.querySelector('.thread, .divPosts, .threadContainer, .page')
  28. ].filter(Boolean));
  29.  
  30. window._fcxObservers.forEach(({ observer, target }) => {
  31. if (dangerousTargets.has(target)) {
  32. //console.warn('[FCX] Disconnecting MutationObserver on:', target);
  33. try {
  34. observer.disconnect();
  35. } catch (err) {
  36. console.error('[FCX] Failed to disconnect observer:', err);
  37. }
  38. }
  39. });
  40.  
  41. console.debug('[FCX] prepareToRenderPage completed.');
  42. }
  43.  
  44. function loadHiddenFilters() {
  45. try {
  46. const raw = localStorage.getItem('fcx-hidden-filters');
  47. if (raw) {
  48. const data = JSON.parse(raw);
  49. hiddenFilters.posts = new Set(data.posts || []);
  50. hiddenFilters.ids = new Set(data.ids || []);
  51. }
  52. } catch (err) {
  53. console.error('[FCX] Failed to load hidden filters', err);
  54. }
  55. }
  56.  
  57. function saveHiddenFilters(maxPosts = 500, maxIds = 100) {
  58. try {
  59. // Trim oldest if limits exceeded
  60. const posts = [...hiddenFilters.posts].slice(-maxPosts);
  61. const ids = [...hiddenFilters.ids].slice(-maxIds);
  62.  
  63. const data = { posts, ids };
  64. localStorage.setItem('fcx-hidden-filters', JSON.stringify(data));
  65. } catch (err) {
  66. console.error('[FCX] Failed to save hidden filters', err);
  67. }
  68. }
  69.  
  70.  
  71.  
  72. (function() {
  73. 'use strict';
  74.  
  75. function prepareToRenderPage() {
  76. if (!window._fcxObservers) {
  77. console.warn('[FCX] No MutationObserver hook installed. Skipping prepareToRenderPage.');
  78. return;
  79. }
  80.  
  81. const dangerousTargets = new Set([
  82. document.body,
  83. document.documentElement,
  84. document.querySelector('.thread, .divPosts, .threadContainer, .page')
  85. ].filter(Boolean));
  86.  
  87. window._fcxObservers.forEach(({ observer, target }) => {
  88. if (dangerousTargets.has(target)) {
  89. console.warn('[FCX] Disconnecting MutationObserver on:', target);
  90. try {
  91. observer.disconnect();
  92. } catch (err) {
  93. console.error('[FCX] Failed to disconnect observer:', err);
  94. }
  95. }
  96. });
  97.  
  98. console.debug('[FCX] prepareToRenderPage completed.');
  99. }
  100.  
  101. function overrideProperty(obj, propName, createFake, blockMessage) {
  102. let _value;
  103. Object.defineProperty(obj, propName, {
  104. configurable: true,
  105. get() {
  106. if (!_value) {
  107. _value = createFake();
  108. }
  109. return _value;
  110. },
  111. set(val) {
  112. console.warn(`[FCX] Blocking site from replacing ${propName}`);
  113. _value = { ...val, ...createFake() };
  114. }
  115. });
  116. }
  117.  
  118. function neuterWatcher() {
  119. try {
  120.  
  121. window.watcher = Object.freeze({
  122. init: () => console.info('[FCX] watcher.init() blocked'),
  123. updateWatcherCounter: () => console.info('[FCX] watcher.updateWatcherCounter() blocked'),
  124. iterateWatchedThreads: () => console.info('[FCX] watcher.iterateWatchedThreads() blocked'),
  125. runWatchedThreadsCheck: () => console.info('[FCX] watcher.runWatchedThreadsCheck() blocked'),
  126. scheduleWatchedThreadsCheck: () => console.info('[FCX] watcher.scheduleWatchedThreadsCheck() blocked'),
  127. addWatchedCell: () => console.info('[FCX] watcher.addWatchedCell() blocked'),
  128. getStoredWatchedData: () => {
  129. console.info('[FCX] watcher.getStoredWatchedData() blocked');
  130. return {};
  131. },
  132. watchedMenu: null,
  133. counters: [],
  134. elementRelation: {},
  135. isInThread: false,
  136. watcherAlertCounter: 0
  137. });
  138.  
  139. } catch (err) {
  140. console.warn('[FCX] Failed to neuter watcher.js', err);
  141. }
  142. }
  143.  
  144. // --- Start execution ---
  145. prepareToRenderPage();
  146.  
  147. overrideProperty(window, 'hiding', () => ({
  148. isFiltered: () => false,
  149. isQuoteFiltered: () => false,
  150. checkFilterHiding: () => {}
  151. }));
  152.  
  153. neuterWatcher();
  154.  
  155. })();
  156.  
  157.  
  158. // ✅ Utility query selectors for brevity
  159. const QS = (s, r = document) => r.querySelector(s);
  160. const QSA = (s, r = document) => [...r.querySelectorAll(s)];
  161.  
  162. // ✅ Normalizes post IDs (6-digit hex string)
  163. const idNorm = s => (s.match(/^[0-9a-f]{6}/i) || [''])[0].toLowerCase();
  164.  
  165. // ✅ Site-specific constants
  166. const HOST = location.hostname;
  167. const OK_MIME = /^(video\/(mp4|webm)|image\/(png|jpe?g|gif)|audio\/mpeg)$/i;
  168. const OK_EXT = /\.(mp4|webm|png|jpe?g|gif|mp3)$/i;
  169. const PANEL_SEL = '#fcx-yous, [id^="fcx-ids-"]';
  170.  
  171. const MAX_FAILURES = 4;
  172. const MAX_LOAD_TIME = 30000; // 30 seconds
  173. const singlePostIdMap = new Map(); // idHex -> { postId, count }
  174. const idUsageCountMap = new Map(); // idHex -> count
  175. const timeouts = new Set();
  176. const intervals = new Set();
  177. const STYLE_PANEL_BASE = {
  178. position: 'fixed',
  179. background: 'var(--contrast-color)',
  180. color: 'var(--link-color)',
  181. border: '1px solid var(--navbar-text-color)',
  182. padding: '6px',
  183. minWidth: '160px',
  184. maxWidth: '90vw',
  185. zIndex: 9999,
  186. transition: 'width 0.2s ease, height 0.2s ease',
  187. font: '14px/1 sans-serif'
  188. };
  189.  
  190. const STYLE_SPINNER = {
  191. position: 'absolute',
  192. top: '0',
  193. left: '0',
  194. zIndex: 10000,
  195. fontSize: '14px',
  196. color: 'white',
  197. background: 'rgba(0,0,0,0.6)',
  198. padding: '2px 6px',
  199. borderRadius: '4px',
  200. pointerEvents: 'none'
  201. };
  202.  
  203. const STYLE_HOVER_MEDIA = {
  204. position: 'fixed',
  205. left: '0px',
  206. top: '0px',
  207. maxWidth: '90vw',
  208. maxHeight: '90vh',
  209. zIndex: 10000,
  210. pointerEvents: 'none'
  211. };
  212.  
  213. // ✅ Initialize preload tracking
  214. const FAILURE_EXPIRY_MS = 5 * 60 * 1000;
  215.  
  216. const preloadCache = new Map();
  217. const preloadFailures = new Map();
  218. const mediaMap = new Map();
  219.  
  220. const preloadQueue = [];
  221. let activePreloads = 0;
  222. const MAX_CONCURRENT = 10;
  223.  
  224. // 🔥 Extra memory cleanup
  225. function fullMemoryReset() {
  226. try {
  227. if (this._idleInterval) {
  228. clearInterval(this._idleInterval);
  229. this._idleInterval = null;
  230. }
  231. if (this._idleListeners) {
  232. this._idleListeners.forEach(({ evt, handler }) => {
  233. window.removeEventListener(evt, handler);
  234. });
  235. this._idleListeners = null;
  236. }
  237. if (this.io) {
  238. this.io.disconnect();
  239. this.io = null;
  240. }
  241.  
  242. // 🧹 Destroy preloadCache entries
  243. for (const el of preloadCache.values()) {
  244. safeDestroy(el);
  245. }
  246. preloadCache.clear();
  247.  
  248. // 🧹 Destroy mediaMap entries
  249. for (const data of mediaMap.values()) {
  250. safeDestroy(data?.img);
  251. safeDestroy(data?.media);
  252. }
  253. mediaMap.clear();
  254.  
  255. // 🧹 Clear preload queue
  256. preloadQueue.length = 0;
  257. activePreloads = 0;
  258.  
  259. // 🧹 Clear Maps
  260. idUsageCountMap.clear();
  261. singlePostIdMap.clear();
  262. recentQuoteStore.clear();
  263. timeouts.clear();
  264. intervals.clear();
  265.  
  266. // 🧹 Clear postElements if any
  267. this.postElements = [];
  268. this.seen = [];
  269. // 🧹 Wipe all posts
  270. if (typeof this.existingPosts !== 'undefined') {
  271. for (const key in this.existingPosts) {
  272. safeDestroy(this.existingPosts[key]);
  273. }
  274. Object.keys(this.existingPosts).forEach(k => delete this.existingPosts[k]);
  275. }
  276.  
  277. // 🧹 Wipe knownPosts too
  278. if (typeof this.knownPosts !== 'undefined') {
  279. for (const key in this.knownPosts) {
  280. safeDestroy(this.knownPosts[key]);
  281. }
  282. Object.keys(this.knownPosts).forEach(k => delete this.knownPosts[k]);
  283. }
  284.  
  285. // 🧹 Wipe quoteMap if exists
  286. if (typeof this.quoteMap !== 'undefined') {
  287. this.quoteMap.clear?.();
  288. }
  289.  
  290. // 🧹 Optionally clear any backlinks UI if separately stored
  291. QSA('.panelBacklinks, .altBacklinks').forEach(safeDestroy);
  292.  
  293. } catch (err) {
  294. console.error('[FCX] Memory reset error:', err);
  295. }
  296. }
  297.  
  298. // ✅ Returns the number of recent failures for a given URL
  299. // If too much time has passed, the failure record is cleared
  300. function getFailureCount(url) {
  301. const entry = preloadFailures.get(url); // { count, lastFail }
  302. if (!entry) return 0;
  303.  
  304. // Expired? Clear and return 0
  305. if (Date.now() - entry.lastFail > FAILURE_EXPIRY_MS) {
  306. preloadFailures.delete(url);
  307. return 0;
  308. }
  309.  
  310. return entry.count;
  311. }
  312.  
  313. // ✅ Queues a media file (image, video, etc.) for preload if eligible
  314. function queuePreloadImage(url, mime = '', onDone, priority = 'low', distance = Infinity) {
  315. // 🛑 Too many recent failures? Abort.
  316. if (getFailureCount(url) >= MAX_FAILURES) return;
  317.  
  318. // 🛑 Prevent duplicate URLs in the queue
  319. if (preloadQueue.some(q => q.url === url)) return;
  320.  
  321. // ✅ Skip if already cached
  322. const cached = preloadCache.get(url);
  323. if (cached && cached._fcxReady) {
  324. onDone?.(); // Gracefully call the callback even if skipped
  325. return;
  326. }
  327.  
  328. // ✅ Limit the queue size based on user settings
  329. const limit = fcxInstance?.settings?.preloadPosts ?? 20;
  330. if (preloadQueue.length >= limit) {
  331. const idx = preloadQueue.findIndex(q => q.priority === 'low');
  332. if (priority === 'high' || idx === -1) return; // Can’t bump anything
  333. preloadQueue.splice(idx, 1); // Drop a low-priority task to make room
  334. }
  335.  
  336. // 🧪 Queue the request for processing
  337. preloadQueue.push({
  338. url,
  339. mime: mime.toLowerCase(),
  340. onDone,
  341. priority,
  342. distance
  343. });
  344.  
  345. // 🔼 Sort to prioritize closest items (based on scroll distance)
  346. preloadQueue.sort((a, b) => a.distance - b.distance);
  347.  
  348. // 🕒 Schedule processing (as soon as possible)
  349. const timeout = setTimeout(processPreloadQueue, 0);
  350. timeouts.add(timeout);
  351. }
  352.  
  353.  
  354. // ✅ Displays a temporary error tooltip when media fails to load
  355. function showHoverLoadError(anchor) {
  356. if (!anchor?.isConnected) return; // 🛑 Don't show error on dead anchor
  357.  
  358. if (anchor._hoverErrorMsg) return;
  359.  
  360. const rect = anchor.getBoundingClientRect();
  361. const msg = document.createElement('div');
  362. msg.className = 'fcx-hover-error';
  363. msg.textContent = '⚠️ Failed to load media ⚠️';
  364.  
  365. Object.assign(msg.style, {
  366. position: 'absolute',
  367. top: `${rect.bottom + window.scrollY + 6}px`,
  368. left: `${rect.left + window.scrollX}px`,
  369. background: 'rgba(0, 0, 0, 0.85)',
  370. color: 'white',
  371. padding: '4px 8px',
  372. fontSize: '13px',
  373. borderRadius: '4px',
  374. zIndex: 10000,
  375. pointerEvents: 'none',
  376. maxWidth: '240px'
  377. });
  378.  
  379. document.body.appendChild(msg);
  380. anchor._hoverErrorMsg = msg;
  381.  
  382. const remove = () => {
  383. msg.remove();
  384. delete anchor._hoverErrorMsg;
  385. anchor.removeEventListener('mouseleave', remove);
  386. };
  387.  
  388. anchor.addEventListener('mouseleave', remove, { once: true });
  389. }
  390.  
  391. setInterval(() => {
  392. // Clean hover previews
  393. QSA('img[data-fcxHover], video[data-fcxHover], audio[data-fcxHover]').forEach(el => {
  394. if (!document.contains(el)) {
  395. safeDestroy(el);
  396. }
  397. });
  398.  
  399. // Clean manual fallback videos
  400. QSA('video[data-fcxManual]').forEach(el => {
  401. if (!document.contains(el)) {
  402. safeDestroy(el);
  403. }
  404. });
  405.  
  406. // Clean detached backlinks
  407. QSA('span.panelBacklinks, span.altBacklinks').forEach(el => {
  408. if (!document.contains(el)) {
  409. safeDestroy(el);
  410. }
  411. });
  412.  
  413. // Clean stray detached .postCell (rare)
  414. QSA('div.postCell').forEach(el => {
  415. if (!document.contains(el)) {
  416. safeDestroy(el);
  417. }
  418. });
  419.  
  420. }, 20000);
  421.  
  422.  
  423. // ✅ Shortens long original filenames in download links (while preserving extension)
  424. function shortenOriginalFilenames(root = document) {
  425. // 📦 Select all target links inside the given root element
  426. const links = root.querySelectorAll('a.originalNameLink');
  427.  
  428. links.forEach(link => {
  429. // 🏷️ Get the full filename (from 'download' attr or link text)
  430. const fullName = link.getAttribute('download') || link.textContent;
  431.  
  432. // ❌ If there's no extension, skip this link
  433. const dotIndex = fullName.lastIndexOf('.');
  434. if (dotIndex === -1) return;
  435.  
  436. // 🧩 Split filename into base and extension
  437. const name = fullName.slice(0, dotIndex);
  438. const ext = fullName.slice(dotIndex);
  439.  
  440. // ✂️ Truncate the name if too long, append ellipsis
  441. const shortName = name.length > 10
  442. ? name.slice(0, 9) + '…' + ext
  443. : name + ext;
  444.  
  445. // ✅ Only update if it's different from what's already shown
  446. if (link.textContent !== shortName) {
  447. link.textContent = shortName;
  448.  
  449. // 💅 Dynamically set maxWidth based on estimated character width
  450. link.style.maxWidth = `${shortName.length * 0.65}em`;
  451. }
  452. });
  453. }
  454.  
  455.  
  456. // ✅ Check if an element is visible in the current viewport
  457. function isInViewport(el) {
  458. const rect = el?.getBoundingClientRect?.();
  459. return rect && rect.top < window.innerHeight && rect.bottom > 0;
  460. }
  461.  
  462. // ✅ Determine whether an anchor should be protected from eviction
  463. function isProtected(anchor) {
  464. if (!anchor) return false;
  465. return isInViewport(anchor) || anchor.dataset._fcxHoverActive === 'true';
  466. }
  467.  
  468. // ✅ Evicts old media entries from preloadCache and mediaMap if limits are hit
  469. function evictFromCacheAndMediaMap(maxSize) {
  470. // 🔁 First, evict from preloadCache if over limit
  471. if (preloadCache.size >= maxSize) {
  472. for (const [url, el] of preloadCache.entries()) {
  473. try {
  474. el?.pause?.(); // Stop video/audio if playing
  475. el?.remove?.(); // Remove from DOM if needed
  476. } catch {}
  477.  
  478. preloadCache.delete(url); // Remove from cache
  479. break; // Only remove one item per call
  480. }
  481. }
  482.  
  483. // 🔁 Then, evict from mediaMap if it exceeds configured limit
  484. const limit = fcxInstance?.settings?.mediaCacheSize ?? 50;
  485.  
  486. while (mediaMap.size >= limit) {
  487. for (const [anchor] of mediaMap.entries()) {
  488. if (!anchor?.isConnected || !isProtected(anchor)) {
  489. cleanupHoverMedia(anchor);
  490. mediaMap.delete(anchor);
  491. break; // Remove one per call
  492. }
  493. }
  494. }
  495. }
  496.  
  497. // ✅ Processes media preload tasks from the queue, with throttling and error handling
  498. function processPreloadQueue() {
  499. if (activePreloads >= MAX_CONCURRENT || preloadQueue.length === 0) return;
  500.  
  501. const maxSize = fcxInstance?.settings?.mediaCacheSize ?? 32;
  502.  
  503. let tasksProcessed = 0;
  504.  
  505. while (activePreloads < MAX_CONCURRENT && preloadQueue.length > 0) {
  506. const { url, mime, onDone } = preloadQueue.shift();
  507.  
  508. // 🛑 Guard: skip if too many failures
  509. if (getFailureCount(url) >= MAX_FAILURES) {
  510. onDone?.(false);
  511. continue;
  512. }
  513.  
  514. activePreloads++;
  515. preloadCache.set(url, { _fcxReady: false, preloadInProgress: true });
  516.  
  517. let resolved = false;
  518. let timeoutId;
  519.  
  520. const resolve = (el, success = true) => {
  521. if (resolved) return;
  522. resolved = true;
  523. clearTimeout(timeoutId);
  524. activePreloads--;
  525.  
  526. if (!success) {
  527. preloadCache.delete(url);
  528. preloadFailures.set(url, {
  529. count: getFailureCount(url) + 1,
  530. lastFail: Date.now()
  531. });
  532. } else {
  533. if (el) {
  534. el.dataset.fcxHover = 'true';
  535. el._fcxReady = true;
  536. preloadCache.set(url, el);
  537. }
  538. preloadFailures.delete(url);
  539. }
  540.  
  541. onDone?.(success);
  542. };
  543.  
  544. if (mime.startsWith('image/')) {
  545. const img = new Image();
  546. img.src = url;
  547.  
  548. timeoutId = setTimeout(() => {
  549. console.warn('[FCX] Image preload timeout:', url);
  550. resolve(img, false);
  551. }, MAX_LOAD_TIME);
  552. timeouts.add(timeoutId);
  553.  
  554. img.onload = () => resolve(img, true);
  555. img.onerror = () => resolve(img, false);
  556. img.decode?.().catch(() => {}); // ignore decode errors
  557.  
  558. } else if (mime.startsWith('video/') || mime.startsWith('audio/')) {
  559. const el = document.createElement(mime.startsWith('video/') ? 'video' : 'audio');
  560. el.src = url;
  561. el.loop = true;
  562. el.muted = true;
  563. el.volume = 0;
  564. el.preload = 'auto';
  565. el.style.display = 'none';
  566.  
  567. let attempts = 0;
  568.  
  569. const cleanup = () => {
  570. el.onloadeddata = null;
  571. el.onerror = null;
  572. };
  573.  
  574. timeoutId = setTimeout(() => {
  575. console.warn('[FCX] Media preload timeout:', url);
  576. cleanup();
  577. resolve(el, false);
  578. }, MAX_LOAD_TIME);
  579. timeouts.add(timeoutId);
  580.  
  581. const tryLoad = () => {
  582. el.onloadeddata = () => {
  583. cleanup();
  584. resolve(el, true);
  585. };
  586. el.onerror = () => {
  587. cleanup();
  588. attempts++;
  589. if (attempts > 1) {
  590. resolve(el, false);
  591. } else {
  592. el.load();
  593. tryLoad();
  594. }
  595. };
  596. el.load();
  597. };
  598.  
  599. tryLoad();
  600.  
  601. } else if (OK_MIME.test(mime) || OK_EXT.test(url)) {
  602. // Fallback fetch (we can't directly create media elements)
  603. const controller = new AbortController();
  604. timeoutId = setTimeout(() => {
  605. controller.abort();
  606. console.warn('[FCX] Fetch timeout for', url);
  607. resolve({}, false);
  608. }, MAX_LOAD_TIME);
  609. timeouts.add(timeoutId);
  610.  
  611. fetch(url, { mode: 'no-cors', signal: controller.signal })
  612. .then(() => resolve({}, true))
  613. .catch(() => resolve({}, false));
  614.  
  615. } else {
  616. // Unsupported file type → skip cleanly
  617. resolve({}, true);
  618. }
  619.  
  620. tasksProcessed++;
  621. }
  622.  
  623. // 🕒 Only reschedule if more tasks exist
  624. if (preloadQueue.length > 0 && activePreloads < MAX_CONCURRENT) {
  625. this._request4 = requestIdleCallback(processPreloadQueue, { timeout: 100 });
  626. }
  627. }
  628.  
  629. window.addEventListener('error', function(e) {
  630. const message = e?.message || '';
  631.  
  632. if (
  633. message.includes('interfaceUtils.shownMenu.remove') ||
  634. message.includes('settingsMenu.init') ||
  635.  
  636. message.includes('Cannot read properties of null (reading \'cloneNode\')') ||
  637. message.includes('tooltips.addAltBacklinksAndReplyPreview') ||
  638. message.includes('posting.parseExistingPost') ||
  639. message.includes('posting.addPost')
  640.  
  641. ) {
  642. // console.info('[FCX] Suppressed expected native error:', message);
  643. e.preventDefault?.(); // Try prevent
  644. return true; // Block it
  645. }
  646. });
  647.  
  648. if (!window.posting) window.posting = {};
  649.  
  650. // ✅ Patch posting.yous to a real array
  651. if (!Array.isArray(window.posting.yous)) {
  652. window.posting.yous = [];
  653. }
  654.  
  655. let myPosterId = null;
  656.  
  657. function detectMyPosterIdFromYous() {
  658. if (!Array.isArray(window.posting?.yous)) return null;
  659.  
  660. // Check from oldest to newest
  661. for (const postNum of window.posting.yous) {
  662. const post = fcxInstance?.threadJson?.find(p => p.postId == postNum);
  663. if (post && post.id && post.id.length === 6) {
  664. console.debug('[FCX] Detected your poster ID:', post.id.toLowerCase());
  665. return post.id.toLowerCase();
  666. }
  667. }
  668. return null;
  669. }
  670.  
  671. function markQuotesToYou(postEl = null) {
  672. if (!myPosterId) return;
  673.  
  674. const posts = postEl ? [postEl] : QSA('.postCell, .opCell, .post').filter(p => p.querySelector('a.quoteLink, a.quotelink'));
  675.  
  676. for (const post of posts) {
  677. const quotes = post.querySelectorAll('a.quoteLink, a.quotelink');
  678. for (const quote of quotes) {
  679. const idMatch = quote.textContent.match(/^>>(\d+)$/);
  680. if (!idMatch) continue;
  681.  
  682. const quotedPostId = idMatch[1];
  683. const quotedPost = fcxInstance?.threadJson?.find(p => String(p.postId) === quotedPostId);
  684. if (!quotedPost) continue;
  685.  
  686. if (quotedPost.id?.toLowerCase() === myPosterId) {
  687. if (!quote.classList.contains('you')) {
  688. quote.classList.add('you');
  689.  
  690. }
  691. }
  692. }
  693. }
  694. }
  695.  
  696.  
  697. // ✅ Cleans up media elements, listeners, cache entries, and DOM artifacts tied to a hovered anchor
  698. function cleanupHoverMedia(anchor) {
  699. if (!anchor) return;
  700.  
  701. const url = anchor.href;
  702. const data = mediaMap.get(anchor);
  703.  
  704. try {
  705. safeDestroy(data?.img);
  706. safeDestroy(data?.media);
  707. safeDestroy(data?.spinner);
  708. } catch {
  709. // Ignore
  710. }
  711.  
  712. mediaMap.delete(anchor);
  713.  
  714. delete anchor._preloaded;
  715. delete anchor._prev;
  716. delete anchor.dataset._fcxHoverActive;
  717.  
  718. if (anchor._hoverErrorMsg) {
  719. safeDestroy(anchor._hoverErrorMsg);
  720. delete anchor._hoverErrorMsg;
  721. }
  722. }
  723.  
  724. function drag(el, grip = el) {
  725. let sx, sy, ox, oy, moved = false;
  726. grip.style.cursor = 'move';
  727. grip.onmousedown = e => {
  728. sx = e.clientX; sy = e.clientY;
  729. ({ left: ox, top: oy } = el.getBoundingClientRect());
  730.  
  731. const move = ev => {
  732. const dx = ev.clientX - sx, dy = ev.clientY - sy;
  733. if (Math.abs(dx) + Math.abs(dy) > 2) moved = true;
  734. el.style.left = ox + dx + 'px';
  735. el.style.top = oy + dy + 'px';
  736. };
  737.  
  738. const up = () => {
  739. window.removeEventListener('mousemove', move);
  740. if (!moved) grip.dispatchEvent(new CustomEvent('tap'));
  741. moved = false;
  742. };
  743.  
  744. window.addEventListener('mousemove', move);
  745. window.addEventListener('mouseup', up, { once: true });
  746. };
  747. }
  748.  
  749. function fit(box) {
  750. const wrap = box.querySelector('.fcx-id-posts');
  751. if (!wrap) return;
  752.  
  753. // ✅ Temporarily override flex for measurement
  754. wrap.style.display = 'inline-block';
  755. wrap.style.flexDirection = 'column';
  756. wrap.style.alignItems = 'start';
  757. wrap.style.maxWidth = 'none';
  758.  
  759. this._aRaf10 = requestAnimationFrame(() => {
  760. const rect = wrap.getBoundingClientRect();
  761. box.style.width = Math.min(rect.width + 32, window.innerWidth * 0.9) + 'px';
  762. box.style.height = rect.height + 64 + 'px';
  763. });
  764.  
  765. }
  766.  
  767. function injectMediaFallback(anchor) {
  768. const url = anchor.href;
  769. const mime = anchor.dataset.filemime;
  770.  
  771. // Create video element
  772. const video = document.createElement('video');
  773. video.src = url;
  774. video.controls = false;
  775. video.autoplay = true;
  776. video.loop = true;
  777. video.style.maxWidth = '90vw';
  778. video.style.maxHeight = '90vh';
  779. video.style.display = 'block';
  780. video.style.marginTop = '8px';
  781. video.dataset.fcxManual = 'true'; // Tag it as manual fix
  782.  
  783. // Optional: remove broken sibling node if any
  784. const siblings = anchor.parentElement?.querySelectorAll('video, audio');
  785. siblings?.forEach(el => el.src = '');
  786.  
  787. siblings?.forEach(el => el.remove());
  788.  
  789. // Insert right after the anchor
  790. anchor.parentNode.insertBefore(video, anchor.nextSibling);
  791. }
  792.  
  793. function cleanupHoverQuote() {
  794. QSA('.fcx-hover-quote').forEach(safeDestroy);
  795.  
  796. QSA('a[data-fcx-hover-quote]').forEach(a => {
  797. delete a._fcxHoverQuote;
  798. });
  799. }
  800.  
  801. function pruneCachesByDistance() {
  802. const limit = fcxInstance?.settings?.mediaCacheSize ?? 32;
  803. if (preloadCache.size <= limit) return;
  804.  
  805. const viewportCenter = window.scrollY + window.innerHeight / 2;
  806.  
  807. // Map URL -> approximate distance from viewport center
  808. const urlDistances = new Map();
  809.  
  810. const anchors = QSA('a[data-filemime]');
  811. for (let anchor of anchors) {
  812. const post = anchor.closest('.post, .postCell, .opCell');
  813. if (!post) continue;
  814.  
  815. const rect = post.getBoundingClientRect();
  816. const postCenter = window.scrollY + rect.top + rect.height / 2;
  817. const dist = Math.abs(postCenter - viewportCenter);
  818.  
  819. urlDistances.set(anchor.href, dist);
  820. }
  821.  
  822. const sorted = [...preloadCache.entries()].map(([url, el]) => ({
  823. url,
  824. el,
  825. dist: urlDistances.get(url) ?? Infinity // If no anchor, assume far away
  826. })).sort((a, b) => b.dist - a.dist); // Farthest first
  827.  
  828. // Evict farthest entries first
  829. while (preloadCache.size > limit && sorted.length) {
  830. const { url, el } = sorted.shift();
  831. try {
  832. el.pause?.();
  833. el.src = '';
  834. el.removeAttribute('src');
  835. el.load?.(); // forces to stop fetching/loading
  836. el.remove?.();
  837. } catch {}
  838.  
  839. preloadCache.delete(url);
  840. }
  841. }
  842.  
  843. function prunePreloadCache(centerPostIndex, limit = 50) {
  844. const threadJson = window.fcxInstance?.threadJson;
  845. if (!threadJson?.length || preloadCache.size <= limit) return;
  846.  
  847. // 1. Map file URLs to their post index once
  848. const urlToPostIndex = new Map();
  849. for (let i = 0; i < threadJson.length; i++) {
  850. const post = threadJson[i];
  851. if (post?.fileUrl) {
  852. urlToPostIndex.set(post.fileUrl, i);
  853. }
  854. }
  855.  
  856. // 2. Rank preloadCache entries by distance from center
  857. const distances = [...preloadCache.entries()].map(([url, el]) => {
  858. const postIndex = urlToPostIndex.get(url);
  859. const dist = typeof postIndex === 'number' ? Math.abs(centerPostIndex - postIndex) : Infinity;
  860. return { url, el, dist };
  861. });
  862.  
  863. distances.sort((a, b) => b.dist - a.dist); // Farthest first
  864.  
  865. // 3. Remove entries beyond limit
  866. while (preloadCache.size > limit && distances.length) {
  867. const { url, el } = distances.shift();
  868. try {
  869. el.pause?.();
  870. el.src = '';
  871. el.removeAttribute('src');
  872. el.load?.(); // forces to stop fetching/loading
  873. el.remove?.();
  874. } catch {}
  875.  
  876. preloadCache.delete(url);
  877. }
  878. }
  879.  
  880. let smartPreloadQueued = false;
  881.  
  882. function maybeTriggerSmartPreload(doSmartPreloadFn, scrollDir = 'down', lookahead = 50) {
  883. if (smartPreloadQueued || preloadQueue.length > 0 || activePreloads >= MAX_CONCURRENT) return;
  884.  
  885. smartPreloadQueued = true;
  886.  
  887. this._request3 = requestIdleCallback(() => {
  888. smartPreloadQueued = false;
  889. doSmartPreloadFn(scrollDir, lookahead);
  890. }, { timeout: 200 });
  891. }
  892.  
  893. function scanForSinglePosters(newPost = null) {
  894. if (!fcxInstance?.settings?.hideSinglePosters) return;
  895.  
  896. if (newPost) {
  897. // 👉 Update maps for a single post
  898. const id = idNorm(newPost.id);
  899. if (!id) return;
  900.  
  901. const newCount = (idUsageCountMap.get(id) || 0) + 1;
  902. idUsageCountMap.set(id, newCount);
  903.  
  904. if (newCount === 1) {
  905. // First time seeing this ID
  906. singlePostIdMap.set(id, { postId: `p${newPost.postId}`, count: 1 });
  907. const postEl = document.getElementById(`p${newPost.postId}`);
  908. if (postEl) postEl.style.display = 'none';
  909. } else if (newCount === 2) {
  910. // Second time → unhide the first post
  911. const entry = singlePostIdMap.get(id);
  912. if (entry) {
  913. const firstPost = document.getElementById(entry.postId);
  914. if (firstPost) firstPost.style.display = '';
  915. singlePostIdMap.delete(id);
  916. }
  917. }
  918. } else {
  919. // 👉 Full rescan (e.g., after full page render)
  920. idUsageCountMap.clear();
  921. singlePostIdMap.clear();
  922.  
  923. // 🚀 Use threadJson directly instead of DOM
  924. for (const post of fcxInstance?.threadJson || []) {
  925. const id = idNorm(post.id);
  926. if (!id) continue;
  927.  
  928. const newCount = (idUsageCountMap.get(id) || 0) + 1;
  929. idUsageCountMap.set(id, newCount);
  930.  
  931. if (newCount === 1) {
  932. singlePostIdMap.set(id, { postId: `p${post.postId}`, count: 1 });
  933. } else if (newCount === 2) {
  934. singlePostIdMap.delete(id);
  935. }
  936. }
  937.  
  938. // Now hide single posters
  939. for (const [id, { postId }] of singlePostIdMap.entries()) {
  940. const postEl = document.getElementById(postId);
  941. if (postEl) postEl.style.display = 'none';
  942. }
  943. }
  944. }
  945.  
  946.  
  947. function pruneMediaMapByDistance() {
  948. const limit = fcxInstance?.settings?.mediaCacheSize ?? 50;
  949. if (mediaMap.size <= limit) return;
  950.  
  951. const viewportCenter = window.scrollY + window.innerHeight / 2;
  952.  
  953. const entries = [...mediaMap.entries()].map(([anchor, data]) => {
  954. if (!anchor?.isConnected || isProtected(anchor)) return null;
  955.  
  956. const post = anchor.closest('.post, .postCell, .opCell');
  957. if (!post) return null;
  958.  
  959. const rect = post.getBoundingClientRect();
  960. const postCenter = window.scrollY + rect.top + rect.height / 2;
  961. const dist = Math.abs(postCenter - viewportCenter);
  962.  
  963. return { anchor, data, dist };
  964. }).filter(Boolean);
  965.  
  966. entries.sort((a, b) => b.dist - a.dist); // Farthest first
  967.  
  968. while (mediaMap.size > limit && entries.length) {
  969. const { anchor } = entries.shift();
  970. cleanupHoverMedia(anchor);
  971. mediaMap.delete(anchor);
  972. }
  973. }
  974.  
  975. function parseQuoteHref(link) {
  976. const href = link?.getAttribute('href') || '';
  977.  
  978. // Match /board/res/2152.html#113855
  979. const cross = href.match(/\/res\/(\d+)\.html#(\d+)/);
  980. if (cross) return { threadId: cross[1], postId: cross[2] };
  981.  
  982. // Match #113855 or #q113855
  983. const local = href.match(/#q?(\d+)/);
  984. if (local) return { threadId: null, postId: local[1] };
  985.  
  986. return null;
  987. }
  988.  
  989. const recentQuoteStore = new Map(); // key: `${threadId}-${postId}`, value: { postData, timestamp }
  990.  
  991. function cacheCrossPost(threadId, postId, postData) {
  992. const key = `${threadId}-${postId}`;
  993. if (!postData) return;
  994.  
  995. // Update or insert
  996. recentQuoteStore.set(key, { postData, timestamp: Date.now() });
  997.  
  998. // Trim if over 10 entries
  999. if (recentQuoteStore.size > 10) {
  1000. const oldest = [...recentQuoteStore.entries()].sort((a, b) => a[1].timestamp - b[1].timestamp)[0];
  1001. if (oldest) recentQuoteStore.delete(oldest[0]);
  1002. }
  1003. }
  1004.  
  1005. function getCachedCrossPost(threadId, postId) {
  1006. const key = `${threadId}-${postId}`;
  1007. return recentQuoteStore.get(key)?.postData || null;
  1008. }
  1009.  
  1010. function buildHideMenu(postElement) {
  1011. const postId = postElement.id.replace(/^p/, '');
  1012. const idLabel = postElement.querySelector('.labelId');
  1013. const idHex = idLabel?.textContent?.trim()?.toLowerCase() || '';
  1014.  
  1015. return [
  1016. {
  1017. name: 'Hide post',
  1018. callback: () => hidePost(postElement)
  1019. },
  1020. {
  1021. name: 'Hide post+',
  1022. callback: () => hidePostPlus(postElement)
  1023. },
  1024. {
  1025. name: 'Hide ID',
  1026. callback: () => hideId(idHex)
  1027. },
  1028. {
  1029. name: 'Hide ID+',
  1030. callback: () => hideIdPlus(idHex)
  1031. },
  1032. ];
  1033. }
  1034.  
  1035. function renderOP(op) {
  1036. const wrap = document.createElement('div');
  1037. wrap.className = 'innerOP';
  1038.  
  1039. // ===== Upload Panel =====
  1040. if (op.files?.length) {
  1041. const panelUploads = document.createElement('div');
  1042. panelUploads.className = 'panelUploads opUploadPanel';
  1043.  
  1044. const figure = document.createElement('figure');
  1045. figure.className = 'uploadCell';
  1046.  
  1047. const details = document.createElement('details');
  1048. details.open = true;
  1049.  
  1050. const summary = document.createElement('summary');
  1051.  
  1052. const uploadDetails = document.createElement('div');
  1053. uploadDetails.className = 'uploadDetails';
  1054.  
  1055. const file = op.files[0];
  1056. const fileLink = document.createElement('a');
  1057. fileLink.className = 'nameLink coloredIcon';
  1058. fileLink.href = file.path;
  1059. fileLink.target = '_blank';
  1060. uploadDetails.appendChild(fileLink);
  1061.  
  1062. const hideButton = document.createElement('span');
  1063. hideButton.className = 'hideFileButton glowOnHover coloredIcon';
  1064. uploadDetails.appendChild(hideButton);
  1065.  
  1066. uploadDetails.insertAdjacentHTML('beforeend', `
  1067. <span class="hideMobile">(</span>
  1068. <span class="sizeLabel">${(file.size / 1024).toFixed(2)} KB</span>
  1069. <span class="dimensionLabel">${file.width}x${file.height}</span>
  1070. <a class="originalNameLink" href="${file.path}" download="${file.originalName}">${file.originalName}</a>
  1071. <span class="hideMobile">)</span>
  1072. `);
  1073.  
  1074. summary.appendChild(uploadDetails);
  1075.  
  1076. const blankDiv = document.createElement('div');
  1077. summary.appendChild(blankDiv);
  1078.  
  1079. details.appendChild(summary);
  1080.  
  1081. const imgLink = document.createElement('a');
  1082. imgLink.className = 'imgLink';
  1083. imgLink.href = file.path;
  1084. imgLink.target = '_blank';
  1085. imgLink.dataset.filewidth = file.width;
  1086. imgLink.dataset.fileheight = file.height;
  1087. imgLink.dataset.filemime = file.mime;
  1088.  
  1089. const img = document.createElement('img');
  1090. img.loading = 'lazy';
  1091. img.src = file.thumb;
  1092. img.width = Math.floor(file.width * 0.6); // Adjust scaling
  1093. img.height = Math.floor(file.height * 0.6);
  1094. imgLink.appendChild(img);
  1095.  
  1096. details.appendChild(document.createElement('br'));
  1097. details.appendChild(imgLink);
  1098.  
  1099. figure.appendChild(details);
  1100. panelUploads.appendChild(figure);
  1101. wrap.appendChild(panelUploads);
  1102. }
  1103.  
  1104. // ===== OP Head / Title =====
  1105. const opHead = document.createElement('div');
  1106. opHead.className = 'opHead title';
  1107.  
  1108. opHead.innerHTML = `
  1109. <input type="checkbox" class="deletionCheckBox" name="vyt-${op.threadId}">
  1110. <label class="hideButton glowOnHover coloredIcon" title="Hide"></label>
  1111. <label class="extraMenuButton glowOnHover coloredIcon" title="Post Menu"></label>
  1112. <span class="watchButton glowOnHover coloredIcon" title="Watch Thread"></span>
  1113. <span class="labelSubject">${op.subject || ''}</span>
  1114. <a class="linkName noEmailName">${op.name || 'Anonymous'}</a>
  1115. <span class="labelCreated">${new Date(op.creation).toLocaleString()}</span>
  1116. <span class="spanId">Id: <span class="labelId" style="background-color: #${op.id || '888888'}">${op.id || ''}</span></span>
  1117. <a class="linkSelf" href="#${op.threadId}">No.</a>
  1118. <a class="linkQuote" href="#q${op.threadId}">${op.threadId}</a>
  1119. <span class="panelBacklinks"></span>
  1120. `;
  1121.  
  1122. wrap.appendChild(opHead);
  1123.  
  1124. // ===== Message Body =====
  1125. const divMessage = document.createElement('div');
  1126. divMessage.className = 'divMessage';
  1127. divMessage.innerHTML = op.markdown || op.message || '';
  1128.  
  1129. wrap.appendChild(divMessage);
  1130.  
  1131. // ===== Alt Backlinks / Reply Preview placeholders =====
  1132. const altBacklinks = document.createElement('div');
  1133. altBacklinks.className = 'altBacklinks';
  1134. altBacklinks.innerHTML = `<span class="panelBacklinks"></span>`;
  1135. wrap.appendChild(altBacklinks);
  1136.  
  1137. const replyPreview = document.createElement('div');
  1138. replyPreview.className = 'replyPreview';
  1139. wrap.appendChild(replyPreview);
  1140.  
  1141. return wrap;
  1142. }
  1143.  
  1144. function hidePost(post) {
  1145. if (!post) return;
  1146.  
  1147. const postId = post.id.replace(/^p/, '');
  1148. hiddenFilters.posts.add(postId); // ✅ Remember this post is hidden
  1149. saveHiddenFilters(); // ✅ Save
  1150.  
  1151. const board = location.pathname.split('/')[1] || '';
  1152.  
  1153. const unhide = document.createElement('span');
  1154. unhide.className = 'unhideButton glowOnHover';
  1155. unhide.textContent = `[Unhide post /${board}/${postId}]`;
  1156. unhide.style.cursor = 'pointer';
  1157.  
  1158. unhide.onclick = () => {
  1159. hiddenFilters.posts.delete(postId); // ✅ Unhide = forget hidden post
  1160. saveHiddenFilters(); // ✅ Save
  1161.  
  1162. unhide.replaceWith(post);
  1163. post.style.display = ''; // show post again
  1164. };
  1165.  
  1166. post.replaceWith(unhide);
  1167. }
  1168.  
  1169.  
  1170. function hidePostPlus(postElement) {
  1171. if (!postElement) return;
  1172.  
  1173. // First, hide the main post
  1174. hideSinglePost(postElement);
  1175.  
  1176. // Then, look for its backlinks
  1177. const backlinksContainer = postElement.querySelector('.panelBacklinks, .altBacklinks');
  1178. if (!backlinksContainer) return;
  1179.  
  1180. const links = backlinksContainer.querySelectorAll('a[href^="#"]');
  1181. for (const link of links) {
  1182. const replyId = link.getAttribute('href').substring(1); // Remove the '#'
  1183. const replyPost = document.getElementById(replyId);
  1184. if (replyPost) {
  1185. hideSinglePost(replyPost);
  1186. }
  1187. }
  1188. }
  1189.  
  1190. function hideSinglePost(postElement) {
  1191. postElement.style.display = 'none';
  1192.  
  1193. const postId = postElement.id || 'unknown';
  1194. const unhideStub = document.createElement('span');
  1195. unhideStub.className = 'unhideButton glowOnHover';
  1196. unhideStub.textContent = `[Unhide post /${postId}]`;
  1197. unhideStub.onclick = () => {
  1198. postElement.style.display = '';
  1199. unhideStub.remove();
  1200. };
  1201. postElement.parentNode.insertBefore(unhideStub, postElement);
  1202. }
  1203.  
  1204. function hideId(idHex) {
  1205. if (!idHex) return;
  1206. hiddenFilters.ids.add(idHex); // ✅ Remember this ID
  1207. saveHiddenFilters(); // ✅ Save
  1208.  
  1209. const posts = QSA('.postCell, .opCell, .post')
  1210. .filter(p => idNorm(p.querySelector('.labelId')?.textContent) === idHex);
  1211.  
  1212. posts.forEach(hidePost);
  1213. }
  1214.  
  1215. function applyHiddenFilters(postEl = null) {
  1216. const posts = postEl ? [postEl] : QSA('.postCell, .opCell, .post');
  1217.  
  1218. posts.forEach(post => {
  1219. if (!post) return;
  1220.  
  1221. const postId = post.id.replace(/^p/, '');
  1222. const idEl = post.querySelector('.labelId');
  1223. const idHex = idNorm(idEl?.textContent);
  1224.  
  1225. if (hiddenFilters.posts.has(postId) || hiddenFilters.ids.has(idHex)) {
  1226. hidePost(post);
  1227. }
  1228. });
  1229. }
  1230.  
  1231. function hideIdPlus(idHex) {
  1232. if (!idHex) return;
  1233.  
  1234. hideId(idHex);
  1235.  
  1236. // Also hide replies to posts by this ID
  1237. const posts = QSA('.postCell, .opCell, .post');
  1238. posts.forEach(post => {
  1239. const links = QSA('a[href^="#"], a[href^="#q"]', post);
  1240. for (const link of links) {
  1241. const refId = link.getAttribute('href')?.replace(/^#q?/, '');
  1242. const target = document.getElementById(refId);
  1243. if (!target) continue;
  1244.  
  1245. const targetId = idNorm(target.querySelector('.labelId')?.textContent);
  1246. if (targetId === idHex) {
  1247. hidePost(post);
  1248. break;
  1249. }
  1250. }
  1251. });
  1252. }
  1253.  
  1254. function forceClearAllMedia(threadElement) {
  1255. try {
  1256. if (!threadElement || !threadElement.querySelectorAll) {
  1257. console.warn('[FCX] No valid thread element for clearing media');
  1258. return;
  1259. }
  1260.  
  1261. for (const el of threadElement.querySelectorAll('img, video, audio, source')) {
  1262. safeDestroy(el);
  1263. }
  1264.  
  1265. } catch (err) {
  1266. console.error('[FCX] Error force clearing media:', err);
  1267. }
  1268. }
  1269.  
  1270.  
  1271. function neuterNativeLeaks() {
  1272. const kill = (name) => {
  1273. if (window[name]) {
  1274. for (const key of Object.keys(window[name])) {
  1275. window[name][key] = () => {
  1276. //console.info(`[FCX] ${name}.${key}() blocked`);
  1277. };
  1278. }
  1279. }
  1280. };
  1281.  
  1282. // Specifically override init methods (safer fallback if objects undefined)
  1283. if (window.posting?.init) {
  1284. window.posting.init = () => {
  1285. console.info('[FCX] posting.init() prevented');
  1286. };
  1287. }
  1288.  
  1289. if (window.interfaceUtils) kill('interfaceUtils');
  1290. if (window.thumbs) kill('thumbs');
  1291. if (window.hiding) kill('hiding')
  1292. if (window.posting) kill('posting');
  1293.  
  1294. // Remove leaked native UI if it already exists
  1295.  
  1296. }
  1297.  
  1298. // Run now and defer one in case native code loads late
  1299. neuterNativeLeaks();
  1300. setTimeout(neuterNativeLeaks, 1000);
  1301.  
  1302. function destroyNode(el) {
  1303. if (!el) return;
  1304. try {
  1305. // Stop media
  1306. el.pause?.();
  1307.  
  1308. // Null event handlers
  1309. el.onload = el.onerror = el.onmouseenter = el.onmouseleave = null;
  1310. el.onclick = el.onmousemove = el.onmousedown = el.onmouseup = null;
  1311. el.onscroll = el.onwheel = null;
  1312. el.oninput = el.onchange = null;
  1313.  
  1314. // Remove attributes
  1315. if (el.tagName === 'IMG' || el.tagName === 'VIDEO' || el.tagName === 'AUDIO') {
  1316. el.removeAttribute('src');
  1317. el.load?.();
  1318. }
  1319.  
  1320. // Special handling for anchor/link
  1321. if (el.tagName === 'A') {
  1322. el.href = 'javascript:void(0)';
  1323. }
  1324.  
  1325. // Disconnect observers if you stored them (optional)
  1326. if (el._fcxObserver) {
  1327. el._fcxObserver.disconnect();
  1328. el._fcxObserver = null;
  1329. }
  1330.  
  1331. // Remove from preloadCache/mediaMap/etc
  1332. preloadCache.delete(el);
  1333. mediaMap.delete(el);
  1334.  
  1335. // Remove from DOM
  1336. el.remove();
  1337. } catch (err) {
  1338. console.error('[FCX] destroyNode failed:', err);
  1339. }
  1340. }
  1341.  
  1342.  
  1343. function safeDestroy(el) {
  1344. if (!el) return;
  1345. try {
  1346. if (el instanceof HTMLElement || el instanceof HTMLImageElement || el instanceof HTMLVideoElement || el instanceof HTMLAudioElement) {
  1347. el.pause?.();
  1348. if (el.tagName === 'IMG' || el.tagName === 'VIDEO' || el.tagName === 'AUDIO') {
  1349. el.removeAttribute('src');
  1350. el.load?.();
  1351. }
  1352. if (el._fcxObserver) {
  1353. el._fcxObserver.disconnect();
  1354. el._fcxObserver = null;
  1355. }
  1356. el.remove?.();
  1357. }
  1358. preloadCache.delete(el);
  1359. mediaMap.delete(el);
  1360. } catch (err) {
  1361. console.error('[FCX] safeDestroy error', err);
  1362. }
  1363. }
  1364.  
  1365. function handleIdHover(idText, event) {
  1366. const normId = idText.trim().toLowerCase();
  1367. if (!normId || !fcxInstance?.threadJson?.length) return;
  1368.  
  1369. const count = fcxInstance.threadJson.filter(post =>
  1370. post?.id?.toLowerCase() === normId
  1371. ).length;
  1372.  
  1373. if (!count) return;
  1374.  
  1375. const tooltip = document.createElement('div');
  1376. tooltip.className = 'fcx-id-tooltip';
  1377. tooltip.textContent = `Posts by ID: ${count}`;
  1378.  
  1379. Object.assign(tooltip.style, {
  1380. position: 'absolute',
  1381. top: (event.clientY + 10) + 'px',
  1382. left: (event.clientX + 10) + 'px',
  1383. background: 'rgba(0,0,0,0.8)',
  1384. color: 'white',
  1385. padding: '4px 8px',
  1386. borderRadius: '4px',
  1387. fontSize: '13px',
  1388. zIndex: 10000,
  1389. pointerEvents: 'none'
  1390. });
  1391.  
  1392. document.body.appendChild(tooltip);
  1393.  
  1394. const remove = () => tooltip.remove();
  1395. event.target.addEventListener('mouseleave', remove, { once: true });
  1396. }
  1397.  
  1398. let isHoveringPreview = false; // Place near top of your script
  1399.  
  1400. class FullchanX extends HTMLElement {
  1401. quoteMap = new WeakMap();
  1402.  
  1403.  
  1404. idleSince = Date.now();
  1405. isIdle = false;
  1406. idleTimeout = 5000; // 5 seconds
  1407.  
  1408. async fetchAndRenderSinglePost(postId) {
  1409. const meta = this.getThreadMeta?.();
  1410. if (!meta) return;
  1411. const { board, tid } = meta;
  1412.  
  1413. try {
  1414. const url = `/${board}/res/${tid}.json`;
  1415. const res = await fetch(url);
  1416. const json = await res.json();
  1417.  
  1418. const postData = json.posts?.find(p => p.postId == postId);
  1419. if (!postData) {
  1420. console.warn('[FCX] New post ID not found in thread JSON:', postId);
  1421. return;
  1422. }
  1423.  
  1424. // 👉 Check if post already exists
  1425. if (document.getElementById(postId)) {
  1426. return;
  1427. }
  1428.  
  1429. // 👉 1. Add post to internal data
  1430. this.threadJson.push(postData);
  1431. this.totalPages = Math.ceil(this.threadJson.length / this.postsPerPage);
  1432.  
  1433. // 👉 2. Update backlinks map
  1434. this.backlinksMap = this.buildBacklinksMap(this.threadJson);
  1435.  
  1436. // 👉 3. Render and insert
  1437. const el = this.renderPostFromJson(postData);
  1438. this.thread.appendChild(el);
  1439. shortenOriginalFilenames(el);
  1440. this.markCrossThreadQuotes(el);
  1441.  
  1442. // After you render the new post (el)...
  1443. const newPostId = postData.postId;
  1444.  
  1445. // For every post that is quoted by this new post
  1446. const quoteLinks = el.querySelectorAll('a.quoteLink, a.quotelink');
  1447. quoteLinks.forEach(link => {
  1448. const match = link.textContent.match(/^>>(\d+)$/);
  1449. if (!match) return;
  1450. const quotedId = match[1];
  1451.  
  1452. const quotedPost = document.getElementById(quotedId);
  1453. if (!quotedPost) return; // Post not on page (yet)
  1454.  
  1455. const backlinksContainer = quotedPost.querySelector('.panelBacklinks');
  1456. if (!backlinksContainer) return;
  1457.  
  1458. // Check if backlink already exists
  1459. if (!backlinksContainer.querySelector(`a[href="#q${newPostId}"]`)) {
  1460. const backlink = document.createElement('a');
  1461. backlink.href = `#q${newPostId}`;
  1462. backlink.textContent = `>>${newPostId}`;
  1463. backlinksContainer.appendChild(backlink);
  1464. }
  1465. });
  1466.  
  1467.  
  1468. // 👉 4. Update cached post list
  1469. this.postElements.push(el);
  1470.  
  1471. // 👉 5. Update "You"s
  1472. this.refreshYous(el);
  1473.  
  1474. // 👉 6. Update single posters
  1475. scanForSinglePosters(postData);
  1476.  
  1477. // 👉 7. Update pagination controls
  1478. this.updatePaginationNav();
  1479. if (this._pageIndicator && this.totalPages > 0) {
  1480. this._pageIndicator.textContent = `${this.currentPage + 1} / ${this.totalPages}`;
  1481. }
  1482.  
  1483. // 👉 10. Apply hidden filters
  1484. applyHiddenFilters(el);
  1485. markQuotesToYou(el);
  1486.  
  1487. const nearBottom = (window.innerHeight + window.scrollY) >= (document.body.offsetHeight - 120);
  1488. if (this.settings.autoScrollOnIdle && (this.isIdle || nearBottom)) {
  1489. el.scrollIntoView({ behavior: 'smooth', block: 'center' });
  1490.  
  1491. }
  1492.  
  1493. } catch (err) {
  1494. console.error('[FCX] Failed to fetch/render new post:', err);
  1495. }
  1496. }
  1497.  
  1498. getIdle() {
  1499. const resetIdle = () => {
  1500. this.idleSince = Date.now();
  1501. this.isIdle = false;
  1502. };
  1503.  
  1504. this._idleListeners = ['scroll', 'mousemove', 'keydown', 'click', 'touchstart'].map(evt => {
  1505. const handler = resetIdle;
  1506. window.addEventListener(evt, handler, { passive: true });
  1507. return { evt, handler };
  1508. });
  1509.  
  1510. let wasIdle = false;
  1511.  
  1512. this._idleInterval = setInterval(() => {
  1513. const nowIdle = (Date.now() - this.idleSince) >= this.idleTimeout;
  1514.  
  1515. if (nowIdle && !wasIdle) {
  1516. this.fillPreloadCacheUntilFull(); // 🔁 Make sure this function is available in scope
  1517. pruneCachesByDistance();
  1518. pruneMediaMapByDistance();
  1519. }
  1520.  
  1521. wasIdle = nowIdle;
  1522. this.isIdle = nowIdle;
  1523. }, 1000);
  1524. intervals.add(this._idleInterval);
  1525.  
  1526. }
  1527.  
  1528. createYouBox() {
  1529. if (this.youBox) return; // Already exists
  1530. this.youBox = this.panel('fcx-yous', 'My (You)s', { right: '1rem', top: '3rem' });
  1531. }
  1532.  
  1533. connectedCallback() {
  1534. document.getElementById('dynamicAnnouncement')?.remove();
  1535. document.getElementById('navTopBoardsSpan')?.remove();
  1536. document.getElementById('navBoardsSpan')?.remove();
  1537.  
  1538. this.currentPage = 0;
  1539. this.postsPerPage = 250;
  1540. this.threadJson = [];
  1541. window.fcxInstance = this;
  1542. this.totalPages = 1;
  1543.  
  1544. if (this._fcxConnected) return;
  1545. this._fcxConnected = true;
  1546. this.initCritical();
  1547. loadHiddenFilters();
  1548.  
  1549. this._aRaf11 = requestAnimationFrame(() => this.initAfterPaint());
  1550. this._request4 = requestIdleCallback(() => this.initIdleExtras());
  1551. shortenOriginalFilenames();
  1552. this._threadClickHandler = e => {
  1553. const link = e.target.closest('a.quoteLink, a.quotelink');
  1554. if (!link || !link.href.includes('#')) return;
  1555.  
  1556. const { postId, threadId } = parseQuoteHref(link) || {};
  1557. if (!postId) return;
  1558.  
  1559. e.preventDefault();
  1560.  
  1561. const inPanel = link.closest('[id^="fcx-ids-"]');
  1562.  
  1563. if (inPanel) {
  1564. this.jump(postId);
  1565. } else {
  1566. this.toggleQuote(postId, link, false, threadId);
  1567. }
  1568. };
  1569.  
  1570. document.addEventListener('click', this._threadClickHandler, true);
  1571.  
  1572. // 🧠 ID click
  1573. this._clickHandler = e => {
  1574. const pn = e.target.closest('a.postNum, a.post_no, a.no, a.linkQuote');
  1575. if (pn) {
  1576. e.preventDefault();
  1577. const id = pn.hash.slice(1).replace(/^q/, '');
  1578. const quoteLine = '>>' + id;
  1579.  
  1580. // Show the reply box
  1581. this.qBox.style.display = 'block';
  1582.  
  1583. // Delay to allow site-native quote handler to run
  1584. const timeout = setTimeout(() => {
  1585. const alreadyThere = this.qBody.value.includes(quoteLine);
  1586. if (!alreadyThere) {
  1587. this.qBody.value += quoteLine + '\n';
  1588. }
  1589.  
  1590. this.qBody.focus();
  1591. }, 100);
  1592. timeouts.add(timeout);
  1593.  
  1594. return;
  1595. }
  1596.  
  1597. const idEl = e.target.closest('.labelId');
  1598. if (idEl) {
  1599. e.preventDefault();
  1600. this.openIdBox(idEl.textContent);
  1601. }
  1602. };
  1603. document.addEventListener('click', this._clickHandler, true);
  1604.  
  1605.  
  1606. document.addEventListener('mouseover', e => {
  1607. const label = e.target.closest('.labelId');
  1608. if (!label) return;
  1609.  
  1610. const originalText = label.textContent.trim();
  1611. const normId = originalText.toLowerCase();
  1612. if (!normId || !fcxInstance?.threadJson?.length) return;
  1613.  
  1614. const count = fcxInstance.threadJson.filter(post =>
  1615. post?.id?.toLowerCase() === normId
  1616. ).length;
  1617.  
  1618. if (count <= 1) return; // optional: don't bother showing (1) if only 1 post
  1619.  
  1620. label.textContent = `${originalText} (${count})`;
  1621.  
  1622. label.addEventListener('mouseleave', () => {
  1623. label.textContent = originalText;
  1624. }, { once: true });
  1625. });
  1626.  
  1627.  
  1628. // 🧠 Quote fallback preview
  1629. document.addEventListener('pointerover', e => {
  1630. const qLink = e.target.closest('a[href*="#"]');
  1631. if (!qLink || qLink.classList.contains('linkQuote')) return; // skip post number links
  1632.  
  1633. this._request1 = requestIdleCallback(async () => {
  1634. if (qLink._fcxHoverQuote) return;
  1635.  
  1636. const href = qLink.getAttribute('href') || '';
  1637. const cross = href.match(/\/res\/(\d+)\.html#q?(\d+)/);
  1638. const local = href.match(/#q?(\d+)/);
  1639.  
  1640. let threadId = null, postId = null;
  1641.  
  1642. if (cross) {
  1643. threadId = cross[1];
  1644. postId = cross[2];
  1645. } else if (local) {
  1646. threadId = fcxInstance?.tid;
  1647. postId = local[1];
  1648. }
  1649.  
  1650. if (!postId) return;
  1651.  
  1652. if (document.querySelector('.quoteTooltip')) return;
  1653.  
  1654. let src = document.getElementById(postId);
  1655.  
  1656. // Try current threadJson
  1657. if (!src && threadId === fcxInstance?.tid && fcxInstance?.threadJson?.length) {
  1658. const postData = fcxInstance.threadJson.find(p => String(p.postId) === String(postId));
  1659. if (postData) {
  1660. src = fcxInstance.renderPostFromJson(postData);
  1661. }
  1662. }
  1663.  
  1664. // Try cache
  1665. if (!src && threadId && threadId !== fcxInstance?.tid) {
  1666. const cached = getCachedCrossPost(threadId, postId);
  1667. if (cached) {
  1668. src = fcxInstance.renderPostFromJson(cached);
  1669. }
  1670. }
  1671.  
  1672. // Fetch cross-thread JSON if not cached
  1673. if (!src && threadId && threadId !== fcxInstance?.tid) {
  1674. try {
  1675. const board = fcxInstance.getThreadMeta()?.board;
  1676. const res = await fetch(`/${board}/res/${threadId}.json`);
  1677. const json = await res.json();
  1678.  
  1679. let postData = json.posts?.find(p => String(p.postId) === String(postId));
  1680.  
  1681. // Special case: OP post is not in posts[] array
  1682. if (!postData && String(threadId) === String(postId)) {
  1683. postData = {
  1684. postId: threadId,
  1685. threadId: threadId,
  1686. subject: json.subject,
  1687. markdown: json.markdown,
  1688. message: json.message,
  1689. name: json.name,
  1690. email: json.email,
  1691. files: json.files || []
  1692. };
  1693. }
  1694.  
  1695. if (postData) {
  1696. cacheCrossPost(threadId, postId, postData); // ✅ cache it
  1697. src = fcxInstance.renderPostFromJson(postData);
  1698. }
  1699. } catch (err) {
  1700. console.warn('[FCX] Hover preview cross-thread fetch failed:', threadId, postId, err);
  1701. return;
  1702. }
  1703. }
  1704.  
  1705. if (!src) return;
  1706.  
  1707. const body = src.querySelector('.innerPost, .postMessage, .divMessage');
  1708. if (!body) return;
  1709.  
  1710. const preview = document.createElement('div');
  1711. preview.className = 'fcx-hover-quote';
  1712. Object.assign(preview.style, {
  1713. position: 'absolute',
  1714. zIndex: '10000',
  1715. pointerEvents: 'none',
  1716. maxWidth: '60vw',
  1717. maxHeight: '50vh',
  1718. overflow: 'auto',
  1719. background: 'var(--contrast-color)',
  1720. border: '1px solid var(--navbar-text-color)',
  1721. padding: '6px',
  1722. borderRadius: '6px',
  1723. font: '14px sans-serif',
  1724. boxShadow: '0 2px 8px rgba(0,0,0,0.3)'
  1725. });
  1726.  
  1727. preview.appendChild(body.cloneNode(true));
  1728. document.body.appendChild(preview);
  1729. qLink._fcxHoverQuote = preview;
  1730.  
  1731. const rect = qLink.getBoundingClientRect();
  1732. let left = rect.right + 10;
  1733. const maxLeft = window.innerWidth - preview.offsetWidth - 10;
  1734. if (left > maxLeft) {
  1735. left = Math.max(rect.left - preview.offsetWidth - 10, 10);
  1736. }
  1737.  
  1738. preview.style.left = `${left}px`;
  1739. preview.style.top = `${rect.top + window.scrollY}px`;
  1740.  
  1741. const cleanup = () => {
  1742. cleanupHoverQuote();
  1743. if (qLink._fcxHoverQuote) {
  1744. qLink._fcxHoverQuote.remove();
  1745. delete qLink._fcxHoverQuote;
  1746. }
  1747. const nested = document.querySelectorAll('.nestedPost');
  1748. nested.forEach(n => {
  1749. if (!document.body.contains(qLink)) n.remove();
  1750. });
  1751. };
  1752.  
  1753. const checkMouse = () => {
  1754. if (!qLink.matches(':hover') && !preview.matches(':hover')) {
  1755. cleanup();
  1756. window.removeEventListener('mousemove', checkMouse);
  1757. }
  1758. };
  1759.  
  1760. this._hoverQuoteMouseHandler = checkMouse;
  1761. window.addEventListener('mousemove', this._hoverQuoteMouseHandler);
  1762.  
  1763. }, { timeout: 150 });
  1764. }, { passive: true });
  1765.  
  1766.  
  1767. // 🖼️ Hover preview
  1768. this._pointerOverHandler = e => {
  1769. let a = e.target.closest('a[data-filemime]');
  1770. if (!a && e.target.matches('.spoiler, .spoiler *')) {
  1771. a = e.target.closest('.spoiler')?.querySelector('a[data-filemime]');
  1772. }
  1773.  
  1774. if (!a || a.dataset._fcxHoverActive === 'true') return;
  1775.  
  1776. const url = a.href;
  1777. const mime = a.dataset.filemime?.toLowerCase() || '';
  1778. if (!OK_MIME.test(mime) && !OK_EXT.test(url)) return;
  1779. if (preloadFailures.get(url) >= 2) {
  1780. showHoverLoadError(a);
  1781. return;
  1782. }
  1783.  
  1784. a.dataset._fcxHoverActive = 'true';
  1785. isHoveringPreview = true;
  1786. this._activePreviewAnchor?.dispatchEvent(new Event('mouseleave'));
  1787. this._activePreviewAnchor = a;
  1788.  
  1789. const cancelHover = () => {
  1790. isHoveringPreview = false;
  1791.  
  1792. // ✨ SAFETY: Do NOT cancel any in-progress preload
  1793. const cached = preloadCache.get(url);
  1794. if (cached?._fcxReady) {
  1795. cleanupHoverMedia(a); // ✅ Safe to remove displayed media
  1796. } else {
  1797. const meta = mediaMap.get(a);
  1798. if (meta?.spinner) meta.spinner.remove?.(); // Hide spinner if still loading
  1799. mediaMap.delete(a); // Don't clear preloadCache or activePreloads!
  1800. }
  1801. };
  1802.  
  1803. const checkLeave = e => {
  1804. if (!a.matches(':hover')) {
  1805. cancelHover();
  1806. window.removeEventListener('mousemove', checkLeave);
  1807. }
  1808. };
  1809. let rafPending = false;
  1810.  
  1811. function throttledCheckLeave() {
  1812. if (rafPending) return;
  1813. rafPending = true;
  1814. this._aRaf5 = requestAnimationFrame(() => {
  1815. checkLeave();
  1816. rafPending = false;
  1817. });
  1818. }
  1819. window.addEventListener('mousemove', throttledCheckLeave);
  1820.  
  1821. a.addEventListener('mouseleave', cancelHover, { once: true });
  1822. a.addEventListener('pointercancel', cancelHover, { once: true });
  1823.  
  1824. const applyMediaStyles = (el) => {
  1825. Object.assign(el.style, STYLE_HOVER_MEDIA);
  1826. };
  1827.  
  1828. // Show loading spinner immediately on hover
  1829. const spinner = document.createElement('div');
  1830. Object.assign(spinner.style, STYLE_SPINNER);
  1831. spinner.className = 'fcx-spinner';
  1832. spinner.textContent = '⏳ Loading ⏳';
  1833. document.body.appendChild(spinner);
  1834.  
  1835. const limit = fcxInstance?.settings?.mediaCacheSize ?? 50;
  1836. while (mediaMap.size >= limit) {
  1837. for (const [anchor] of mediaMap.entries()) {
  1838. if (!isProtected(anchor)) {
  1839. cleanupHoverMedia(anchor);
  1840. mediaMap.delete(anchor);
  1841. break;
  1842. }
  1843. }
  1844. }
  1845. mediaMap.set(a, { ...mediaMap.get(a), spinner });
  1846.  
  1847. const anchorRect = a.getBoundingClientRect();
  1848. spinner.style.top = `${anchorRect.top + window.scrollY}px`;
  1849. spinner.style.left = `${anchorRect.left + window.scrollX}px`;
  1850.  
  1851.  
  1852. let tries = 0;
  1853. const tryShow = () => {
  1854. const data = mediaMap.get(a);
  1855. let el = data?.img || data?.media;
  1856.  
  1857. if (!el && document.body.contains(a)) {
  1858. // Inject new element if none exists
  1859. const cachedEl = preloadCache.get(url);
  1860. const srcToUse = (cachedEl && cachedEl._fcxReady && cachedEl.src) ? cachedEl.src : url;
  1861.  
  1862. if (mime.startsWith('video/')) {
  1863. el = document.createElement('video');
  1864. el.src = srcToUse;
  1865. el.controls = false;
  1866. el.autoplay = true;
  1867. el.loop = true;
  1868. } else if (mime.startsWith('audio/')) {
  1869. el = document.createElement('audio');
  1870. el.src = srcToUse;
  1871. el.controls = true;
  1872. el.autoplay = true;
  1873. el.loop = true;
  1874. } else {
  1875. el = document.createElement('img');
  1876. el.src = srcToUse;
  1877. }
  1878.  
  1879. mediaMap.set(a, { ...data, media: el });
  1880. }
  1881.  
  1882. if (el && document.body.contains(a)) {
  1883. if (!document.body.contains(el)) {
  1884. el.dataset.fcxHover = 'true';
  1885.  
  1886. if (el.tagName === 'VIDEO' || el.tagName === 'AUDIO') {
  1887. el.style.display = 'block'; // important for video/audio
  1888. el.autoplay = true;
  1889. el.muted = false;
  1890. el.volume = this.settings.volume;
  1891. el.play()?.catch(err => {
  1892. console.warn('[FCX] play() failed:', err);
  1893. });
  1894. }
  1895.  
  1896. applyMediaStyles(el);
  1897.  
  1898. // 💡 Optional spoiler blur
  1899. if (this.settings.blurSpoilerHover) {
  1900. const thumb = a.querySelector('img');
  1901. const isSpoiler = thumb?.src?.includes('custom.spoiler') || false;
  1902. if (isSpoiler) {
  1903. el.classList.add('fcx-hover-blurred');
  1904.  
  1905. if (!this._blurredHoverEls) this._blurredHoverEls = new Set();
  1906. this._blurredHoverEls.add(el);
  1907. }
  1908. }
  1909.  
  1910. this._aRaf6 = requestAnimationFrame(() => {
  1911. document.body.appendChild(el);
  1912. });
  1913.  
  1914. // ✅ Remove spinner once media is shown
  1915. mediaMap.get(a)?.spinner?.remove?.();
  1916. }
  1917. } else if (++tries < 25) {
  1918. const timeout = setTimeout(tryShow, 200);
  1919. timeouts.add(timeout);
  1920. } else {
  1921. console.warn('[FCX] Hover media failed after retries:', a.href);
  1922. showHoverLoadError(a);
  1923. }
  1924. };
  1925.  
  1926. const cached = preloadCache.get(url);
  1927. if (cached && cached?._fcxReady) {
  1928. tryShow();
  1929. } else {
  1930. const rect = a.closest('.post, .postCell, .opCell')?.getBoundingClientRect();
  1931. const viewportCenter = window.innerHeight / 2;
  1932. const dist = rect ? Math.abs((rect.top + rect.bottom) / 2 - viewportCenter) : Infinity;
  1933.  
  1934. queuePreloadImage(url, mime, undefined, 'high', dist);
  1935. }
  1936.  
  1937. };
  1938. document.addEventListener('pointerover', this._pointerOverHandler, { passive: true });
  1939. }
  1940.  
  1941. async initCritical() {
  1942. this._pendingPreloads = new Map();
  1943. this._lastRefreshAttempt = 0;
  1944. this.lastKnownPostTime = Date.now();
  1945.  
  1946. this.thread = QS('.divPosts');
  1947. if (!this.thread) {
  1948. console.warn('[FCX] Thread container not found');
  1949. return;
  1950. }
  1951.  
  1952. this.paginationNav = document.createElement('div');
  1953. this.paginationNav.id = 'fcx-pagination';
  1954. Object.assign(this.paginationNav.style, {
  1955. textAlign: 'center',
  1956. margin: '16px 0',
  1957. fontSize: '14px'
  1958. });
  1959. this.thread.before(this.paginationNav);
  1960.  
  1961. const op = QS('.opCell');
  1962. if (!op?.id) {
  1963. console.warn('[FCX] OP post not found');
  1964. return;
  1965. }
  1966.  
  1967. this.tid = op.id.replace(/^p/, '') || 'thread';
  1968. this.seenKey = `${this.tid}-seen-yous`;
  1969.  
  1970. this.loadSettings(); // << important, before using settings!
  1971.  
  1972. if (this.settings.hideStubs) document.body.classList.add('fcx-hide-stubs');
  1973. if (this.settings.anonymize) document.body.classList.add('fcx-anonymize');
  1974.  
  1975. this.staleAge = this.settings.staleAge ?? 10000;
  1976. this.refreshPoll = this.settings.refreshPoll ?? 5000;
  1977.  
  1978. if (this.settings.showYous) {
  1979. this.createYouBox();
  1980. }
  1981.  
  1982. // ✅ Only after setting up thread + settings → now fetch the JSON
  1983. const meta = this.getThreadMeta?.();
  1984. if (!meta) {
  1985. console.warn('[FCX] Could not parse thread meta');
  1986. return;
  1987. }
  1988.  
  1989. const { board, tid } = meta;
  1990.  
  1991. try {
  1992. const res = await fetch(`/${board}/res/${tid}.json`);
  1993. const json = await res.json();
  1994.  
  1995. if (!Array.isArray(json.posts)) {
  1996. console.warn('[FCX] Thread JSON malformed');
  1997. return;
  1998. }
  1999. this.createOp(json);
  2000. this.threadJson = json.posts;
  2001. this.totalPages = Math.ceil(this.threadJson.length / this.postsPerPage);
  2002. if (this._pageIndicator && this.totalPages > 0) {
  2003. this._pageIndicator.textContent = `${this.currentPage + 1} / ${this.totalPages}`;
  2004. }
  2005.  
  2006. if ( !window.location.hash) {
  2007. this.renderPage(0); // ✅ safe to render now!
  2008. } else {
  2009. this.handleInitialAnchor();
  2010. }
  2011. } catch (err) {
  2012. console.error('[FCX] Failed to fetch thread JSON:', err);
  2013. }
  2014.  
  2015. }
  2016.  
  2017. createOp(json) {
  2018. // Extract OP fields
  2019. const { subject, message, markdown, name, email, id, files, threadId, creation } = json;
  2020.  
  2021. // Create OP post object
  2022. this.opPost = {
  2023. postId: threadId,
  2024. threadId,
  2025. subject,
  2026. message,
  2027. markdown,
  2028. name: name || "Anonymous",
  2029. email,
  2030. id,
  2031. files: files || [],
  2032. creation: creation || new Date().toISOString()
  2033. };
  2034. }
  2035.  
  2036. initAfterPaint() {
  2037. this.createSettingsIcon();
  2038. this.createFloatingPaginationNav();
  2039. this.bindFloatingPaginationNav();
  2040.  
  2041. this.getIdle();
  2042. document.addEventListener('click', e => {
  2043. const link = e.target.closest('a[data-filemime^="video/"]');
  2044. if (!link) return;
  2045.  
  2046. const existingFix = link.parentNode.querySelector(`video[data-fcx-manual="true"][src="${link.href}"]`);
  2047. if (existingFix) {
  2048. existingFix.remove(); // toggle off
  2049. return;
  2050. }
  2051.  
  2052. const timeout = setTimeout(() => {
  2053. const broken = !document.querySelector(`video[src="${link.href}"]`);
  2054. if (broken) {
  2055. injectMediaFallback(link);
  2056. }
  2057. }, 300);
  2058. timeouts.add(timeout);
  2059.  
  2060. });
  2061. }
  2062.  
  2063. initIdleExtras() {
  2064. this.postElements = QSA('.postCell, .opCell, .post', this.thread);
  2065.  
  2066. pruneCachesByDistance();
  2067. pruneMediaMapByDistance();
  2068.  
  2069. }
  2070.  
  2071. updateFloatingPaginationText() {
  2072. if (this._pageIndicator && this.totalPages > 0) {
  2073. this._pageIndicator.textContent = `${this.currentPage + 1} / ${this.totalPages}`;
  2074. }
  2075. }
  2076.  
  2077.  
  2078. createFloatingPaginationNav() {
  2079. const existing = document.getElementById('fcx-floating-pagination');
  2080. if (existing) existing.remove();
  2081.  
  2082. const nav = document.createElement('div');
  2083. nav.id = 'fcx-floating-pagination';
  2084. Object.assign(nav.style, {
  2085. position: 'fixed',
  2086. bottom: '1rem',
  2087. right: '3.5rem',
  2088. zIndex: 10000,
  2089. display: 'flex',
  2090. gap: '8px',
  2091. alignItems: 'center',
  2092. background: 'var(--contrast-color)',
  2093. border: '1px solid var(--navbar-text-color)',
  2094. borderRadius: '6px',
  2095. padding: '4px 8px',
  2096. font: '14px sans-serif'
  2097. });
  2098.  
  2099. nav.innerHTML = `
  2100. <button id="fcx-page-prev">⬅</button>
  2101. <span id="fcx-page-indicator">1 / ?</span> <!-- placeholder initially -->
  2102. <button id="fcx-page-next">➡</button>
  2103. `;
  2104.  
  2105. document.body.appendChild(nav);
  2106.  
  2107. this._floatingPagination = nav;
  2108. this._pageIndicator = nav.querySelector('#fcx-page-indicator');
  2109.  
  2110. this.bindFloatingPaginationNav();
  2111. this.updateFloatingPaginationText(); // ✅ Immediately update number if possible
  2112. }
  2113.  
  2114. bindFloatingPaginationNav() {
  2115. const prevBtn = document.getElementById('fcx-page-prev');
  2116. const nextBtn = document.getElementById('fcx-page-next');
  2117.  
  2118. if (prevBtn) prevBtn.disabled = (this.currentPage === 0);
  2119. if (nextBtn) nextBtn.disabled = (this.currentPage + 1 >= this.totalPages);
  2120.  
  2121. if (prevBtn) prevBtn.onclick = () => this.renderPage(this.currentPage - 1);
  2122. if (nextBtn) nextBtn.onclick = () => this.renderPage(this.currentPage + 1);
  2123. }
  2124.  
  2125. initWebSocket() {
  2126. const meta = this.getThreadMeta?.();
  2127. if (!meta) return;
  2128.  
  2129. const { board, tid } = meta;
  2130.  
  2131. const hostname = location.hostname.replace(/^www\./, '');
  2132. const wsUrl = `wss://${hostname}:8888/`;
  2133.  
  2134. try {
  2135. this.ws = new WebSocket(wsUrl);
  2136.  
  2137. this.ws.onopen = () => {
  2138.  
  2139. const joinKey = `${board}-${tid}`;
  2140.  
  2141. try {
  2142. this.ws.send(joinKey);
  2143.  
  2144. // 👉 Stop the fallback poller if running
  2145. if (this._refreshTimeout) {
  2146. clearTimeout(this._refreshTimeout);
  2147. this._refreshTimeout = null;
  2148. }
  2149.  
  2150. } catch (err) {
  2151. console.warn('[FCX] WebSocket send failed:', err);
  2152. }
  2153. };
  2154.  
  2155. this.ws.onmessage = (event) => {
  2156. console.debug('[FCX] WebSocket message:', event.data);
  2157. let data;
  2158.  
  2159. try {
  2160. data = JSON.parse(event.data);
  2161. } catch (err) {
  2162. console.warn('[FCX] WebSocket invalid message:', err);
  2163. return;
  2164. }
  2165.  
  2166. if (data?.action === 'post' && Array.isArray(data.target)) {
  2167. for (const postId of data.target) {
  2168. this.fetchAndRenderSinglePost(postId);
  2169. }
  2170. }
  2171. };
  2172.  
  2173.  
  2174. this.ws.onclose = () => {
  2175. console.warn('[FCX] WebSocket closed.');
  2176. this.ws = null;
  2177. this.tryReconnectWebSocket();
  2178. this.startPollingFallback();
  2179. };
  2180.  
  2181. this.ws.onerror = (err) => {
  2182. console.error('[FCX] WebSocket error:', err);
  2183. if (this.ws) this.ws.close(); // Ensure it's closed properly
  2184. this.tryReconnectWebSocket();
  2185. this.startPollingFallback(); // 👉 Only start fallback if WebSocket really fails
  2186.  
  2187. };
  2188.  
  2189. } catch (err) {
  2190. console.error('[FCX] WebSocket setup failed:', err);
  2191. this.startPollingFallback();
  2192. }
  2193. }
  2194.  
  2195. tryReconnectWebSocket() {
  2196. const now = Date.now();
  2197. const MIN_RECONNECT_INTERVAL = 60000; // 60 seconds
  2198.  
  2199. if (now - this._lastWsReconnectAttempt < MIN_RECONNECT_INTERVAL) {
  2200. return;
  2201. }
  2202.  
  2203. this._lastWsReconnectAttempt = now;
  2204.  
  2205. // 👉 Before reconnecting, clear fallback polling if active
  2206. if (this._refreshTimeout) {
  2207. clearTimeout(this._refreshTimeout);
  2208. this._refreshTimeout = null;
  2209. }
  2210.  
  2211. setTimeout(() => {
  2212. this.initWebSocket();
  2213. }, 500); // tiny delay to give browser time to close old socket
  2214. }
  2215.  
  2216. startPollingFallback() {
  2217. if (this._refreshTimeout) {
  2218. return;
  2219. }
  2220. if (this._refreshTimeout) clearTimeout(this._refreshTimeout);
  2221.  
  2222. const loop = async () => {
  2223. await this.checkThreadFreshness();
  2224. this._refreshTimeout = setTimeout(loop, 20000); // every 30s
  2225. };
  2226.  
  2227. loop();
  2228. }
  2229.  
  2230.  
  2231. updatePaginationNav() {
  2232. if (!this.paginationNav) return;
  2233.  
  2234. this.paginationNav.innerHTML = `
  2235. <button ${this.currentPage === 0 ? 'disabled' : ''}>⬅ Prev</button>
  2236. Page ${this.currentPage + 1} of ${this.totalPages}
  2237. <button ${this.currentPage + 1 >= this.totalPages ? 'disabled' : ''}>Next ➡</button>
  2238. `;
  2239.  
  2240. const [prevBtn, nextBtn] = this.paginationNav.querySelectorAll('button');
  2241.  
  2242. if (prevBtn) prevBtn.onclick = () => this.renderPage(this.currentPage - 1);
  2243. if (nextBtn) nextBtn.onclick = () => this.renderPage(this.currentPage + 1);
  2244. }
  2245.  
  2246. async handleInitialAnchor() {
  2247. const hash = window.location.hash;
  2248.  
  2249. if (!hash) {
  2250. // No hash → stay at top, no special action needed
  2251. return;
  2252. }
  2253.  
  2254. if (hash === '#footer') {
  2255. await this.renderPage(this.totalPages - 1);
  2256.  
  2257. this._aRaf12 = requestAnimationFrame(() => {
  2258. window.scrollTo({ top: document.body.scrollHeight, behavior: 'smooth' });
  2259. });
  2260. return;
  2261. }
  2262.  
  2263. const postMatch = hash.match(/^#q?(\d+)$/);
  2264. if (postMatch) {
  2265. const postId = postMatch[1];
  2266.  
  2267. if (!this.threadJson?.length) {
  2268. // Wait for thread JSON if not ready
  2269. await new Promise(resolve => setTimeout(resolve, 250));
  2270. }
  2271.  
  2272. let pageIndex = 0;
  2273.  
  2274. const postIndex = this.threadJson.findIndex(p => String(p.postId) === String(postId));
  2275. if (postIndex !== -1) {
  2276. pageIndex = Math.floor(postIndex / this.postsPerPage);
  2277. }
  2278.  
  2279.  
  2280. await this.renderPage(pageIndex);
  2281.  
  2282. this._aRaf13 = requestAnimationFrame(() => {
  2283. const el = document.getElementById(postId);
  2284. if (el) {
  2285. el.scrollIntoView({ behavior: 'smooth', block: 'center' });
  2286. el.style.outline = '2px solid var(--link-color)';
  2287. setTimeout(() => el.style.outline = '', 1500);
  2288. }
  2289. });
  2290. }
  2291. }
  2292.  
  2293. buildBacklinksMap(posts) {
  2294. const backlinks = new Map(); // postId → array of replyPostIds
  2295.  
  2296. for (const post of posts) {
  2297. if (!post.markdown && !post.message) continue;
  2298.  
  2299. const html = post.markdown || post.message;
  2300. const matches = [...html.matchAll(/&gt;&gt;(\d{5,10})/g)];
  2301.  
  2302. for (const [, targetIdStr] of matches) {
  2303. const targetId = parseInt(targetIdStr);
  2304. if (!targetId) continue;
  2305.  
  2306. if (!backlinks.has(targetId)) {
  2307. backlinks.set(targetId, []);
  2308. }
  2309. backlinks.get(targetId).push(post.postId);
  2310. }
  2311. }
  2312.  
  2313. return backlinks;
  2314. }
  2315.  
  2316.  
  2317. fillPreloadCacheUntilFull() {
  2318. const mediaLimit = fcxInstance.settings.preloadPosts ?? 20;
  2319. if (preloadCache.size >= mediaLimit) return;
  2320.  
  2321. const posts = fcxInstance.postElements?.filter(post =>
  2322. post.querySelector('a[data-filemime]')
  2323. );
  2324. if (!posts?.length) return;
  2325.  
  2326. const viewportCenter = window.scrollY + window.innerHeight / 2;
  2327.  
  2328. const mediaPosts = posts.map(post => {
  2329. const rect = post.getBoundingClientRect();
  2330. const postCenter = window.scrollY + rect.top + rect.height / 2;
  2331. const dist = Math.abs(postCenter - viewportCenter);
  2332. return { post, dist };
  2333. }).sort((a, b) => a.dist - b.dist); // Closest first
  2334.  
  2335. let mediaQueued = 0;
  2336.  
  2337. for (const { post, dist } of mediaPosts) {
  2338. const priority = dist < 400 ? 'high' : 'low';
  2339.  
  2340. QSA('a[data-filemime]', post).forEach(anchor => {
  2341. const url = anchor.href;
  2342. const mime = anchor.dataset.filemime?.toLowerCase() || '';
  2343.  
  2344. if (!OK_MIME.test(mime) && !OK_EXT.test(url)) return;
  2345. if (preloadCache.has(url)) return;
  2346. if (mediaQueued >= mediaLimit) return;
  2347.  
  2348. mediaQueued++;
  2349. queuePreloadImage(url, mime, undefined, priority, dist); // 🛠 Pass mime correctly now
  2350. });
  2351.  
  2352. if (mediaQueued >= mediaLimit) break;
  2353. }
  2354.  
  2355. pruneCachesByDistance();
  2356. }
  2357.  
  2358. autoCloseReplyOnPost() {
  2359. const form = this.qBox?.querySelector('form');
  2360. const container = this.thread;
  2361. const body = this.qBody;
  2362.  
  2363. if (!form || !container || !body) return;
  2364. if (!myPosterId) return; // 🚨 Require known poster ID now
  2365. if (this._replySubmitHooked) return;
  2366.  
  2367. this._replySubmitHooked = true;
  2368.  
  2369. form.addEventListener('submit', () => {
  2370. // Save current post IDs by your poster ID
  2371. const priorYourPosts = new Set(
  2372. QSA('.postCell, .opCell').filter(p => {
  2373. const idLabel = p.querySelector('.labelId');
  2374. return idLabel && idLabel.textContent.trim().toLowerCase() === myPosterId;
  2375. }).map(p => p.id)
  2376. );
  2377.  
  2378. const checkForNewYouPost = () => {
  2379. const newYourPost = QSA('.postCell, .opCell').find(p => {
  2380. const idLabel = p.querySelector('.labelId');
  2381. const matchesYou = idLabel && idLabel.textContent.trim().toLowerCase() === myPosterId;
  2382. return matchesYou && !priorYourPosts.has(p.id);
  2383. });
  2384.  
  2385. if (newYourPost) {
  2386. console.debug('[FCX] Detected new (You) post, closing QR.');
  2387. this.qBox.style.display = 'none';
  2388. this.qBody.value = '';
  2389.  
  2390. const fileDropzone = this.qBox.querySelector('#dropzoneQr');
  2391. const selectedDiv = this.qBox.querySelector('#selectedDivQr');
  2392. if (fileDropzone) fileDropzone.innerHTML = 'Drag files here to upload or <br>click here to select them';
  2393. if (selectedDiv) selectedDiv.innerHTML = '';
  2394.  
  2395. clearInterval(timer);
  2396. clearTimeout(timeout);
  2397. }
  2398. };
  2399.  
  2400. const timer = setInterval(checkForNewYouPost, 500); // every 500ms
  2401. intervals.add(timer);
  2402.  
  2403. const timeout = setTimeout(() => {
  2404. console.warn('[FCX] Auto-close fallback triggered.');
  2405. clearInterval(timer);
  2406.  
  2407. if (this.settings.closeReplyBox) {
  2408. this.qBox.style.display = 'none';
  2409. this.qBody.value = '';
  2410.  
  2411. const fileDropzone = this.qBox.querySelector('#dropzoneQr');
  2412. const selectedDiv = this.qBox.querySelector('#selectedDivQr');
  2413. if (fileDropzone) fileDropzone.innerHTML = 'Drag files here to upload or <br>click here to select them';
  2414. if (selectedDiv) selectedDiv.innerHTML = '';
  2415. }
  2416. }, 10000);
  2417. timeouts.add(timeout);
  2418. });
  2419. }
  2420.  
  2421. getThreadMeta() {
  2422. const match = location.pathname.match(/^\/([^/]+)\/res\/(\d+)\.html/);
  2423. if (!match) return null;
  2424. const [, board, tid] = match;
  2425. return { board, tid };
  2426. }
  2427.  
  2428. appendNewPosts(lastRenderedId) {
  2429. if (!Array.isArray(this.threadJson)) return;
  2430.  
  2431. const newPosts = this.threadJson.filter(p => p.postId > lastRenderedId);
  2432.  
  2433. if (!newPosts.length) return;
  2434.  
  2435. console.debug(`[FCX] Appending ${newPosts.length} new post(s)`);
  2436.  
  2437. for (const post of newPosts) {
  2438. const el = this.renderPostFromJson(post);
  2439. this.thread.appendChild(el);
  2440. shortenOriginalFilenames(el);
  2441. this.markCrossThreadQuotes(el);
  2442. }
  2443.  
  2444. this.postElements = QSA('.postCell, .opCell, .post', this.thread);
  2445. scanForSinglePosters();
  2446. }
  2447.  
  2448. async checkThreadFreshness() {
  2449.  
  2450. if (!this.thread || !Array.isArray(this.threadJson)) return;
  2451.  
  2452. // Skip freshness check unless on last page
  2453. if (this.currentPage + 1 < this.totalPages) return;
  2454.  
  2455. const now = Date.now();
  2456. if (this._refreshInFlight) return;
  2457. this._refreshInFlight = true;
  2458.  
  2459. try {
  2460.  
  2461. if (now - this._lastRefreshAttempt < this.staleAge) return;
  2462. this._lastRefreshAttempt = now;
  2463.  
  2464. const meta = this.getThreadMeta?.();
  2465. if (!meta) return;
  2466. const { board, tid } = meta;
  2467.  
  2468. const res = await fetch(`/${board}/res/${tid}.json`);
  2469.  
  2470. if (!res.ok) {
  2471. console.warn('[FCX] Thread JSON fetch failed:', res.status);
  2472. return; // STOP safely
  2473. }
  2474.  
  2475. const json = await res.json().catch(() => null); // ⬅️ Catch parse error too!
  2476.  
  2477. if (!json || !Array.isArray(json.posts)) {
  2478. console.warn('[FCX] Thread JSON malformed');
  2479. return; // STOP safely
  2480. }
  2481.  
  2482. const newPosts = json.posts;
  2483. const oldLastId = this.threadJson.at(-1)?.postId || 0;
  2484. const newLastId = newPosts.at(-1)?.postId || 0;
  2485.  
  2486. // No new posts to add
  2487. if (newLastId <= oldLastId) return;
  2488.  
  2489. const fresh = newPosts.filter(p => p.postId > oldLastId);
  2490.  
  2491. this.threadJson = newPosts;
  2492. this.totalPages = Math.ceil(newPosts.length / this.postsPerPage);
  2493. if (this.currentPage + 1 === this.totalPages) { // Only if on last page
  2494. const lastRenderedPost = this.thread.querySelector('.postCell:last-child, .opCell:last-child, .post:last-child');
  2495. const lastJsonPost = this.threadJson.at(-1);
  2496.  
  2497. if (lastRenderedPost && lastJsonPost) {
  2498. const renderedId = parseInt(lastRenderedPost.id.replace(/^p/, ''), 10);
  2499. const jsonId = lastJsonPost.postId;
  2500.  
  2501. if (renderedId !== jsonId) {
  2502. this.appendNewPosts(renderedId);
  2503. }
  2504. }
  2505. }
  2506.  
  2507. if (this.currentPage + 1 === this.totalPages) {
  2508. //this.renderPage(this.currentPage);
  2509.  
  2510. preloadQueue.length = 0;
  2511. activePreloads = 0;
  2512. preloadCache.clear();
  2513. mediaMap.clear();
  2514. timeouts.forEach(t => clearTimeout(t));
  2515. timeouts.clear();
  2516.  
  2517. this.fillPreloadCacheUntilFull();
  2518. pruneCachesByDistance();
  2519. pruneMediaMapByDistance();
  2520. }
  2521.  
  2522. this.postElements = QSA('.postCell, .opCell, .post', this.thread);
  2523. this.refreshYous();
  2524. scanForSinglePosters();
  2525.  
  2526. if (this._pageIndicator && this.totalPages > 0) {
  2527. this._pageIndicator.textContent = `${this.currentPage + 1} / ${this.totalPages}`;
  2528. }
  2529.  
  2530. this.lastKnownPostId = newLastId;
  2531. this.lastKnownPostTime = new Date(newPosts.at(-1)?.creation).getTime();
  2532. if (this.currentPage + 1 === this.totalPages) { // Only if on last page
  2533. const lastRenderedPost = this.thread.querySelector('.postCell:last-child, .opCell:last-child, .post:last-child');
  2534. const lastJsonPost = this.threadJson.at(-1);
  2535.  
  2536. if (lastRenderedPost && lastJsonPost) {
  2537. const renderedId = parseInt(lastRenderedPost.id.replace(/^p/, ''), 10);
  2538. const jsonId = lastJsonPost.postId;
  2539.  
  2540. if (renderedId !== jsonId) {
  2541. this.appendNewPosts(renderedId);
  2542. }
  2543. }
  2544. }
  2545.  
  2546. const nearBottom = (window.innerHeight + window.scrollY) >= (document.body.offsetHeight - 120);
  2547. if (this.settings.autoScrollOnIdle && (this.isIdle || nearBottom)) {
  2548. const timeout = requestAnimationFrame(() =>
  2549. setTimeout(() => {
  2550. window.scrollTo({ top: document.body.scrollHeight, behavior: 'smooth' });
  2551. }, 100)
  2552. );
  2553. timeouts.add(timeout);
  2554. }
  2555.  
  2556. } catch (err) {
  2557. console.error('[FCX] checkThreadFreshness error:', err);
  2558.  
  2559. } finally {
  2560. if (this._refreshTimeout) {
  2561. clearTimeout(this._refreshTimeout);
  2562. this._refreshTimeout = null;
  2563. }
  2564. this._refreshInFlight = false;
  2565. }
  2566. }
  2567.  
  2568. disconnectedCallback() {
  2569. this.cleanup();
  2570. }
  2571.  
  2572. superWipeAllBeforeRender() {
  2573.  
  2574. // 1. Brutally remove all post-related structure
  2575. document.querySelectorAll(
  2576. 'div.postCell, span.panelBacklinks, div.replyPreview, span.fcx-hover-quote, iframe, a.fcx-hover-quote, figure.uploadCell, div.divMessage, div.panelUploads, div.altBacklinks'
  2577. ).forEach(el => {
  2578. try {
  2579. el.remove();
  2580. } catch {}
  2581. });
  2582.  
  2583. // 2. Destroy all <img> with src attributes
  2584. document.querySelectorAll('img[src]').forEach(img => {
  2585. try {
  2586. img.src = '';
  2587. img.removeAttribute('src');
  2588. img.remove();
  2589. } catch {}
  2590. });
  2591.  
  2592. // 3. Extra: kill deep orphaned .postCell nodes and their children
  2593. document.querySelectorAll('.postCell').forEach(post => {
  2594. try {
  2595. post.querySelectorAll('input, a, span, summary, details, figure, div, img, iframe').forEach(child => {
  2596. child.remove();
  2597. });
  2598. post.remove();
  2599. } catch {}
  2600. });
  2601.  
  2602. // 4. Extra: kill leftover backlinks (deep)
  2603. document.querySelectorAll('.panelBacklinks a[href], .panelBacklinks iframe').forEach(el => {
  2604. try {
  2605. el.remove();
  2606. } catch {}
  2607. });
  2608.  
  2609. // 5. Clear preload caches
  2610. if (typeof preloadCache !== 'undefined') preloadCache.clear();
  2611. if (typeof mediaMap !== 'undefined') mediaMap.clear();
  2612. if (typeof preloadFailures !== 'undefined') preloadFailures.clear();
  2613.  
  2614. // 6. Clear quote and backlink caches
  2615. if (typeof recentQuoteStore !== 'undefined') recentQuoteStore.clear();
  2616. if (typeof fcxInstance !== 'undefined' && fcxInstance.quoteMap) {
  2617. if (typeof fcxInstance.quoteMap.clear === 'function') {
  2618. fcxInstance.quoteMap.clear();
  2619. } else {
  2620. fcxInstance.quoteMap = new WeakMap();
  2621. }
  2622. }
  2623.  
  2624. // 7. Clear ID-related maps
  2625. if (typeof idUsageCountMap !== 'undefined') idUsageCountMap.clear();
  2626. if (typeof singlePostIdMap !== 'undefined') singlePostIdMap.clear();
  2627.  
  2628. // 8. Clear intervals and timeouts
  2629. if (typeof timeouts !== 'undefined') {
  2630. timeouts.forEach(t => clearTimeout(t));
  2631. timeouts.clear();
  2632. }
  2633. if (typeof intervals !== 'undefined') {
  2634. intervals.forEach(i => clearInterval(i));
  2635. intervals.clear();
  2636. }
  2637.  
  2638. // 9. Clear seen posts and postElements
  2639. if (typeof fcxInstance !== 'undefined') {
  2640. if (Array.isArray(fcxInstance.postElements)) fcxInstance.postElements = [];
  2641. if (Array.isArray(fcxInstance.seen)) fcxInstance.seen = [];
  2642. if (typeof fcxInstance.existingPosts === 'object') fcxInstance.existingPosts = {};
  2643. if (typeof fcxInstance.knownPosts === 'object') fcxInstance.knownPosts = {};
  2644. }
  2645.  
  2646. }
  2647.  
  2648. renderPage(page) {
  2649. this.superWipeAllBeforeRender();
  2650.  
  2651. prepareToRenderPage();
  2652.  
  2653. if (window.validationWorker?.running) {
  2654. console.warn('[FCX] ValidationWorker running, delaying page wipe...');
  2655. setTimeout(() => this.renderPage(page), 2000);
  2656. return;
  2657. }
  2658.  
  2659. // Remove backlink and quote-related DOM elements
  2660. document.querySelectorAll('.panelBacklinks, .altBacklinks, .replyPreview, .fcx-hover-quote .linkSelf').forEach(el => el.remove());
  2661.  
  2662. // Clear internal backlink/quote caches
  2663. if (typeof recentQuoteStore !== 'undefined' && recentQuoteStore.clear) {
  2664. recentQuoteStore.clear();
  2665. }
  2666.  
  2667. if (typeof fcxInstance !== 'undefined' && fcxInstance.quoteMap) {
  2668. if (typeof fcxInstance.quoteMap.clear === 'function') {
  2669. fcxInstance.quoteMap.clear();
  2670. } else if (fcxInstance.quoteMap instanceof WeakMap) {
  2671. fcxInstance.quoteMap = new WeakMap(); // replace with a fresh WeakMap
  2672. }
  2673. }
  2674.  
  2675.  
  2676. // 🧹 Destroy lingering detached nodes
  2677. QSA('img, video, audio, source, iframe, figure, details, summary, a, span, input, div').forEach(el => {
  2678. if (!document.contains(el)) safeDestroy(el);
  2679. });
  2680.  
  2681. // 🧹 Clear stray event handlers
  2682. QSA('*').forEach(el => {
  2683. if (!document.contains(el)) {
  2684. el.onclick = el.onmouseenter = el.onmouseleave = null;
  2685. el.onmousemove = el.onmousedown = el.onmouseup = null;
  2686. el.onscroll = el.onwheel = null;
  2687. }
  2688. });
  2689.  
  2690. // 🛑 Cancel idle callbacks
  2691. for (let i = 1; i <= 4; i++) {
  2692. if (this[`_request${i}`]) {
  2693. cancelIdleCallback(this[`_request${i}`]);
  2694. this[`_request${i}`] = null;
  2695. }
  2696. }
  2697.  
  2698. // 🛑 Cancel animation frames
  2699. for (let i = 2; i <= 13; i++) {
  2700. if (this[`_aRaf${i}`]) {
  2701. cancelAnimationFrame(this[`_aRaf${i}`]);
  2702. this[`_aRaf${i}`] = null;
  2703. }
  2704. }
  2705.  
  2706. // 🛑 Cancel mutation debounce
  2707. if (typeof this.mutationDebounce !== 'undefined' && this.mutationDebounce) {
  2708. cancelIdleCallback(this.mutationDebounce);
  2709. this.mutationDebounce = null;
  2710. }
  2711.  
  2712. // 🛑 Cancel normal timeouts
  2713. timeouts.forEach(t => clearTimeout(t));
  2714. timeouts.clear();
  2715.  
  2716. // 🛑 Clear preload jobs
  2717. preloadQueue.length = 0;
  2718. activePreloads = 0;
  2719. preloadCache.forEach(el => safeDestroy(el));
  2720. preloadCache.clear();
  2721.  
  2722. // 🛑 Clear mediaMap
  2723. mediaMap.forEach(data => {
  2724. safeDestroy(data?.img);
  2725. safeDestroy(data?.media);
  2726. });
  2727. mediaMap.clear();
  2728.  
  2729.  
  2730. // 🧹 Extra clearing: hover previews, fallback videos, quotes
  2731. QSA('[data-fcxHover]').forEach(safeDestroy);
  2732. QSA('.nestedPost').forEach(safeDestroy);
  2733. QSA('video[data-fcxManual]').forEach(safeDestroy);
  2734. recentQuoteStore.clear();
  2735.  
  2736. // 🧹 Clear old thread DOM
  2737. if (this.thread) {
  2738. QSA('img, video, audio, source, a, figure', this.thread).forEach(safeDestroy);
  2739. forceClearAllMedia(this.thread);
  2740.  
  2741. if (!this.threadJson || !Array.isArray(this.threadJson) || this.threadJson.length === 0) {
  2742. console.error('[FCX-DEBUG] Thread JSON missing or empty before renderPage wipe!');
  2743. }
  2744.  
  2745. if (this.thread.nodeName === 'DIV' && !['BODY', 'HTML'].includes(this.thread.nodeName)) {
  2746. // 🧹 Fully clear all media and links inside thread first
  2747. QSA('img, video, audio, source, a', this.thread).forEach(el => {
  2748. safeDestroy(el);
  2749. });
  2750.  
  2751. QSA('div.postCell').forEach(el => safeDestroy(el));
  2752.  
  2753. //console.log('[FCX-DEBUG] Wiping thread innerHTML now...');
  2754. //console.log('[FCX-DEBUG] About to clear thread:', this.thread, 'nodeName:', this.thread?.nodeName, 'id:', this.thread?.id, 'class:', this.thread?.className);
  2755. this.thread.innerHTML = '';
  2756. } else {
  2757. console.warn('[FCX] Refusing to clear suspicious thread node:', this.thread?.nodeName);
  2758. }
  2759.  
  2760. } else {
  2761. console.warn('[FCX-DEBUG] Thread missing before wipe!');
  2762. }
  2763.  
  2764. // 🛡️ Only after DOM wipe, clean memory
  2765. //wipeAllListeners();
  2766. fullMemoryReset.call(this);
  2767.  
  2768. // ✅ Now safe to render new page
  2769. if (!this.thread || !Array.isArray(this.threadJson)) return;
  2770.  
  2771. if (!this.paginationNav) {
  2772. this.paginationNav = document.createElement('div');
  2773. this.paginationNav.id = 'fcx-pagination';
  2774. Object.assign(this.paginationNav.style, {
  2775. textAlign: 'center',
  2776. margin: '16px 0',
  2777. fontSize: '14px'
  2778. });
  2779. this.thread.before(this.paginationNav);
  2780. }
  2781.  
  2782. this.currentPage = Math.max(0, Math.min(page, this.totalPages - 1));
  2783.  
  2784. const start = this.currentPage * this.postsPerPage;
  2785. const end = Math.min(start + this.postsPerPage, this.threadJson.length);
  2786. const slice = this.threadJson.slice(start, end);
  2787.  
  2788. this.backlinksMap = this.buildBacklinksMap(this.threadJson);
  2789.  
  2790. if (slice.length === 0) {
  2791. console.warn(`[FCX] No posts to display on page ${this.currentPage + 1}`);
  2792. }
  2793.  
  2794. myPosterId = detectMyPosterIdFromYous();
  2795.  
  2796. const frag = document.createDocumentFragment();
  2797.  
  2798. if (this.opPost) {
  2799. const opEl = renderOP(this.opPost);
  2800. frag.appendChild(opEl);
  2801. }
  2802.  
  2803.  
  2804. for (const post of slice) {
  2805. const el = this.renderPostFromJson(post);
  2806. frag.appendChild(el);
  2807. }
  2808.  
  2809. // After loop finishes
  2810. this.thread.appendChild(frag);
  2811.  
  2812. markQuotesToYou();
  2813.  
  2814. this.postElements = QSA('.postCell, .opCell, .post', this.thread);
  2815. scanForSinglePosters();
  2816. this.updatePaginationNav();
  2817. shortenOriginalFilenames();
  2818. this.markCrossThreadQuotes();
  2819.  
  2820. if (this._pageIndicator && this.totalPages > 0) {
  2821. this._pageIndicator.textContent = `${this.currentPage + 1} / ${this.totalPages}`;
  2822. }
  2823.  
  2824. // ✅ Restore UI elements
  2825. this.createSettingsIcon();
  2826. this.createFloatingPaginationNav();
  2827. this.bindFloatingPaginationNav();
  2828. this.qBox = QS('#quick-reply');
  2829. this.qBody = QS('#qrbody');
  2830. drag(this.qBox, this.qBox.querySelector('.handle'));
  2831.  
  2832. // ✅ Rebind functionality
  2833. this.bindGlobal();
  2834. this.setupMediaPreloader();
  2835. applyHiddenFilters();
  2836.  
  2837. if (this.settings.closeReplyBox) this.autoCloseReplyOnPost();
  2838.  
  2839. // 🧹 Final cleanup in case of detached leftovers
  2840. setTimeout(() => {
  2841. QSA('img, video, audio, source, span.panelBacklinks, div.postCell').forEach(el => {
  2842. if (!document.contains(el)) safeDestroy(el);
  2843. });
  2844. }, 500);
  2845.  
  2846. setTimeout(() => {
  2847. QSA('span.panelBacklinks, span.altBacklinks').forEach(el => {
  2848. if (!el.closest('.postCell, .opCell, .post')) {
  2849. safeDestroy(el);
  2850. }
  2851. });
  2852. }, 500);
  2853.  
  2854. this.updateFloatingPaginationText();
  2855. this.initWebSocket();
  2856. }
  2857.  
  2858.  
  2859. panel(id, title, pos) {
  2860. const box = Object.assign(document.createElement('div'), { id });
  2861.  
  2862. box.innerHTML = `
  2863. <div class="hdr" style="display: flex; align-items: center; justify-content: space-between; gap: 8px;">
  2864. <span class="hdrText">${title}</span>
  2865. <div class="fcx-nav" style="display: flex; gap: 4px;"></div>
  2866. </div>
  2867. <div class="lst"></div>
  2868. `;
  2869.  
  2870. Object.assign(box.style, { ...STYLE_PANEL_BASE, ...pos });
  2871.  
  2872. const hdr = box.querySelector('.hdr'),
  2873. text = box.querySelector('.hdrText'),
  2874. lst = box.querySelector('.lst');
  2875.  
  2876. Object.assign(lst.style, {
  2877. lineHeight: '1.5',
  2878. display: 'block',
  2879. minWidth: '100%',
  2880. whiteSpace: 'normal',
  2881. overflowX: 'hidden',
  2882. overflowY: 'auto'
  2883. });
  2884.  
  2885. this._aRaf2 = requestAnimationFrame(() => box.dataset.baseWidth = box.offsetWidth);
  2886.  
  2887. // Fit box to content when children change
  2888. const mo = new MutationObserver(() => fit(box));
  2889. mo.observe(lst, { childList: true });
  2890.  
  2891. // Auto-clean on removal
  2892. const cleanup = () => {
  2893. mo.disconnect();
  2894. box.remove();
  2895. };
  2896.  
  2897. text.onclick = () => {
  2898. if (id.startsWith('fcx-ids-')) {
  2899. cleanup(); // Click to close
  2900. } else if (id === 'fcx-yous') {
  2901. this.toggleYou(box);
  2902. }
  2903. };
  2904.  
  2905. drag(box, hdr);
  2906. document.body.appendChild(box);
  2907.  
  2908. return box;
  2909. }
  2910.  
  2911. toggleYou(box) {
  2912. const lst = box.querySelector('.lst');
  2913. const isVisible = lst.style.display !== 'none';
  2914.  
  2915. lst.style.display = isVisible ? 'none' : 'block';
  2916. box.style.width = box.style.height = isVisible ? 'auto' : '';
  2917. if (!isVisible) fit(box);
  2918. }
  2919.  
  2920. pid(post) {
  2921. const a = QSA('a', post).find(x => /^\d+$/.test(x.textContent?.trim()));
  2922. return a?.textContent?.trim() || (post.id || '').replace(/^p/, '');
  2923. }
  2924.  
  2925. jump(id) {
  2926. const p = document.getElementById(id);
  2927. if (!p) return;
  2928.  
  2929. try {
  2930. p.scrollIntoView({ behavior: 'smooth', block: 'center' });
  2931. } catch {
  2932. p.scrollIntoView(true);
  2933. }
  2934.  
  2935. p.style.outline = '2px solid var(--link-color)';
  2936. const timeout = setTimeout(() => p.style.outline = '', 1500);
  2937. timeouts.add(timeout);
  2938. }
  2939.  
  2940. refreshYous(postEl = null) {
  2941. if (!this.youBox) return;
  2942.  
  2943. if (!Array.isArray(this.seen)) this.seen = [];
  2944.  
  2945. const lst = this.youBox.querySelector('.lst');
  2946. const seenIds = new Set(this.seen);
  2947. const existingIds = new Set([...lst.querySelectorAll('a')].map(a => a.dataset.pid));
  2948. const frag = document.createDocumentFragment();
  2949.  
  2950. const posts = postEl ? [postEl] : QSA('.postCell, .post', this.thread);
  2951.  
  2952. posts.forEach(post => {
  2953. if (!post) return;
  2954.  
  2955. const youQuotes = post.querySelectorAll('.quoteLink.you, .quotelink.you');
  2956. if (!youQuotes.length) return;
  2957.  
  2958. const id = post.id;
  2959. if (!id || existingIds.has(id)) return;
  2960.  
  2961. const a = document.createElement('a');
  2962. a.textContent = '>>' + id;
  2963. a.href = '#' + id;
  2964. a.dataset.pid = id;
  2965. a.className = 'fcx-youlink';
  2966. a.style.display = 'block';
  2967. a.style.fontWeight = seenIds.has(id) ? 'normal' : '700';
  2968. a.onclick = e => {
  2969. e.preventDefault();
  2970. this.jump(id);
  2971. };
  2972.  
  2973. frag.appendChild(a);
  2974. });
  2975.  
  2976. if (frag.childNodes.length) lst.appendChild(frag);
  2977.  
  2978. if (!this.io) {
  2979. this.io = new IntersectionObserver(entries => {
  2980. let updated = false;
  2981. for (const ent of entries) {
  2982. if (ent.isIntersecting) {
  2983. const id = ent.target.id;
  2984. if (!seenIds.has(id)) {
  2985. this.seen.push(id);
  2986. seenIds.add(id);
  2987. updated = true;
  2988. if (this.seen.length > 500) {
  2989. this.seen = this.seen.slice(-500);
  2990. }
  2991. this.io.unobserve(ent.target);
  2992. }
  2993. }
  2994. }
  2995.  
  2996. if (updated) {
  2997. localStorage.setItem(this.seenKey, JSON.stringify(this.seen));
  2998. const links = lst.querySelectorAll('a.fcx-youlink');
  2999. links.forEach(a => {
  3000. if (seenIds.has(a.dataset.pid)) a.style.fontWeight = 'normal';
  3001. });
  3002. }
  3003. }, { threshold: 0.15 });
  3004. }
  3005.  
  3006. posts
  3007. .filter(p => !seenIds.has(p.id))
  3008. .slice(0, 250)
  3009. .forEach(p => {
  3010. if (!p.dataset.fcxYouObserved) {
  3011. this.io.observe(p);
  3012. p.dataset.fcxYouObserved = '1';
  3013. }
  3014. });
  3015. }
  3016.  
  3017. openIdBox(raw) {
  3018. const id = idNorm(raw); // Normalize input to 6-char hex ID
  3019. const boxId = `fcx-ids-${id}`;
  3020. const prevOpen = QS('[id^="fcx-ids-"]');
  3021.  
  3022. // Close previously open box if different
  3023. if (prevOpen && prevOpen.id !== boxId) {
  3024. this.cleanupIdClones?.();
  3025. prevOpen.remove();
  3026. } else if (prevOpen) {
  3027. // Close if clicking the same ID again
  3028. this.cleanupIdClones?.();
  3029. prevOpen.remove();
  3030. return;
  3031. }
  3032.  
  3033. // Build map of posts with this ID
  3034. const posts = fcxInstance.threadJson.filter(post =>
  3035. post?.id?.toLowerCase() === id
  3036. );
  3037.  
  3038.  
  3039. // Create floating panel
  3040. const box = this.panel(boxId, `Posts by ID: ${id}`, { right: '1rem', top: '12rem' });
  3041. box.classList.add('fcx-idview');
  3042. this._openIdBox = box;
  3043.  
  3044. const hdr = box.querySelector('.hdr');
  3045. const lst = box.querySelector('.lst');
  3046.  
  3047. // Nav buttons
  3048. const nav = document.createElement('div');
  3049. nav.className = 'fcx-nav';
  3050. nav.innerHTML = `<button style="width:2em;">◀</button><button style="width:2em;">▶</button>`;
  3051. hdr.appendChild(nav);
  3052. const [prev, next] = nav.querySelectorAll('button');
  3053.  
  3054. // Container for post clones
  3055. const wrap = document.createElement('div');
  3056. Object.assign(wrap.style, {
  3057. display: 'flex',
  3058. flexDirection: 'column',
  3059. gap: '8px',
  3060. alignItems: 'stretch',
  3061. });
  3062. wrap.className = 'fcx-id-posts';
  3063. lst.appendChild(wrap);
  3064.  
  3065. const clones = new Array(posts.length).fill(null);
  3066. this._idPanelClones = clones;
  3067.  
  3068. const loadPost = i => {
  3069. const post = posts[i];
  3070. if (!post || clones[i]) return;
  3071.  
  3072. const container = document.createElement('div');
  3073. Object.assign(container.style, {
  3074. border: '1px solid var(--navbar-text-color)',
  3075. padding: '6px',
  3076. borderRadius: '4px',
  3077. backgroundColor: 'rgba(255,255,255,0.02)',
  3078. boxSizing: 'border-box',
  3079. overflow: 'auto',
  3080. overflowWrap: 'break-word',
  3081. maxHeight: '60vh'
  3082. });
  3083.  
  3084. const src = this.renderPostFromJson(post);
  3085. if (src) {
  3086. container.appendChild(src);
  3087. } else {
  3088. container.innerHTML = '<div class="postBody">(Failed to render post)</div>';
  3089. }
  3090.  
  3091. clones[i] = container;
  3092. };
  3093.  
  3094. let idx = 0;
  3095. const show = i => {
  3096. idx = (i + posts.length) % posts.length;
  3097. const nextIdx = (idx + 1) % posts.length;
  3098.  
  3099. // Remove old DOM nodes
  3100. while (wrap.firstChild) wrap.removeChild(wrap.firstChild);
  3101.  
  3102. loadPost(idx);
  3103. loadPost(nextIdx); // Preload next
  3104.  
  3105. if (clones[idx]) wrap.appendChild(clones[idx]);
  3106. if (clones[nextIdx] && posts.length > 1) wrap.appendChild(clones[nextIdx]);
  3107.  
  3108. this._aRaf3 = requestAnimationFrame(() => {
  3109. wrap.style.display = 'flex';
  3110. this._aRaf4 = requestAnimationFrame(() => fit(box));
  3111. });
  3112. };
  3113.  
  3114. prev.onclick = () => show(idx - 2);
  3115. next.onclick = () => show(idx + 2);
  3116.  
  3117. box.tabIndex = 0;
  3118. box.onkeydown = e => {
  3119. if (e.key === 'ArrowLeft') { e.preventDefault(); show(idx - 2); }
  3120. if (e.key === 'ArrowRight') { e.preventDefault(); show(idx + 2); }
  3121. };
  3122.  
  3123. const text = box.querySelector('.hdrText');
  3124. text.onclick = () => {
  3125. this.cleanupIdClones?.();
  3126. box.remove();
  3127. };
  3128.  
  3129. drag(box, hdr);
  3130. show(0);
  3131. box.focus();
  3132. }
  3133.  
  3134. cleanupIdClones() {
  3135. if (!this._idPanelClones) return;
  3136. this._idPanelClones.forEach(el => {
  3137. try {
  3138. el?.remove?.();
  3139. } catch {}
  3140. });
  3141. this._idPanelClones = null;
  3142. }
  3143.  
  3144. async toggleQuote(pid, link, small = false, threadId = null) {
  3145.  
  3146. if (this.quoteMap.has(link)) {
  3147. const wrap = this.quoteMap.get(link);
  3148.  
  3149. if (wrap && wrap.isConnected) {
  3150. let current = wrap.nextElementSibling;
  3151. while (current && current.classList.contains('nestedPost')) {
  3152. const next = current.nextElementSibling;
  3153. safeDestroy(current);
  3154. current = next;
  3155. }
  3156. safeDestroy(wrap);
  3157. }
  3158.  
  3159. this.quoteMap.delete(link);
  3160. return;
  3161. }
  3162.  
  3163. let src = document.getElementById(pid);
  3164.  
  3165. if (!src) {
  3166.  
  3167. const postData = this.threadJson.find(p => String(p.postId) === String(pid));
  3168. if (postData) {
  3169. src = this.renderPostFromJson(postData);
  3170. }
  3171. }
  3172.  
  3173. if (!src) {
  3174. console.warn('[TOGGLE-QUOTE] Could not find source post:', pid);
  3175. return;
  3176. }
  3177.  
  3178. const inPanel = !!link.closest(PANEL_SEL);
  3179. const depth = +(link.closest('.nestedPost')?.dataset.depth || 0);
  3180.  
  3181. const wrap = document.createElement('div');
  3182. wrap.className = 'nestedPost';
  3183. wrap.dataset.depth = depth + 1;
  3184. Object.assign(wrap.style, {
  3185. marginLeft: `${(depth + 1) * 20}px`,
  3186. padding: '2px',
  3187. boxSizing: 'border-box',
  3188. fontSize: small ? '90%' : ''
  3189. });
  3190.  
  3191. const inner = document.createElement('div');
  3192. Object.assign(inner.style, {
  3193. display: 'inline-block',
  3194. maxWidth: '100%',
  3195. border: '1px solid var(--navbar-text-color)',
  3196. borderRadius: '4px',
  3197. padding: '4px',
  3198. background: 'var(--contrast-color)'
  3199. });
  3200.  
  3201. const innerPost = src.querySelector('.innerPost');
  3202. if (!innerPost) {
  3203. console.warn('[TOGGLE-QUOTE] No .innerPost found inside post:', src);
  3204. return;
  3205. }
  3206.  
  3207. const fullPostClone = innerPost.cloneNode(true);
  3208. fullPostClone.id = ''; // Prevent ID duplication
  3209.  
  3210. inner.appendChild(fullPostClone);
  3211. wrap.appendChild(inner);
  3212.  
  3213. this.quoteMap.set(link, wrap);
  3214. link.dataset.depth = depth + 1;
  3215.  
  3216. const parent = link.closest('.nestedPost, .postCell, .post');
  3217. if (parent?.isConnected) {
  3218. parent.appendChild(wrap);
  3219. }
  3220.  
  3221. if (inPanel) {
  3222. const panel = link.closest(PANEL_SEL);
  3223. if (panel) fit(panel);
  3224. }
  3225.  
  3226. }
  3227.  
  3228. createSettingsIcon() {
  3229. const existing = document.getElementById('fcx-settings-icon');
  3230. if (existing) existing.remove();
  3231.  
  3232. const icon = document.createElement('div');
  3233. icon.id = 'fcx-settings-icon';
  3234. icon.innerHTML = '⚙️';
  3235. Object.assign(icon.style, {
  3236. position: 'fixed',
  3237. bottom: '1rem',
  3238. right: '1rem',
  3239. fontSize: '20px',
  3240. cursor: 'pointer',
  3241. zIndex: 10000
  3242. });
  3243.  
  3244. icon.onclick = () => this.toggleSettingsPanel();
  3245. document.body.appendChild(icon);
  3246. }
  3247.  
  3248. toggleSettingsPanel() {
  3249. const existing = document.getElementById('fcx-settings-panel');
  3250. if (existing) {
  3251. existing.remove();
  3252. } else {
  3253. this.createSettingsPanel();
  3254. }
  3255. }
  3256.  
  3257. createSettingsPanel() {
  3258. const panel = document.createElement('div');
  3259. panel.id = 'fcx-settings-panel';
  3260.  
  3261. Object.assign(panel.style, {
  3262. position: 'fixed',
  3263. bottom: '3rem',
  3264. right: '1rem',
  3265. background: 'var(--contrast-color)',
  3266. color: 'var(--link-color)',
  3267. border: '1px solid var(--navbar-text-color)',
  3268. padding: '10px',
  3269. borderRadius: '6px',
  3270. zIndex: 10000,
  3271. font: '14px sans-serif',
  3272. width: '240px',
  3273. maxWidth: '90vw',
  3274. boxSizing: 'border-box',
  3275. });
  3276.  
  3277.  
  3278. panel.innerHTML = `
  3279. <label><input type="checkbox" id="fcx-toggle-yous"> Show My (You)s</label>
  3280. <label><input type="checkbox" id="fcx-close-reply-box"> Auto-close Quick Reply after posting</label>
  3281. <label>Media Volume: <input type="range" id="fcx-volume-slider" min="0" max="100" style="width: 100%;"></label>
  3282. <label><input type="checkbox" id="fcx-blur-spoilers"> Blur Spoiler Hover Previews</label>
  3283. <label><input type="checkbox" id="fcx-hide-stubs"> Hide Filtered Stubs</label>
  3284. <label><input type="checkbox" id="fcx-hide-single-posters"> Hide Single-Post IDs</label>
  3285.  
  3286. <label><input type="checkbox" id="fcx-auto-scroll"> Auto-scroll on New Posts (when idle)</label>
  3287. <label><input type="checkbox" id="fcx-anonymize"> Anonymize (hide IDs)</label>
  3288. <label><input type="checkbox" id="fcx-preload-images"> Preload Images</label>
  3289. <label>Max Posts to Preload: <input type="number" id="fcx-preload-posts" min="1" max="50" style="width: 100%;"></label>
  3290. <label>Refresh Interval (ms): <input type="number" id="fcx-refresh-poll" min="1000" max="60000" style="width: 100%;"></label>
  3291. <label>Stale Threshold (ms): <input type="number" id="fcx-stale-age" min="2000" max="120000" style="width: 100%;"></label>
  3292. <label>Max Media Cache Size: <input type="number" id="fcx-cache-size" min="5" max="200" style="width: 100%;"></label>
  3293. <button id="fcx-stop-previews" style="margin-top:10px; width: 100%;">⏹️ Stop All Previews</button>
  3294. `;
  3295.  
  3296. document.body.appendChild(panel);
  3297.  
  3298. // === Helpers ===
  3299. const QS = (s) => panel.querySelector(s);
  3300. const clamp = (val, min, max, fallback) => {
  3301. const n = parseInt(val);
  3302. return isNaN(n) ? fallback : Math.max(min, Math.min(max, n));
  3303. };
  3304.  
  3305. const save = () =>
  3306. localStorage.setItem('fcx-settings', JSON.stringify(this.settings));
  3307.  
  3308. const bindCheckbox = (id, key, onChange) => {
  3309. const el = QS(id);
  3310. el.checked = this.settings[key];
  3311. el.onchange = (e) => {
  3312. this.settings[key] = e.target.checked;
  3313. save();
  3314. onChange?.(e);
  3315. };
  3316. };
  3317.  
  3318. const bindNumber = (id, key, min, max, fallback = 0, onChange) => {
  3319. const el = QS(id);
  3320. el.value = this.settings[key];
  3321. el.oninput = (e) => {
  3322. this.settings[key] = clamp(e.target.value, min, max, fallback);
  3323. save();
  3324. onChange?.(e);
  3325. };
  3326. };
  3327.  
  3328. // === Bind Controls ===
  3329. bindCheckbox('#fcx-toggle-yous', 'showYous', () => {
  3330. if (this.settings.showYous) {
  3331. this.createYouBox();
  3332. this.youBox.style.display = 'block';
  3333. fit(this.youBox);
  3334. this.refreshYous();
  3335. } else if (this.youBox) {
  3336. this.youBox.style.display = 'none';
  3337. }
  3338. });
  3339.  
  3340. bindCheckbox('#fcx-hide-single-posters', 'hideSinglePosters', () => {
  3341. if (this.settings.hideSinglePosters) {
  3342. scanForSinglePosters(); // ✅ Re-apply the filter when toggled ON
  3343. } else {
  3344. // ✅ Unhide all single-posters and clear the map
  3345. singlePostIdMap.forEach(entry => {
  3346. const post = document.getElementById(entry.postId);
  3347. if (post) post.style.display = '';
  3348. });
  3349. singlePostIdMap.clear();
  3350. }
  3351. });
  3352.  
  3353.  
  3354. bindCheckbox('#fcx-close-reply-box', 'closeReplyBox');
  3355. bindCheckbox('#fcx-hide-stubs', 'hideStubs', () =>
  3356. document.body.classList.toggle('fcx-hide-stubs', this.settings.hideStubs)
  3357. );
  3358. bindCheckbox('#fcx-anonymize', 'anonymize', () =>
  3359. document.body.classList.toggle('fcx-anonymize', this.settings.anonymize)
  3360. );
  3361. bindCheckbox('#fcx-auto-scroll', 'autoScrollOnIdle');
  3362. bindCheckbox('#fcx-preload-images', 'preloadImages');
  3363.  
  3364. bindCheckbox('#fcx-blur-spoilers', 'blurSpoilerHover', () => {
  3365. if (!this.settings.blurSpoilerHover && this._blurredHoverEls?.size > 0) {
  3366. this._blurredHoverEls.forEach(el => {
  3367. el.classList.remove('fcx-hover-blurred');
  3368. delete el.dataset.fcxWasSpoiler;
  3369. });
  3370. this._blurredHoverEls.clear();
  3371. }
  3372. });
  3373.  
  3374. QS('#fcx-volume-slider').value = Math.round(this.settings.volume * 100);
  3375. QS('#fcx-volume-slider').oninput = (e) => {
  3376. this.settings.volume = +e.target.value / 100;
  3377. save();
  3378. };
  3379.  
  3380. bindNumber('#fcx-cache-size', 'mediaCacheSize', 5, 100, 20);
  3381. bindNumber('#fcx-preload-posts', 'preloadPosts', 1, 50, 20);
  3382. bindNumber('#fcx-refresh-poll', 'refreshPoll', 1000, 60000, 10000, () =>
  3383. this.refreshPoll = this.settings.refreshPoll
  3384. );
  3385. bindNumber('#fcx-stale-age', 'staleAge', 2000, 120000, 200000, () =>
  3386. this.staleAge = this.settings.staleAge
  3387. );
  3388.  
  3389. // Stop all previews
  3390. QS('#fcx-stop-previews').onclick = () => this.stopAllPreviews();
  3391. }
  3392.  
  3393. renderPostFromJson(post) {
  3394. const div = document.createElement('div');
  3395. const board = this.getThreadMeta()?.board ?? '';
  3396. const isOP = post.postId === parseInt(this.tid);
  3397.  
  3398. const replies = this.backlinksMap?.get(post.postId) || [];
  3399. const repliesHtml = replies.map(no => {
  3400. let href = `#${no}`;
  3401. let label = '';
  3402.  
  3403. if (String(no) === String(this.tid)) {
  3404. // Same-thread OP → normal
  3405. } else {
  3406. const postInSameThread = this.threadJson?.some(p => String(p.postId) === String(no));
  3407.  
  3408. if (!postInSameThread) {
  3409. const board = this.getThreadMeta()?.board ?? '';
  3410. href = `/${board}/res/${no}.html#${no}`;
  3411. label = ' (Cross Thread)';
  3412. }
  3413. }
  3414.  
  3415. return `<a href="${href}">&gt;&gt;${no}${label}</a>`;
  3416. }).join(' ');
  3417.  
  3418.  
  3419. div.id = post.postId;
  3420. div.className = isOP ? 'opCell' : 'postCell';
  3421. div.dataset.boarduri = board;
  3422. div.dataset.fcxObserved = '1';
  3423.  
  3424. const createdDate = new Date(post.creation);
  3425. const formattedTime = createdDate.toLocaleString('en-US', {
  3426. timeZone: 'UTC',
  3427. weekday: 'short',
  3428. year: 'numeric',
  3429. month: '2-digit',
  3430. day: '2-digit',
  3431. hour: '2-digit',
  3432. minute: '2-digit',
  3433. second: '2-digit'
  3434. });
  3435.  
  3436. const idHex = post.id?.slice(0, 6).toLowerCase() || '';
  3437.  
  3438. // Build file section
  3439. const fileSection = Array.isArray(post.files) && post.files.length > 0
  3440. ? `<div class="panelUploads">${post.files.map(file => `
  3441. <figure class="uploadCell"><details open>
  3442. <summary>
  3443. <div class="uploadDetails">
  3444. <a class="nameLink coloredIcon" target="_blank" href="${file.path}"></a>
  3445. <span class="hideFileButton glowOnHover coloredIcon"></span>
  3446. <span class="hideMobile">(</span>
  3447. <span class="sizeLabel">${(file.size / 1024).toFixed(1)} KB</span>
  3448. <span class="dimensionLabel">${file.width}×${file.height}</span>
  3449. <a class="originalNameLink" href="${file.path}" download="${file.originalName}">${file.originalName}</a>
  3450. <span class="hideMobile">)</span>
  3451. </div>
  3452. </summary>
  3453. <br>
  3454. <a class="imgLink" target="_blank" href="${file.path}" data-filemime="${file.mime}">
  3455. <img src="${file.thumb || file.path}">
  3456. </a>
  3457. </details></figure>
  3458. `).join('')}</div>`
  3459. : '';
  3460.  
  3461. const msg = post.markdown || post.message || '';
  3462.  
  3463. div.innerHTML = `
  3464. <div class="innerPost">
  3465. <div class="postInfo title">
  3466. <input type="checkbox" class="deletionCheckBox" name="${board}-${this.tid}-${post.postId}">
  3467. <label class="hideButton glowOnHover coloredIcon" title="Hide"></label>
  3468. <label class="extraMenuButton glowOnHover coloredIcon" title="Post Menu"></label>
  3469.  
  3470. <a class="linkName noEmailName">${post.name || 'Anonymous'}</a>
  3471. <span class="labelCreated">${formattedTime}</span>
  3472. ${idHex ? `<span class="spanId"> Id: <span class="labelId" style="background-color: #${idHex}">${idHex}</span></span>` : ''}
  3473. <span class="labelCreated">No.</span>
  3474. <a class="linkQuote" href="#q${post.postId}">${post.postId}</a>
  3475. ${post.postId == this.tid ? '<span style="font-size:90%;color:var(--link-color);"> (OP)</span>' : ''}
  3476. <span class="panelBacklinks">${repliesHtml}</span>
  3477. </div>
  3478.  
  3479. ${fileSection}
  3480. <div class="divMessage">${msg}</div>
  3481. <div class="altBacklinks"><span class="panelBacklinks">${repliesHtml}</span></div>
  3482. <div class="replyPreview"></div>
  3483. </div>`;
  3484.  
  3485. const hideBtn = div.querySelector('.hideButton');
  3486. if (hideBtn) {
  3487. hideBtn.onclick = (e) => {
  3488. e.preventDefault();
  3489. e.stopPropagation();
  3490.  
  3491. // Remove old open menus
  3492. document.querySelectorAll('.floatingList.extraMenu').forEach(m => m.remove());
  3493.  
  3494. const extraMenu = document.createElement('div');
  3495. extraMenu.className = 'floatingList extraMenu';
  3496. extraMenu.style.position = 'absolute';
  3497. extraMenu.style.zIndex = '10000';
  3498. extraMenu.style.background = 'var(--contrast-color)';
  3499. extraMenu.style.border = '1px solid var(--navbar-text-color)';
  3500. extraMenu.style.padding = '6px';
  3501. extraMenu.style.borderRadius = '6px';
  3502. extraMenu.style.font = '14px sans-serif';
  3503.  
  3504. const list = document.createElement('ul');
  3505. list.style.margin = 0;
  3506. list.style.padding = 0;
  3507. list.style.listStyle = 'none';
  3508.  
  3509. extraMenu.appendChild(list);
  3510.  
  3511. const menuItems = buildHideMenu(div);
  3512.  
  3513. menuItems.forEach(option => {
  3514. const li = document.createElement('li');
  3515. li.textContent = option.name;
  3516. li.style.padding = '4px 8px';
  3517. li.style.cursor = 'pointer';
  3518. li.onmouseenter = () => li.style.background = 'var(--link-hover-color)';
  3519. li.onmouseleave = () => li.style.background = '';
  3520. li.onclick = (ev) => {
  3521. ev.stopPropagation();
  3522. extraMenu.remove();
  3523. option.callback(ev);
  3524. };
  3525. list.appendChild(li);
  3526. });
  3527.  
  3528. hideBtn.appendChild(extraMenu);
  3529.  
  3530. // Optional: Close menu if clicking outside
  3531. const closeOnOutside = (event) => {
  3532. if (!extraMenu.contains(event.target)) {
  3533. extraMenu.remove();
  3534. document.removeEventListener('click', closeOnOutside);
  3535. }
  3536. };
  3537. document.addEventListener('click', closeOnOutside);
  3538. };
  3539. }
  3540.  
  3541. return div;
  3542. }
  3543.  
  3544. loadSettings() {
  3545. const defaults = {
  3546. showYous: false,
  3547. volume: 0.5,
  3548. hideStubs: false,
  3549. anonymize: false,
  3550. preloadImages: true,
  3551. preloadPosts: 5,
  3552. mediaCacheSize: 50,
  3553. closeReplyBox: true,
  3554. staleAge: 10000,
  3555. refreshPoll: 5000,
  3556. autoScrollOnIdle: false,
  3557. blurSpoilerHover: false,
  3558. hideSinglePosters: true,
  3559. };
  3560.  
  3561. const saved = JSON.parse(localStorage.getItem('fcx-settings') || '{}');
  3562.  
  3563. // Merge with defaults to ensure all keys are present
  3564. this.settings = { ...defaults, ...saved };
  3565.  
  3566. // Reflect relevant settings in DOM classes
  3567. document.body.classList.toggle('fcx-hide-stubs', this.settings.hideStubs);
  3568. document.body.classList.toggle('fcx-anonymize', this.settings.anonymize);
  3569. }
  3570.  
  3571. stopAllPreviews() {
  3572. // Remove all hover-related media elements
  3573. QSA('[data-fcx-hover], .fcx-spinner').forEach(el => {
  3574. if (el.dataset.fcxManual) return; // 🛡️ Don't remove manually fixed video
  3575. try {
  3576. el.pause?.();
  3577. el.currentTime = 0;
  3578. } catch {}
  3579. el.remove();
  3580. });
  3581.  
  3582. // Extra safety cleanup for stragglers
  3583. QSA('img, video, audio').forEach(el => {
  3584. const style = getComputedStyle(el);
  3585. if (style.position === 'fixed' || el.style.pointerEvents === 'none') {
  3586. el.pause?.();
  3587. el.currentTime = 0;
  3588. el.remove();
  3589. }
  3590. });
  3591.  
  3592. // Clean up tracking attributes from anchors
  3593. QSA('a[data-filemime]').forEach(a => {
  3594. delete a._prev;
  3595. delete a._preloaded;
  3596. delete a._fcxFailed;
  3597. delete a.dataset._fcxHoverActive;
  3598. });
  3599.  
  3600. // Reset mediaMap if needed
  3601. mediaMap.clear();
  3602. }
  3603.  
  3604. bindGlobal() {
  3605. const thread = this.thread;
  3606. if (!thread) return;
  3607.  
  3608. const applyMediaStyles = el => {
  3609. Object.assign(el.style, STYLE_HOVER_MEDIA);
  3610. };
  3611.  
  3612. const getSharedSpinner = () => {
  3613. if (!this._spinner) {
  3614. this._spinner = document.createElement('div');
  3615. Object.assign(this._spinner.style, STYLE_SPINNER);
  3616. this._spinner.className = 'fcx-spinner';
  3617. this._spinner.textContent = '⏳ Loading ⏳';
  3618. }
  3619. return this._spinner.cloneNode(true);
  3620. };
  3621.  
  3622. // ❌ Cancel previews on scroll or touch
  3623. let scrollRaf = false;
  3624. const throttledStopHoverPreviews = () => {
  3625. if (scrollRaf) return;
  3626. scrollRaf = true;
  3627. this._aRaf7 = requestAnimationFrame(() => {
  3628. fcxInstance?.stopHoverPreviews();
  3629. scrollRaf = false;
  3630. });
  3631. };
  3632.  
  3633. this._hoverPreviewStopHandler = throttledStopHoverPreviews;
  3634. ['scroll', 'wheel', 'touchmove'].forEach(evt =>
  3635. window.addEventListener(evt, this._hoverPreviewStopHandler, { passive: true })
  3636. );
  3637.  
  3638. let hoverTries = 0;
  3639.  
  3640. }
  3641.  
  3642. async showCrossThreadQuote(link, threadId, postId) {
  3643. const board = this.getThreadMeta()?.board;
  3644. try {
  3645. const res = await fetch(`/${board}/res/${threadId}.json`);
  3646. if (!res.ok) throw new Error('Cross-thread fetch failed');
  3647.  
  3648. const json = await res.json();
  3649. const post = json.posts.find(p => String(p.postId) === String(postId));
  3650. if (!post) return;
  3651.  
  3652. const el = this.renderPostFromJson(post);
  3653. el.classList.add('nestedPost', 'cross-thread');
  3654. el.style.marginLeft = '20px';
  3655.  
  3656. link.insertAdjacentElement('afterend', el);
  3657. this.quoteMap.set(link, el);
  3658. } catch (err) {
  3659. console.warn('[FCX] Error loading cross-thread post:', err);
  3660. }
  3661. }
  3662.  
  3663. stopHoverPreviews() {
  3664. if (isHoveringPreview) return;
  3665.  
  3666. for (const [anchor] of mediaMap.entries()) {
  3667. cleanupHoverMedia(anchor);
  3668. mediaMap.delete(anchor); // ⬅️ ensure full cleanup
  3669. }
  3670.  
  3671. // Extra fallback: remove any lingering media
  3672. QSA('img[data-fcx-hover], video[data-fcx-hover], audio[data-fcx-hover]').forEach(el => {
  3673. el.pause?.();
  3674. el.src = '';
  3675. el.removeAttribute('src');
  3676. el.load?.(); // forces to stop fetching/loading
  3677. el.remove?.();
  3678. });
  3679.  
  3680. // Just in case some untagged audio/video slipped through
  3681. QSA('video, audio').forEach(el => {
  3682. if (!document.contains(el) || el.dataset.fcxManual) return;
  3683. el.pause?.();
  3684. el.src = '';
  3685.  
  3686. el.removeAttribute('src');
  3687. el.load?.(); // forces to stop fetching/loading
  3688. el.remove?.();
  3689. });
  3690.  
  3691. // Kill spinners
  3692. QSA('.fcx-spinner').forEach(s => s.remove());
  3693.  
  3694. isHoveringPreview = false; // ✅ reinforce reset
  3695. }
  3696.  
  3697. setupMediaPreloader() {
  3698. if (!this.settings?.preloadImages) return;
  3699.  
  3700. if (this._mediaPreloaderBound) return; // ✅ Already bound, skip
  3701. this._mediaPreloaderBound = true; // ✅ Mark as bound
  3702.  
  3703. let lastPrune = 0;
  3704. const pruneScrollHandler = () => {
  3705. const now = Date.now();
  3706. if (now - lastPrune > 500) {
  3707. pruneCachesByDistance();
  3708. pruneMediaMapByDistance();
  3709. lastPrune = now;
  3710. }
  3711. };
  3712. window.addEventListener('scroll', pruneScrollHandler, { passive: true });
  3713. this._pruneScrollHandler = pruneScrollHandler; // ✅ Save reference if ever needed for cleanup
  3714.  
  3715. const LOOKAHEAD = this.settings.preloadPosts || 20;
  3716. const SCROLL_IDLE_DELAY = 10;
  3717.  
  3718. let lastScrollTop = window.scrollY;
  3719. let scrollDir = 'down';
  3720.  
  3721. const handleScroll = () => {
  3722. const top = window.scrollY;
  3723. scrollDir = top > lastScrollTop ? 'down' : 'up';
  3724. lastScrollTop = top;
  3725.  
  3726. maybeTriggerSmartPreload(this.doSmartPreload.bind(this), scrollDir, LOOKAHEAD);
  3727. };
  3728.  
  3729. this._handleScroll = handleScroll; // ✅ Save for cleanup if needed
  3730. window.addEventListener('scroll', this._handleScroll, { passive: true });
  3731.  
  3732. // 💡 First-pass preload for visible media
  3733. this._request2 = requestIdleCallback(() => {
  3734. this.scrollDir = 'down';
  3735.  
  3736. this.postElements?.forEach(post => {
  3737. const rect = post.getBoundingClientRect();
  3738. if (rect.bottom >= 0 && rect.top <= window.innerHeight) {
  3739. QSA('a[data-filemime]', post).forEach(a => {
  3740. const url = a.href;
  3741. const mime = a.dataset.filemime?.toLowerCase() || '';
  3742.  
  3743. if (!OK_MIME.test(mime) && !OK_EXT.test(url)) return;
  3744. queuePreloadImage(url, mime, undefined, 'high', 0); // 🛠 Pass mime + distance=0
  3745. });
  3746. }
  3747. });
  3748.  
  3749. this.doSmartPreload(scrollDir, LOOKAHEAD);
  3750. });
  3751. }
  3752.  
  3753.  
  3754. doSmartPreload(dir = 'down') {
  3755. const LOOKAHEAD = this.settings?.preloadPosts || 20;
  3756. const MAX_PRELOAD = 20;
  3757.  
  3758. const posts = this.postElements;
  3759. if (!posts?.length) return;
  3760.  
  3761. const winTop = window.scrollY;
  3762. const winBottom = winTop + window.innerHeight;
  3763.  
  3764. let centerIndex = -1;
  3765. const visible = [];
  3766.  
  3767. for (let i = 0; i < posts.length; i++) {
  3768. const post = posts[i];
  3769. if (!post?.parentElement) continue;
  3770.  
  3771. const rect = post.getBoundingClientRect();
  3772. const absTop = rect.top + window.scrollY;
  3773. const absBottom = rect.bottom + window.scrollY;
  3774.  
  3775. if (absBottom > winTop && absTop < winBottom) {
  3776. visible.push({ post, index: i });
  3777. }
  3778. }
  3779.  
  3780. if (!visible.length) return;
  3781.  
  3782. centerIndex = visible[Math.floor(visible.length / 2)].index;
  3783.  
  3784. const preloadPosts = new Set();
  3785. visible.forEach(({ post }) => preloadPosts.add(post));
  3786.  
  3787. const directionRange = dir === 'up'
  3788. ? { start: centerIndex - LOOKAHEAD, end: centerIndex - 1 }
  3789. : { start: centerIndex + 1, end: centerIndex + LOOKAHEAD };
  3790.  
  3791. for (let i = directionRange.start; i <= directionRange.end; i++) {
  3792. if (posts[i]) preloadPosts.add(posts[i]);
  3793. }
  3794.  
  3795. const opposite = dir === 'up'
  3796. ? [centerIndex + 1, centerIndex + 2, centerIndex + 3, centerIndex + 4, centerIndex + 5]
  3797. : [centerIndex - 1, centerIndex - 2, centerIndex - 3, centerIndex - 4, centerIndex - 5];
  3798. for (let i of opposite) {
  3799. if (posts[i]) preloadPosts.add(posts[i]);
  3800. }
  3801.  
  3802. const limited = Array.from(preloadPosts).slice(0, MAX_PRELOAD);
  3803.  
  3804. const viewportCenter = window.innerHeight / 2;
  3805.  
  3806. for (const post of limited) {
  3807. const links = QSA('a[data-filemime]', post);
  3808.  
  3809. for (const a of links) {
  3810. const url = a.href;
  3811. const mime = a.dataset.filemime?.toLowerCase() || '';
  3812.  
  3813. if (!OK_MIME.test(mime) && !OK_EXT.test(url)) continue;
  3814. if (preloadCache.has(url) || preloadQueue.some(q => q.url === url)) continue;
  3815. if (preloadFailures.get(url)?.count >= MAX_FAILURES) continue;
  3816.  
  3817. const rect = a.closest('.post, .postCell, .opCell')?.getBoundingClientRect();
  3818. const dist = rect ? Math.abs((rect.top + rect.bottom) / 2 - viewportCenter) : Infinity;
  3819.  
  3820. queuePreloadImage(url, mime, undefined, 'high', dist); // ✅ Pass mime
  3821. }
  3822. }
  3823.  
  3824. prunePreloadCache(centerIndex, this.settings?.mediaCacheSize || 50);
  3825. }
  3826.  
  3827. enforcePostWindow(center, max = 200) {
  3828. const posts = this.postElements ?? QSA('.postCell, .opCell', this.thread);
  3829. if (!posts.length) return;
  3830.  
  3831. const keep = new Set();
  3832. const half = Math.floor(max / 2);
  3833.  
  3834. for (let i = center - half; i <= center + half; i++) {
  3835. const post = posts[i];
  3836. if (post) keep.add(post);
  3837. }
  3838.  
  3839. const toRemove = posts.filter(p => !keep.has(p));
  3840.  
  3841. toRemove.forEach(post => {
  3842. if (!post?.parentElement) return;
  3843.  
  3844. // Only clean up hover previews, don't touch preload cache
  3845. QSA('a[data-filemime]', post).forEach(a => {
  3846. cleanupHoverMedia(a);
  3847. });
  3848.  
  3849. // Optional: you could remove post from DOM if doing real virtual scrolling
  3850. // post.remove();
  3851. });
  3852.  
  3853. this.postElements = Array.from(keep);
  3854. }
  3855.  
  3856. markCrossThreadQuotes(container = document) {
  3857. if (!this.crossQuoteObserver) {
  3858. this.crossQuoteObserver = new IntersectionObserver((entries, obs) => {
  3859. const toTag = [];
  3860.  
  3861. for (const ent of entries) {
  3862. if (!ent.isIntersecting) continue;
  3863.  
  3864. const q = ent.target;
  3865. if (q.dataset.crossThreaded) continue;
  3866.  
  3867. const text = q.textContent || '';
  3868. const href = q.getAttribute('href') || '';
  3869. const explicit = text.match(/^>>(\d+)\s+\((\d+)\)$/);
  3870. const hrefMatch = href.match(/\/res\/(\d+)\.html#(\d+)/);
  3871.  
  3872. let shouldTag = false;
  3873.  
  3874. if (explicit) {
  3875. const [, postId, threadId] = explicit;
  3876. if (threadId !== this.tid) {
  3877. q.href = `/${location.pathname.split('/')[1]}/res/${threadId}.html#${postId}`;
  3878. shouldTag = true;
  3879. }
  3880. } else if (hrefMatch) {
  3881. const [, threadId] = hrefMatch;
  3882. if (threadId !== this.tid) {
  3883. shouldTag = true;
  3884. }
  3885. }
  3886.  
  3887. if (shouldTag) {
  3888. toTag.push(q);
  3889. }
  3890.  
  3891. obs.unobserve(q);
  3892. }
  3893.  
  3894. if (toTag.length) {
  3895. this._aRaf8 = requestAnimationFrame(() => {
  3896. toTag.forEach(q => this._tagAsCrossThread(q));
  3897. });
  3898. }
  3899. }, { threshold: 0.1 });
  3900. }
  3901.  
  3902. QSA('.quoteLink, .quotelink', container).forEach(q => {
  3903. if (!q.dataset.crossThreaded) {
  3904. this.crossQuoteObserver.observe(q);
  3905. }
  3906. });
  3907. }
  3908.  
  3909. _tagAsCrossThread(q) {
  3910. if (!q) return; // only null check
  3911.  
  3912. const href = q.getAttribute('href') || '';
  3913. const idMatch = href.match(/#(\d+)/);
  3914. const postId = idMatch?.[1] || '';
  3915.  
  3916. const crossSpan = document.createElement('span');
  3917. crossSpan.textContent = ' (Cross-thread)';
  3918.  
  3919. // Copy basic styles from the quote itself
  3920. crossSpan.style.color = getComputedStyle(q).color;
  3921. crossSpan.style.fontSize = getComputedStyle(q).fontSize;
  3922. crossSpan.style.fontFamily = getComputedStyle(q).fontFamily;
  3923. crossSpan.style.textDecoration = getComputedStyle(q).textDecoration;
  3924.  
  3925. q.after(crossSpan); // Add the new text immediately after the <a>
  3926.  
  3927. q.dataset.crossThreaded = 'true';
  3928. }
  3929.  
  3930. cleanup() {
  3931. fullMemoryReset.call(this);
  3932.  
  3933. // Cleanly disconnect the IntersectionObserver when the element is removed from DOM
  3934. if (this.io) this.io.disconnect?.();
  3935.  
  3936. this._fcxConnected = false;
  3937. if (this._refreshTimeout) clearTimeout(this._refreshTimeout);
  3938. if (this._onVisibilityChange) document.removeEventListener('visibilitychange', this._onVisibilityChange);
  3939.  
  3940. // Cleanup idle interval
  3941. if (this._idleInterval) clearInterval(this._idleInterval);
  3942.  
  3943. // Remove idle event listeners
  3944. if (this._idleListeners) {
  3945. this._idleListeners.forEach(({ evt, handler }) =>
  3946. window.removeEventListener(evt, handler)
  3947. );
  3948. this._idleListeners = null;
  3949. }
  3950. if (this._onVisibilityChange) {
  3951. document.removeEventListener('visibilitychange', this._onVisibilityChange);
  3952. this._onVisibilityChange = null;
  3953. }
  3954.  
  3955. this._pendingNodes?.clear?.();
  3956.  
  3957. if (this._handleScroll) {
  3958. window.removeEventListener('scroll', this._handleScroll);
  3959. this._handleScroll = null;
  3960. }
  3961. QSA('img[data-fcx-hover], video[data-fcx-hover], audio[data-fcx-hover]').forEach(el => {
  3962.  
  3963. try {
  3964. el.pause?.();
  3965. el.src = '';
  3966. el.removeAttribute('src');
  3967. el.load?.(); // forces to stop fetching/loading
  3968. el.remove?.();
  3969. } catch {}
  3970.  
  3971. });
  3972.  
  3973. preloadCache.forEach((el, key) => {
  3974.  
  3975. try {
  3976. el.pause?.();
  3977. el.src = '';
  3978. el.removeAttribute('src');
  3979. el.load?.(); // forces to stop fetching/loading
  3980. el.remove?.();
  3981. } catch (err) {
  3982. console.warn('[FCX] Error removing preloadCache element:', err);
  3983. }
  3984.  
  3985. });
  3986. this.stopAllPreviews();
  3987.  
  3988. QSA('a[data-filemime]').forEach(a => {
  3989. a._cancelPreload?.();
  3990. delete a._preloaded;
  3991. delete a._prev;
  3992. delete a._fcxFailed;
  3993. delete a._onPreloadDone;
  3994. delete a.dataset._fcxHoverActive;
  3995. delete a.dataset.fcxHover;
  3996. delete a.dataset._fcxLinked;
  3997. delete a.dataset.fcxMediaLinked;
  3998. });
  3999. preloadCache.clear();
  4000. mediaMap.clear();
  4001. preloadFailures.clear();
  4002.  
  4003. const timeout = setTimeout(() => {
  4004. console.debug('[FCX] Remaining IMG/VIDEO/AUDIO in DOM:',
  4005. QSA('img, video, audio').length);
  4006. console.debug('[FCX] Remaining preloadCache size:', preloadCache.size);
  4007. console.debug('[FCX] Remaining mediaMap size:', mediaMap.size);
  4008. }, 200); // Delay slightly to let cleanup settle
  4009. timeouts.add(timeout);
  4010.  
  4011. preloadQueue.length = 0;
  4012. preloadCache.clear();
  4013. mediaMap.clear();
  4014. preloadFailures.clear();
  4015. this._blurredHoverEls?.forEach(el => el.src = '');
  4016. this._blurredHoverEls?.forEach(el => el.remove?.());
  4017. this._blurredHoverEls?.clear?.();
  4018.  
  4019. // Detach scroll + mouse event handlers
  4020. window.removeEventListener('scroll', this._scrollHandler);
  4021. window.removeEventListener('mousemove', this._mousemoveHandler);
  4022.  
  4023. timeouts.forEach(id => clearTimeout(id));
  4024. intervals.forEach(id => clearInterval(id));
  4025. timeouts.clear();
  4026. intervals.clear();
  4027.  
  4028. this._idleListeners?.forEach(({ evt, handler }) =>
  4029. window.removeEventListener(evt, handler)
  4030. );
  4031. this._idleListeners = null;
  4032. if (this._onVisibilityChange) {
  4033. document.removeEventListener('visibilitychange', this._onVisibilityChange);
  4034. this._onVisibilityChange = null;
  4035. }
  4036.  
  4037. ['scroll', 'wheel', 'touchmove'].forEach(evt =>
  4038. window.removeEventListener(evt, this._hoverPreviewStopHandler)
  4039. );
  4040. this._hoverPreviewStopHandler = null;
  4041. if (this._hoverQuoteMouseHandler) {
  4042. window.removeEventListener('mousemove', this._hoverQuoteMouseHandler);
  4043. this._hoverQuoteMouseHandler = null;
  4044. }
  4045. document.removeEventListener('click', this._clickHandler, true);
  4046. document.removeEventListener('pointerover', this._pointerOverHandler, { passive: true });
  4047.  
  4048. this._clickHandler = null;
  4049. this._pointerOverHandler = null;
  4050. if (this.thread && this._threadClickHandler) {
  4051. this.thread.removeEventListener('click', this._threadClickHandler, true);
  4052. this._threadClickHandler = null;
  4053. }
  4054.  
  4055.  
  4056. this.youBox?.querySelector('.lst')?.replaceChildren?.(); // Fast DOM flush
  4057.  
  4058. this.thread = null;
  4059. this.postElements = null;
  4060.  
  4061. }
  4062.  
  4063. }
  4064.  
  4065. // == FullchanX Bootstrap Script ==
  4066. // This script registers a custom web component, manages its lifecycle,
  4067. // observes URL changes (for SPAs), injects styles, and handles cleanup on unload.
  4068.  
  4069. // 1. Register the custom element if it's not already registered
  4070. if (!customElements.get('fullchan-x')) {
  4071. customElements.define('fullchan-x', FullchanX);
  4072. console.debug('[FCX] Custom element registered.');
  4073. }
  4074.  
  4075. // 2. Initialize and add the <fullchan-x> element to the DOM
  4076. if (!window.fcxInstance) {
  4077. const initFCX = () => {
  4078. // Prevent duplicate insertion
  4079. if (document.querySelector('fullchan-x')) return;
  4080.  
  4081. // Create and append the custom element
  4082. const el = document.createElement('fullchan-x');
  4083. document.body.appendChild(el);
  4084. window.fcxInstance = el;
  4085. };
  4086.  
  4087. // Wait until the DOM is ready, then initialize
  4088. if (document.readyState === 'loading') {
  4089. document.addEventListener('DOMContentLoaded', () => requestAnimationFrame(initFCX));
  4090. } else {
  4091. this._aRaf9 = requestAnimationFrame(initFCX);
  4092. }
  4093. }
  4094. let currentPath = location.pathname;
  4095.  
  4096.  
  4097.  
  4098. // Observe the entire document body for changes
  4099. //urlChangeObserver.observe(document.body, { childList: true, subtree: true });
  4100.  
  4101. // 5. Insert <link rel="preconnect"> to speed up media requests to same origin
  4102. const preconnect = url => {
  4103. const link = document.createElement('link');
  4104. link.rel = 'preconnect';
  4105. link.href = url;
  4106. link.crossOrigin = ''; // Enables credentials for cross-origin use if needed
  4107. document.head.appendChild(link);
  4108. };
  4109.  
  4110. // Only add preconnect if using HTTP/S
  4111. if (location.origin.startsWith('http')) {
  4112. preconnect(location.origin);
  4113. }
  4114.  
  4115. // 6. Inject custom CSS for the FullchanX interface and behavior
  4116. const style = document.createElement('style');
  4117. style.textContent = `
  4118. /* Hide filtered post stubs when option is enabled */
  4119. body.fcx-hide-stubs .unhideButton {
  4120. display: none !important;
  4121. }
  4122.  
  4123.  
  4124. /* Hide user IDs when anonymize mode is active */
  4125. body.fcx-anonymize .spanId {
  4126. display: none !important;
  4127. }
  4128.  
  4129. /* Blurred hover previews */
  4130. .fcx-hover-blurred {
  4131. filter: blur(10px) brightness(0.8);
  4132. backdrop-filter: blur(10px);
  4133. transition: filter 0.2s ease;
  4134. pointer-events: none;
  4135. }
  4136.  
  4137. /* Override visibility styles */
  4138. .fcx-hover-visible {
  4139. display: block !important;
  4140. visibility: visible !important;
  4141. opacity: 1 !important;
  4142. }
  4143.  
  4144. /* Adjust height for hidden/transitioning elements */
  4145. .divRefresh.hidden,
  4146. #actionsForm.hidden,
  4147. #dynamicFooterAlt,
  4148. .threadBottom {
  4149. min-height: 10px;
  4150. transition: all 0.2s ease;
  4151. }
  4152.  
  4153. /* Style number inputs in settings panel */
  4154. #fcx-settings-panel input[type="number"] {
  4155. background: var(--contrast-color);
  4156. color: var(--link-color);
  4157. border: 1px solid var(--navbar-text-color);
  4158. padding: 4px;
  4159. border-radius: 4px;
  4160. box-sizing: border-box;
  4161. max-width: 80px; /* Prevent overly wide inputs */
  4162. }
  4163.  
  4164. /* Style range inputs in settings panel */
  4165. #fcx-settings-panel input[type="range"] {
  4166. width: 100%;
  4167. max-width: 100%;
  4168. box-sizing: border-box;
  4169. }
  4170.  
  4171. /* Label spacing for better readability */
  4172. #fcx-settings-panel label {
  4173. display: block;
  4174. margin-bottom: 10px;
  4175. line-height: 1.4;
  4176. }
  4177.  
  4178. /* Truncate long filenames */
  4179. .originalNameLink {
  4180. max-width: 14ch;
  4181. }
  4182. .nestedPost .innerPost {
  4183. transform: scale(0.98);
  4184. transform-origin: top left;
  4185. }
  4186.  
  4187. `;
  4188. document.head.appendChild(style);
  4189.  
  4190.  
  4191. // 7. Cleanup before leaving the page
  4192. window.addEventListener('beforeunload', () => {
  4193. if (window.fcxInstance) {
  4194. window.fcxInstance.cleanup?.();
  4195. window.fcxInstance.remove?.();
  4196. window.fcxInstance = null;
  4197. }
  4198. });
  4199.  
Advertisement
Add Comment
Please, Sign In to add comment