nodebb-plugin-ezoic-infinite 1.6.67 → 1.6.69

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/public/client.js +719 -923
package/public/client.js CHANGED
@@ -1,756 +1,512 @@
1
1
  (function () {
2
2
  'use strict';
3
3
 
4
- // ─── Scroll direction tracker ───────────────────────────────────────────────
5
- // Prevents aggressive recycling when the user scrolls upward.
6
- let lastScrollY = 0;
7
- let scrollDir = 1; // 1 = down, -1 = up
8
- try {
9
- lastScrollY = window.scrollY || 0;
10
- window.addEventListener('scroll', () => {
11
- const y = window.scrollY || 0;
12
- const d = y - lastScrollY;
13
- if (Math.abs(d) > 4) { scrollDir = d > 0 ? 1 : -1; lastScrollY = y; }
14
- }, { passive: true });
15
- } catch (e) {}
4
+ // ─── Constants ───────────────────────────────────────────────────────────────
5
+ const WRAP_CLASS = 'nodebb-ezoic-wrap';
6
+ const PLACEHOLDER_PREFIX = 'ezoic-pub-ad-placeholder-';
7
+ const HOST_CLASS = 'nodebb-ezoic-host';
8
+ const MAX_INSERTS_PER_RUN = 8;
16
9
 
17
- const $ = (typeof window.jQuery === 'function') ? window.jQuery : null;
10
+ // IntersectionObserver preload margins (large = request fills early).
11
+ const IO_MARGIN_DESKTOP = '2600px 0px 2600px 0px';
12
+ const IO_MARGIN_MOBILE = '3200px 0px 3200px 0px';
18
13
 
19
- // ─── Constants ──────────────────────────────────────────────────────────────
20
- const WRAP_CLASS = 'nodebb-ezoic-wrap';
21
- const PLACEHOLDER_PREFIX = 'ezoic-pub-ad-placeholder-';
22
- const HOST_CLASS = 'nodebb-ezoic-host';
23
- const MAX_INSERTS_PER_RUN = 8;
14
+ // Fast-scroll boost: temporarily widen IO margins.
15
+ const IO_MARGIN_DESKTOP_BOOST = '5200px 0px 5200px 0px';
16
+ const IO_MARGIN_MOBILE_BOOST = '5200px 0px 5200px 0px';
17
+ const BOOST_DURATION_MS = 2500;
18
+ const BOOST_SPEED_PX_PER_MS = 2.2;
24
19
 
25
- function keepEmptyWrapMs() { return isMobile() ? 120_000 : 60_000; }
26
-
27
- const PRELOAD_MARGIN_DESKTOP = '2600px 0px 2600px 0px';
28
- const PRELOAD_MARGIN_MOBILE = '3200px 0px 3200px 0px';
29
- const PRELOAD_MARGIN_DESKTOP_BOOST = '5200px 0px 5200px 0px';
30
- const PRELOAD_MARGIN_MOBILE_BOOST = '5200px 0px 5200px 0px';
31
- const BOOST_DURATION_MS = 2500;
32
- const BOOST_SPEED_PX_PER_MS = 2.2;
33
- const MAX_INFLIGHT_DESKTOP = 4;
34
- const MAX_INFLIGHT_MOBILE = 3;
35
-
36
- const SELECTORS = {
37
- topicItem: 'li[component="category/topic"]',
38
- postItem: '[component="post"][data-pid]',
39
- categoryItem: 'li[component="categories/category"]',
20
+ const MAX_INFLIGHT = { desktop: 4, mobile: 3 };
21
+
22
+ const SEL = {
23
+ topic: 'li[component="category/topic"]',
24
+ post: '[component="post"][data-pid]',
25
+ category: 'li[component="categories/category"]',
40
26
  };
41
27
 
42
- // ─── Shared helpers ─────────────────────────────────────────────────────────
28
+ // ─── Utils ───────────────────────────────────────────────────────────────────
29
+
30
+ const $ = (typeof window.jQuery === 'function') ? window.jQuery : null;
43
31
 
44
32
  function now() { return Date.now(); }
45
33
  function isMobile() { try { return window.innerWidth < 768; } catch (e) { return false; } }
46
- function isBoosted(){ return now() < (state.scrollBoostUntil || 0); }
34
+ function isBoosted(){ return now() < (st.boostUntil || 0); }
35
+ function isBlocked(){ return now() < blockedUntil; }
47
36
 
48
- function normalizeBool(v) {
37
+ function normBool(v) {
49
38
  return v === true || v === 'true' || v === 1 || v === '1' || v === 'on';
50
39
  }
51
40
 
52
- /** Returns true if the node contains a rendered ad creative. */
41
+ function parsePool(raw) {
42
+ if (!raw) return [];
43
+ const seen = new Set(), out = [];
44
+ for (const v of String(raw).split(/\r?\n/).map(s => s.trim()).filter(Boolean)) {
45
+ const n = parseInt(v, 10);
46
+ if (Number.isFinite(n) && n > 0 && !seen.has(n)) { seen.add(n); out.push(n); }
47
+ }
48
+ return out;
49
+ }
50
+
53
51
  function isFilled(node) {
54
52
  return !!(node && node.querySelector &&
55
53
  node.querySelector('iframe, ins, img, video, [data-google-container-id]'));
56
54
  }
57
55
 
58
- // ─── Console noise filter ───────────────────────────────────────────────────
59
- function muteNoisyConsole() {
56
+ function getMaxInflight() {
57
+ return (isMobile() ? MAX_INFLIGHT.mobile : MAX_INFLIGHT.desktop) + (isBoosted() ? 1 : 0);
58
+ }
59
+
60
+ function getIoMargin() {
61
+ return isMobile()
62
+ ? (isBoosted() ? IO_MARGIN_MOBILE_BOOST : IO_MARGIN_MOBILE)
63
+ : (isBoosted() ? IO_MARGIN_DESKTOP_BOOST : IO_MARGIN_DESKTOP);
64
+ }
65
+
66
+ function getPageKey() {
60
67
  try {
61
- if (window.__nodebbEzoicConsoleMuted) return;
62
- window.__nodebbEzoicConsoleMuted = true;
63
- const shouldMute = (args) => {
64
- try {
65
- const s = typeof args[0] === 'string' ? args[0] : '';
66
- return (
67
- (s.includes('[EzoicAds JS]: Placeholder Id') && s.includes('has already been defined')) ||
68
- s.includes('Debugger iframe already exists') ||
69
- (s.includes('HTML element with id ezoic-pub-ad-placeholder-') && s.includes('does not exist'))
70
- );
71
- } catch (e) { return false; }
72
- };
73
- const wrap = (m) => {
74
- const orig = console[m];
75
- if (typeof orig !== 'function') return;
76
- console[m] = function (...a) { if (shouldMute(a)) return; return orig.apply(console, a); };
68
+ const d = window.ajaxify && window.ajaxify.data;
69
+ if (d) {
70
+ if (d.tid) return 'topic:' + d.tid;
71
+ if (d.cid) return 'category:' + d.cid;
72
+ }
73
+ } catch (e) {}
74
+ return window.location.pathname;
75
+ }
76
+
77
+ function getKind() {
78
+ const p = window.location.pathname || '';
79
+ if (/^\/topic\//.test(p)) return 'topic';
80
+ if (/^\/category\//.test(p)) return 'categoryTopics';
81
+ if (p === '/' || /^\/categories/.test(p)) return 'categories';
82
+ if (document.querySelector(SEL.category)) return 'categories';
83
+ if (document.querySelector(SEL.post)) return 'topic';
84
+ if (document.querySelector(SEL.topic)) return 'categoryTopics';
85
+ return 'other';
86
+ }
87
+
88
+ function withInternal(fn) {
89
+ st.internalMut++;
90
+ try { fn(); } finally { st.internalMut--; }
91
+ }
92
+
93
+ // ─── State ───────────────────────────────────────────────────────────────────
94
+
95
+ const st = {
96
+ pageKey: null,
97
+ cfg: null,
98
+
99
+ pools: { topic: [], post: [], category: [] },
100
+ cursors: { topic: 0, post: 0, category: 0 },
101
+
102
+ lastShow: new Map(), // id → timestamp
103
+ inflight: 0,
104
+ pending: [],
105
+ pendingSet: new Set(),
106
+
107
+ io: null,
108
+ ioMargin: null,
109
+ domObs: null,
110
+
111
+ internalMut: 0,
112
+ boostUntil: 0,
113
+ lastScrollY: 0,
114
+ lastScrollTs: 0,
115
+
116
+ heroDone: false,
117
+ runQueued: false,
118
+ burstActive: false,
119
+ burstDeadline:0,
120
+ burstCount: 0,
121
+ lastBurstTs: 0,
122
+
123
+ // Detached wraps cache (to survive NodeBB virtual/infinite scroll recycling)
124
+ // kindClass -> Map(afterPos -> wrapNode)
125
+ detached: new Map(),
126
+ };
127
+
128
+ let blockedUntil = 0;
129
+ const inserting = new Set();
130
+
131
+ // ─── Console mute (Ezoic SPA spam) ───────────────────────────────────────────
132
+
133
+ function muteConsole() {
134
+ try {
135
+ if (window.__nbbEzoicMuted) return;
136
+ window.__nbbEzoicMuted = true;
137
+ const bad = (a) => {
138
+ const s = typeof a[0] === 'string' ? a[0] : '';
139
+ return (s.includes('[EzoicAds JS]: Placeholder Id') && s.includes('has already been defined'))
140
+ || s.includes('Debugger iframe already exists')
141
+ || (s.includes('HTML element with id ezoic-pub-ad-placeholder-') && s.includes('does not exist'));
77
142
  };
78
- ['log','info','warn','error'].forEach(wrap);
143
+ for (const m of ['log','info','warn','error']) {
144
+ const o = console[m];
145
+ if (typeof o !== 'function') continue;
146
+ console[m] = function(...a) { if (!bad(a)) o.apply(console, a); };
147
+ }
79
148
  } catch (e) {}
80
149
  }
81
150
 
82
- // ensureTcfApiLocator intentionally removed:
83
- // Creating our own __tcfapiLocator iframe conflicts with the CMP's own locator
84
- // management and causes "Cannot read properties of null (reading 'postMessage')"
85
- // errors when the CMP tries to postMessage to an iframe we created then navigated away.
86
- // The CMP (Ezoic/IAB TCF) handles its own locator iframe lifecycle.
87
- function ensureTcfApiLocator() { /* no-op */ }
88
-
89
- // ─── Ezoic min-height tightener ─────────────────────────────────────────────
90
- // Ezoic injects `min-height:400px !important` on nested wrappers via inline
91
- // style. CSS alone can't fix it — we must rewrite the inline style.
92
- function tightenEzoicMinHeight(wrap) {
151
+ // ─── Network warmup ──────────────────────────────────────────────────────────
152
+
153
+ const _warmed = new Set();
154
+ function warmNetwork() {
155
+ const head = document.head;
156
+ if (!head) return;
157
+ for (const [rel, href, cors] of [
158
+ ['preconnect', 'https://g.ezoic.net', true],
159
+ ['dns-prefetch', 'https://g.ezoic.net', false],
160
+ ['preconnect', 'https://go.ezoic.net', true],
161
+ ['preconnect', 'https://securepubads.g.doubleclick.net', true],
162
+ ['dns-prefetch', 'https://securepubads.g.doubleclick.net', false],
163
+ ['preconnect', 'https://pagead2.googlesyndication.com', true],
164
+ ]) {
165
+ const k = rel + href;
166
+ if (_warmed.has(k)) continue;
167
+ _warmed.add(k);
168
+ const l = document.createElement('link');
169
+ l.rel = rel; l.href = href;
170
+ if (cors) l.crossOrigin = 'anonymous';
171
+ head.appendChild(l);
172
+ }
173
+ }
174
+
175
+ // ─── Ezoic bridge ────────────────────────────────────────────────────────────
176
+ // Patch showAds so it silently skips IDs whose placeholder is not in the DOM.
177
+
178
+ function patchShowAds() {
179
+ const patch = () => {
180
+ try {
181
+ const ez = window.ezstandalone = window.ezstandalone || {};
182
+ if (window.__nbbEzoicPatched || typeof ez.showAds !== 'function') return;
183
+ window.__nbbEzoicPatched = true;
184
+ const orig = ez.showAds;
185
+ ez.showAds = function (...args) {
186
+ if (isBlocked()) return;
187
+ const ids = Array.isArray(args[0]) ? args[0] : args;
188
+ const seen = new Set();
189
+ for (const v of ids) {
190
+ const id = parseInt(v, 10);
191
+ if (!Number.isFinite(id) || id <= 0 || seen.has(id)) continue;
192
+ const ph = document.getElementById(PLACEHOLDER_PREFIX + id);
193
+ if (!ph || !ph.isConnected) continue;
194
+ seen.add(id);
195
+ try { orig.call(ez, id); } catch (e) {}
196
+ }
197
+ };
198
+ } catch (e) {}
199
+ };
200
+ patch();
201
+ if (!window.__nbbEzoicPatched) {
202
+ try {
203
+ (window.ezstandalone = window.ezstandalone || {}).cmd =
204
+ window.ezstandalone.cmd || [];
205
+ window.ezstandalone.cmd.push(patch);
206
+ } catch (e) {}
207
+ }
208
+ }
209
+
210
+ // ─── Config ──────────────────────────────────────────────────────────────────
211
+
212
+ async function fetchConfig() {
213
+ if (st.cfg) return st.cfg;
93
214
  try {
94
- if (!wrap || !wrap.querySelector) return;
215
+ const r = await fetch('/api/plugins/ezoic-infinite/config', { credentials: 'same-origin' });
216
+ if (r.ok) st.cfg = await r.json();
217
+ } catch (e) {}
218
+ return st.cfg;
219
+ }
220
+
221
+ function initPools(cfg) {
222
+ if (!cfg) return;
223
+ if (!st.pools.topic.length) st.pools.topic = parsePool(cfg.placeholderIds);
224
+ if (!st.pools.post.length) st.pools.post = parsePool(cfg.messagePlaceholderIds);
225
+ if (!st.pools.category.length) st.pools.category = parsePool(cfg.categoryPlaceholderIds);
226
+ }
227
+
228
+ // ─── Ezoic min-height tightener ──────────────────────────────────────────────
229
+ // Ezoic injects `min-height:400px !important` via inline style; we override it.
230
+
231
+ function tightenMinHeight(wrap) {
232
+ try {
233
+ if (!wrap) return;
95
234
  const iframes = wrap.querySelectorAll('iframe');
96
235
  if (!iframes.length) return;
97
236
 
98
- let refNode = null;
237
+ // Find the nearest ezoic-ad ancestor with the 400px inline min-height.
238
+ let ref = null;
99
239
  let p = iframes[0].parentElement;
100
240
  while (p && p !== wrap) {
101
241
  if (p.classList && p.classList.contains('ezoic-ad')) {
102
242
  const st = (p.getAttribute('style') || '').toLowerCase();
103
- if (st.includes('min-height:400') || st.includes('min-height: 400')) { refNode = p; break; }
243
+ if (st.includes('min-height:400') || st.includes('min-height: 400')) { ref = p; break; }
104
244
  }
105
245
  p = p.parentElement;
106
246
  }
107
- if (!refNode) refNode = wrap.querySelector('.ezoic-ad-adaptive') || wrap.querySelector('.ezoic-ad') || wrap;
247
+ ref = ref || wrap.querySelector('.ezoic-ad-adaptive, .ezoic-ad') || wrap;
108
248
 
109
249
  let refTop = 0;
110
- try { refTop = refNode.getBoundingClientRect().top; } catch (e) {}
250
+ try { refTop = ref.getBoundingClientRect().top; } catch (e) {}
111
251
 
112
- let maxBottom = 0;
113
- iframes.forEach((f) => {
114
- const rect = f.getBoundingClientRect();
115
- if (rect.width <= 1 || rect.height <= 1) return;
116
- maxBottom = Math.max(maxBottom, rect.bottom - refTop);
252
+ let maxH = 0;
253
+ iframes.forEach(f => {
254
+ const r = f.getBoundingClientRect();
255
+ if (r.width > 1 && r.height > 1) maxH = Math.max(maxH, r.bottom - refTop);
117
256
  });
118
- if (!maxBottom) {
119
- iframes.forEach((f) => {
120
- maxBottom = Math.max(maxBottom, parseInt(f.getAttribute('height') || '0', 10), f.offsetHeight || 0);
121
- });
122
- }
123
- if (!maxBottom) return;
257
+ if (!maxH) iframes.forEach(f => {
258
+ maxH = Math.max(maxH,
259
+ parseInt(f.getAttribute('height') || '0', 10), f.offsetHeight || 0);
260
+ });
261
+ if (!maxH) return;
124
262
 
125
- const h = Math.max(1, Math.ceil(maxBottom));
126
- const tightenNode = (n) => {
263
+ const h = Math.max(1, Math.ceil(maxH)) + 'px';
264
+ const set = (n) => {
127
265
  if (!n || !n.style) return;
128
- try { n.style.setProperty('min-height', h + 'px', 'important'); } catch (e) { n.style.minHeight = h + 'px'; }
266
+ try { n.style.setProperty('min-height', h, 'important'); } catch (e) { n.style.minHeight = h; }
129
267
  try { n.style.setProperty('height', 'auto', 'important'); } catch (e) {}
130
268
  try { n.style.setProperty('line-height', '0', 'important'); } catch (e) {}
131
269
  };
132
270
 
133
- let cur = refNode;
271
+ let cur = ref;
134
272
  while (cur && cur !== wrap) {
135
273
  if (cur.classList && cur.classList.contains('ezoic-ad')) {
136
- const st = (cur.getAttribute('style') || '').toLowerCase();
137
- if (st.includes('min-height:400') || st.includes('min-height: 400')) tightenNode(cur);
274
+ const s = (cur.getAttribute('style') || '').toLowerCase();
275
+ if (s.includes('min-height:400') || s.includes('min-height: 400')) set(cur);
138
276
  }
139
277
  cur = cur.parentElement;
140
278
  }
141
- tightenNode(refNode);
142
- refNode.querySelectorAll('.ezoic-ad[style*="min-height"], .ezoic-ad-adaptive').forEach((n) => {
143
- const st = (n.getAttribute('style') || '').toLowerCase();
144
- if (st.includes('min-height:400') || st.includes('min-height: 400')) tightenNode(n);
279
+ set(ref);
280
+ ref.querySelectorAll('.ezoic-ad[style*="min-height"], .ezoic-ad-adaptive').forEach(n => {
281
+ const s = (n.getAttribute('style') || '').toLowerCase();
282
+ if (s.includes('min-height:400') || s.includes('min-height: 400')) set(n);
145
283
  });
284
+
146
285
  if (isMobile()) {
147
- try { refNode.style.setProperty('width', '100%', 'important'); } catch (e) {}
148
- try { refNode.style.setProperty('max-width', '100%', 'important'); } catch (e) {}
149
- try { refNode.style.setProperty('min-width', '0', 'important'); } catch (e) {}
286
+ try { ref.style.setProperty('width', '100%', 'important'); } catch (e) {}
287
+ try { ref.style.setProperty('max-width', '100%', 'important'); } catch (e) {}
288
+ try { ref.style.setProperty('min-width', '0', 'important'); } catch (e) {}
150
289
  }
151
290
  } catch (e) {}
152
291
  }
153
292
 
154
- function watchWrapForFill(wrap) {
293
+ function watchFill(wrap) {
155
294
  try {
156
295
  if (!wrap || wrap.__ezFillObs) return;
157
296
  const start = now();
158
- const tightenBurst = () => {
159
- try { tightenEzoicMinHeight(wrap); } catch (e) {}
160
- if (now() - start < 6000) setTimeout(tightenBurst, 350);
297
+ const burst = () => {
298
+ try { tightenMinHeight(wrap); } catch (e) {}
299
+ if (now() - start < 6000) setTimeout(burst, 350);
161
300
  };
162
- const obs = new MutationObserver((muts) => {
163
- if (isFilled(wrap)) { wrap.classList.remove('is-empty'); tightenBurst(); }
164
- for (const m of muts) {
165
- if (m.type === 'attributes' && m.attributeName === 'style') {
166
- try { tightenEzoicMinHeight(wrap); } catch (e) {}
167
- break;
168
- }
169
- }
170
- if (now() - start > 7000) { try { obs.disconnect(); } catch (e) {} wrap.__ezFillObs = null; }
301
+ const obs = new MutationObserver(muts => {
302
+ if (isFilled(wrap)) { wrap.classList.remove('is-empty'); burst(); }
303
+ for (const m of muts)
304
+ if (m.type === 'attributes' && m.attributeName === 'style') { tightenMinHeight(wrap); break; }
305
+ if (now() - start > 7000) { obs.disconnect(); wrap.__ezFillObs = null; }
171
306
  });
172
307
  obs.observe(wrap, { childList: true, subtree: true, attributes: true, attributeFilter: ['style'] });
173
308
  wrap.__ezFillObs = obs;
174
309
  } catch (e) {}
175
310
  }
176
311
 
177
- function globalGapFixInit() {
312
+ // Global safety net: catch Ezoic 400px min-height inside posts.
313
+ function globalGapFix() {
178
314
  try {
179
- if (window.__nodebbEzoicGapFix) return;
180
- window.__nodebbEzoicGapFix = true;
315
+ if (window.__nbbEzoicGapFix) return;
316
+ window.__nbbEzoicGapFix = true;
181
317
  const root = document.getElementById('content') ||
182
318
  document.querySelector('[component="content"], #panel') || document.body;
183
- const inPostArea = (el) => {
184
- try { return !!(el && el.closest && el.closest('[component="post"], .topic, .posts, [component="topic"]')); }
185
- catch (e) { return false; }
186
- };
187
- const maybeFix = (r) => {
319
+ const inPost = el => !!(el && el.closest &&
320
+ el.closest('[component="post"], .topic, .posts, [component="topic"]'));
321
+ const fix = r => {
188
322
  if (!r || !r.querySelectorAll) return;
189
323
  r.querySelectorAll('.ezoic-ad[style*="min-height"], .ezoic-ad-adaptive[style*="min-height"]')
190
- .forEach((n) => {
191
- const st = (n.getAttribute('style') || '').toLowerCase();
192
- if (!st.includes('min-height:400')) return;
193
- if (!inPostArea(n)) return;
194
- try { tightenEzoicMinHeight(n.closest('.' + WRAP_CLASS) || n.parentElement || n); } catch (e) {}
324
+ .forEach(n => {
325
+ if (!(n.getAttribute('style') || '').toLowerCase().includes('min-height:400')) return;
326
+ if (!inPost(n)) return;
327
+ tightenMinHeight(n.closest('.' + WRAP_CLASS) || n.parentElement || n);
195
328
  });
196
329
  };
197
- requestAnimationFrame(() => maybeFix(root));
330
+ requestAnimationFrame(() => fix(root));
198
331
  const pending = new Set();
199
- let scheduled = false;
200
- const flush = () => {
201
- scheduled = false;
202
- pending.forEach((n) => { try { maybeFix(n); } catch (e) {} });
203
- pending.clear();
204
- };
205
- new MutationObserver((muts) => {
332
+ let sched = false;
333
+ new MutationObserver(muts => {
206
334
  for (const m of muts) {
207
- if (m.type === 'attributes') {
208
- const t = m.target && m.target.nodeType === 1 ? m.target : m.target && m.target.parentElement;
209
- if (t) pending.add(t);
210
- } else if (m.addedNodes) {
211
- m.addedNodes.forEach((n) => { if (n && n.nodeType === 1) pending.add(n); });
212
- }
335
+ const t = m.type === 'attributes'
336
+ ? (m.target.nodeType === 1 ? m.target : m.target.parentElement)
337
+ : null;
338
+ if (t) pending.add(t);
339
+ else m.addedNodes && m.addedNodes.forEach(n => n.nodeType === 1 && pending.add(n));
340
+ }
341
+ if (pending.size && !sched) {
342
+ sched = true;
343
+ requestAnimationFrame(() => { sched = false; pending.forEach(n => fix(n)); pending.clear(); });
213
344
  }
214
- if (pending.size && !scheduled) { scheduled = true; requestAnimationFrame(flush); }
215
345
  }).observe(root, { subtree: true, childList: true, attributes: true, attributeFilter: ['style'] });
216
346
  } catch (e) {}
217
347
  }
218
348
 
219
- // ─── State ──────────────────────────────────────────────────────────────────
220
- const state = {
221
- pageKey: null, cfg: null,
222
- allTopics: [], allPosts: [], allCategories: [],
223
- curTopics: 0, curPosts: 0, curCategories: 0,
224
- lastShowById: new Map(),
225
- domObs: null, io: null, ioMargin: null,
226
- internalDomChange: 0,
227
- inflight: 0, pending: [], pendingSet: new Set(),
228
- scrollBoostUntil: 0, lastScrollY: 0, lastScrollTs: 0,
229
- heroDoneForPage: false,
230
- runQueued: false, burstActive: false, burstDeadline: 0, burstCount: 0, lastBurstReqTs: 0,
231
- };
232
-
233
- let blockedUntil = 0;
234
- const insertingIds = new Set();
235
- function isBlocked() { return now() < blockedUntil; }
236
-
237
- // ─── Utils ──────────────────────────────────────────────────────────────────
238
- function parsePool(raw) {
239
- if (!raw) return [];
240
- const seen = new Set(), out = [];
241
- String(raw).split(/\r?\n/).map(s => s.trim()).filter(Boolean).forEach((v) => {
242
- const n = parseInt(v, 10);
243
- if (Number.isFinite(n) && n > 0 && !seen.has(n)) { seen.add(n); out.push(n); }
244
- });
245
- return out;
246
- }
247
-
248
- function getPageKey() {
249
- try {
250
- const ax = window.ajaxify;
251
- if (ax && ax.data) {
252
- if (ax.data.tid) return 'topic:' + ax.data.tid;
253
- if (ax.data.cid) return 'cid:' + ax.data.cid + ':' + window.location.pathname;
254
- }
255
- } catch (e) {}
256
- return window.location.pathname;
257
- }
349
+ // ─── DOM helpers ─────────────────────────────────────────────────────────────
258
350
 
259
- function getPreloadRootMargin() {
260
- return isMobile()
261
- ? (isBoosted() ? PRELOAD_MARGIN_MOBILE_BOOST : PRELOAD_MARGIN_MOBILE)
262
- : (isBoosted() ? PRELOAD_MARGIN_DESKTOP_BOOST : PRELOAD_MARGIN_DESKTOP);
263
- }
264
-
265
- function getMaxInflight() {
266
- return (isMobile() ? MAX_INFLIGHT_MOBILE : MAX_INFLIGHT_DESKTOP) + (isBoosted() ? 1 : 0);
267
- }
268
-
269
- function withInternalDomChange(fn) {
270
- state.internalDomChange++;
271
- try { fn(); } finally { state.internalDomChange--; }
272
- }
273
-
274
- // ─── DOM helpers ────────────────────────────────────────────────────────────
275
- function getTopicItems() { return Array.from(document.querySelectorAll(SELECTORS.topicItem)); }
276
- function getCategoryItems() { return Array.from(document.querySelectorAll(SELECTORS.categoryItem)); }
277
-
278
- function getPostContainers() {
279
- return Array.from(document.querySelectorAll(SELECTORS.postItem)).filter((el) => {
351
+ function getItems(kind) {
352
+ if (kind === 'topic') return Array.from(document.querySelectorAll(SEL.post)).filter(el => {
280
353
  if (!el.isConnected) return false;
281
354
  if (!el.querySelector('[component="post/content"]')) return false;
282
- const pp = el.parentElement && el.parentElement.closest('[component="post"][data-pid]');
283
- if (pp && pp !== el) return false;
284
- if (el.getAttribute('component') === 'post/parent') return false;
285
- return true;
355
+ const pp = el.parentElement && el.parentElement.closest(SEL.post);
356
+ return !(pp && pp !== el) && el.getAttribute('component') !== 'post/parent';
286
357
  });
358
+ if (kind === 'categoryTopics') return Array.from(document.querySelectorAll(SEL.topic));
359
+ if (kind === 'categories') return Array.from(document.querySelectorAll(SEL.category));
360
+ return [];
287
361
  }
288
362
 
289
- function getKind() {
290
- const p = window.location.pathname || '';
291
- if (/^\/topic\//.test(p)) return 'topic';
292
- if (/^\/category\//.test(p)) return 'categoryTopics';
293
- if (p === '/' || /^\/categories/.test(p)) return 'categories';
294
- if (document.querySelector(SELECTORS.categoryItem)) return 'categories';
295
- if (document.querySelector(SELECTORS.postItem)) return 'topic';
296
- if (document.querySelector(SELECTORS.topicItem)) return 'categoryTopics';
297
- return 'other';
298
- }
299
-
300
- function isAdjacentAd(target) {
301
- if (!target) return false;
302
- const next = target.nextElementSibling;
303
- if (next && next.classList && next.classList.contains(WRAP_CLASS)) return true;
304
- const prev = target.previousElementSibling;
305
- if (prev && prev.classList && prev.classList.contains(WRAP_CLASS)) return true;
306
- return false;
307
- }
308
-
309
- function findWrap(kindClass, afterPos) {
310
- return document.querySelector('.' + WRAP_CLASS + '.' + kindClass + '[data-ezoic-after="' + afterPos + '"]');
311
- }
312
-
313
- // ─── Network warmup ─────────────────────────────────────────────────────────
314
- const _warmLinksDone = new Set();
315
- function warmUpNetwork() {
316
- try {
317
- const head = document.head || document.getElementsByTagName('head')[0];
318
- if (!head) return;
319
- [
320
- ['preconnect', 'https://g.ezoic.net', true],
321
- ['dns-prefetch', 'https://g.ezoic.net', false],
322
- ['preconnect', 'https://go.ezoic.net', true],
323
- ['dns-prefetch', 'https://go.ezoic.net', false],
324
- ['preconnect', 'https://securepubads.g.doubleclick.net', true],
325
- ['dns-prefetch', 'https://securepubads.g.doubleclick.net', false],
326
- ['preconnect', 'https://pagead2.googlesyndication.com', true],
327
- ['dns-prefetch', 'https://pagead2.googlesyndication.com', false],
328
- ['preconnect', 'https://tpc.googlesyndication.com', true],
329
- ['dns-prefetch', 'https://tpc.googlesyndication.com', false],
330
- ].forEach(([rel, href, cors]) => {
331
- const key = rel + '|' + href;
332
- if (_warmLinksDone.has(key)) return;
333
- _warmLinksDone.add(key);
334
- const link = document.createElement('link');
335
- link.rel = rel; link.href = href;
336
- if (cors) link.crossOrigin = 'anonymous';
337
- head.appendChild(link);
338
- });
339
- } catch (e) {}
340
- }
341
-
342
- // ─── Ezoic bridge ───────────────────────────────────────────────────────────
343
- // Patch showAds so it silently skips IDs whose placeholder is not in the DOM.
344
- function patchShowAds() {
345
- const applyPatch = () => {
346
- try {
347
- window.ezstandalone = window.ezstandalone || {};
348
- const ez = window.ezstandalone;
349
- if (window.__nodebbEzoicPatched) return;
350
- if (typeof ez.showAds !== 'function') return;
351
- window.__nodebbEzoicPatched = true;
352
- const orig = ez.showAds;
353
- ez.showAds = function (...args) {
354
- if (isBlocked()) return;
355
- const ids = (args.length === 1 && Array.isArray(args[0])) ? args[0] : args;
356
- const seen = new Set();
357
- for (const v of ids) {
358
- const id = parseInt(v, 10);
359
- if (!Number.isFinite(id) || id <= 0 || seen.has(id)) continue;
360
- const ph = document.getElementById(PLACEHOLDER_PREFIX + id);
361
- if (!ph || !ph.isConnected) continue;
362
- seen.add(id);
363
- try { orig.call(ez, id); } catch (e) {}
364
- }
365
- };
366
- } catch (e) {}
367
- };
368
- applyPatch();
369
- if (!window.__nodebbEzoicPatched) {
370
- try {
371
- window.ezstandalone = window.ezstandalone || {};
372
- window.ezstandalone.cmd = window.ezstandalone.cmd || [];
373
- window.ezstandalone.cmd.push(applyPatch);
374
- } catch (e) {}
375
- }
376
- }
377
-
378
- // ─── Config ─────────────────────────────────────────────────────────────────
379
- async function fetchConfigOnce() {
380
- if (state.cfg) return state.cfg;
381
- try {
382
- const res = await fetch('/api/plugins/ezoic-infinite/config', { credentials: 'same-origin' });
383
- if (!res.ok) return null;
384
- state.cfg = await res.json();
385
- return state.cfg;
386
- } catch (e) { return null; }
363
+ function isAdjacentWrap(el) {
364
+ const n = el.nextElementSibling, p = el.previousElementSibling;
365
+ return (n && n.classList.contains(WRAP_CLASS)) || (p && p.classList.contains(WRAP_CLASS));
387
366
  }
388
367
 
389
- function initPools(cfg) {
390
- if (!cfg) return;
391
- // Pools are NOT pre-seeded into the DOM: connecting placeholders offscreen
392
- // causes Ezoic to pre-define slots → "already defined" errors on SPA navigations.
393
- if (!state.allTopics.length) state.allTopics = parsePool(cfg.placeholderIds);
394
- if (!state.allPosts.length) state.allPosts = parsePool(cfg.messagePlaceholderIds);
395
- if (!state.allCategories.length) state.allCategories = parsePool(cfg.categoryPlaceholderIds);
368
+ function findWrapAt(kindClass, afterPos) {
369
+ return document.querySelector('.' + WRAP_CLASS + '.' + kindClass +
370
+ '[data-ezoic-after="' + afterPos + '"]');
396
371
  }
397
372
 
398
- // ─── Insertion primitives ───────────────────────────────────────────────────
373
+ // ─── Wrap creation / destruction ─────────────────────────────────────────────
399
374
 
400
- function buildWrap(id, kindClass, afterPos, createPlaceholder) {
375
+ function buildWrap(id, kindClass, afterPos) {
401
376
  const wrap = document.createElement('div');
402
- wrap.className = WRAP_CLASS + ' ' + kindClass;
377
+ wrap.className = WRAP_CLASS + ' ' + kindClass;
403
378
  wrap.setAttribute('data-ezoic-after', String(afterPos));
404
379
  wrap.setAttribute('data-ezoic-wrapid', String(id));
405
380
  wrap.setAttribute('data-created', String(now()));
406
381
  if (afterPos === 1) wrap.setAttribute('data-ezoic-pin', '1');
407
382
  wrap.style.width = '100%';
408
- if (createPlaceholder) {
409
- const ph = document.createElement('div');
410
- ph.id = PLACEHOLDER_PREFIX + id;
411
- ph.setAttribute('data-ezoic-id', String(id));
412
- wrap.appendChild(ph);
413
- }
383
+ const ph = document.createElement('div');
384
+ ph.id = PLACEHOLDER_PREFIX + id;
385
+ ph.setAttribute('data-ezoic-id', String(id));
386
+ wrap.appendChild(ph);
414
387
  return wrap;
415
388
  }
416
389
 
417
- function insertAfter(target, id, kindClass, afterPos) {
418
- if (!target || !target.insertAdjacentElement) return null;
419
- if (findWrap(kindClass, afterPos)) return null;
420
- if (insertingIds.has(id)) return null;
421
- const existingPh = document.getElementById(PLACEHOLDER_PREFIX + id);
422
- insertingIds.add(id);
423
- try {
424
- const wrap = buildWrap(id, kindClass, afterPos, !existingPh);
425
- target.insertAdjacentElement('afterend', wrap);
426
- if (existingPh) {
427
- existingPh.setAttribute('data-ezoic-id', String(id));
428
- if (!wrap.firstElementChild) wrap.appendChild(existingPh);
429
- else wrap.replaceChild(existingPh, wrap.firstElementChild);
430
- }
431
- return wrap;
432
- } finally {
433
- insertingIds.delete(id);
434
- }
435
- }
436
-
437
- function releaseWrapNode(wrap) {
390
+ function dropWrap(wrap) {
391
+ // Unobserve placeholder, then remove the whole wrap node.
392
+ // The placeholder ID is freed back to the pool automatically because
393
+ // it no longer has a DOM element — pickId will pick it again.
438
394
  try {
439
- const ph = wrap && wrap.querySelector && wrap.querySelector('[id^="' + PLACEHOLDER_PREFIX + '"]');
440
- if (ph) {
441
- try { (document.body || document.documentElement).appendChild(ph); } catch (e) {}
442
- try { state.io && state.io.unobserve(ph); } catch (e) {}
443
- }
395
+ const ph = wrap.querySelector('[id^="' + PLACEHOLDER_PREFIX + '"]');
396
+ if (ph) try { st.io && st.io.unobserve(ph); } catch (e) {}
444
397
  } catch (e) {}
445
- try { wrap && wrap.remove && wrap.remove(); } catch (e) {}
398
+ try { wrap.remove(); } catch (e) {}
446
399
  }
447
400
 
448
- function pickIdFromAll(allIds, cursorKey) {
449
- const n = allIds.length;
450
- if (!n) return null;
451
- for (let tries = 0; tries < n; tries++) {
452
- const idx = state[cursorKey] % n;
453
- state[cursorKey] = (state[cursorKey] + 1) % n;
454
- const id = allIds[idx];
455
- const ph = document.getElementById(PLACEHOLDER_PREFIX + id);
456
- // Skip if placeholder is already mounted in a visible wrap.
457
- if (ph && ph.isConnected && ph.closest('.' + WRAP_CLASS)) continue;
458
- return id;
459
- }
460
- return null;
401
+ function getDetachedMap(kindClass) {
402
+ let m = st.detached.get(kindClass);
403
+ if (!m) { m = new Map(); st.detached.set(kindClass, m); }
404
+ return m;
461
405
  }
462
406
 
463
- // ─── Orphan / cluster management ────────────────────────────────────────────
464
- function pruneOrphanWraps(kindClass, items) {
465
- if (!items || !items.length) return 0;
466
- const itemSet = new Set(items);
467
- const isMessage = kindClass === 'ezoic-ad-message';
468
- const allowRelease = isMessage;
469
- let removed = 0;
470
-
471
- const hasNearbyItem = (wrap) => {
472
- // If wrap is inside a li.nodebb-ezoic-host, check the HOST's siblings.
473
- const pivot = (wrap.parentElement && wrap.parentElement.classList &&
474
- wrap.parentElement.classList.contains(HOST_CLASS))
475
- ? wrap.parentElement : wrap;
476
- let prev = pivot.previousElementSibling;
477
- for (let i = 0; i < 14 && prev; i++) {
478
- if (itemSet.has(prev)) return true;
479
- prev = prev.previousElementSibling;
480
- }
481
- let next = pivot.nextElementSibling;
482
- for (let i = 0; i < 14 && next; i++) {
483
- if (itemSet.has(next)) return true;
484
- next = next.nextElementSibling;
485
- }
486
- return false;
487
- };
488
-
489
- document.querySelectorAll('.' + WRAP_CLASS + '.' + kindClass).forEach((wrap) => {
490
- if (wrap.getAttribute('data-ezoic-pin') === '1') return;
491
-
492
- if (hasNearbyItem(wrap)) {
493
- // Anchor is in the DOM → always restore visibility, regardless of age.
494
- try { wrap.classList.remove('ez-orphan-hidden'); wrap.style.display = ''; } catch (e) {}
495
- return;
496
- }
497
-
498
- // Anchor is NOT in the DOM → always hide immediately, regardless of age.
499
- // KEY FIX: the previous code skipped this for fresh wraps (< keepEmptyWrapMs).
500
- // That meant a wrap whose anchor topic was virtualized away within the first
501
- // 60-120s would stay visible and float to the top of the list.
502
- try { wrap.classList.add('ez-orphan-hidden'); wrap.style.display = 'none'; } catch (e) {}
503
-
504
- // Release (message-ads only): only after the freshness window, and only
505
- // when far off-screen — we still want late-filling ads to have a chance.
506
- if (!allowRelease) return;
507
-
508
- const created = parseInt(wrap.getAttribute('data-created') || '0', 10);
509
- if (created && (now() - created) < keepEmptyWrapMs()) return;
407
+ function detachWrap(kindClass, wrap) {
408
+ // Keep a wrap node in memory so we can re-attach it later without
409
+ // re-requesting the same placement (avoids GPT "format already created" and
410
+ // prevents ads from vanishing permanently when NodeBB recycles DOM nodes).
411
+ try {
412
+ const afterPos = parseInt(wrap.getAttribute('data-ezoic-after') || '0', 10);
413
+ if (!afterPos) return dropWrap(wrap);
510
414
 
415
+ // Unobserve placeholder while detached
511
416
  try {
512
- const r = wrap.getBoundingClientRect();
513
- const vh = Math.max(1, window.innerHeight || 1);
514
- if (r.bottom > -vh * 2 && r.top < vh * 4) return;
515
- } catch (e) { return; }
516
-
517
- withInternalDomChange(() => releaseWrapNode(wrap));
518
- removed++;
519
- });
520
- return removed;
521
- }
417
+ const ph = wrap.querySelector('[id^="' + PLACEHOLDER_PREFIX + '"]');
418
+ if (ph) try { st.io && st.io.unobserve(ph); } catch (e) {}
419
+ } catch (e) {}
522
420
 
523
- function decluster(kindClass) {
524
- const wraps = Array.from(document.querySelectorAll('.' + WRAP_CLASS + '.' + kindClass));
525
- if (wraps.length < 2) return 0;
526
- // Between/category ads: hide the duplicate rather than releasing it.
527
- // Releasing frees IDs into the pool → re-injection at wrong positions on scroll-up.
528
- const allowRelease = kindClass === 'ezoic-ad-message';
529
- const evict = (n) => {
530
- if (allowRelease) { withInternalDomChange(() => releaseWrapNode(n)); }
531
- else { try { n.classList.add('ez-orphan-hidden'); n.style.display = 'none'; } catch (e) {} }
532
- };
533
- const isWrap = (el) => !!(el && el.classList && el.classList.contains(WRAP_CLASS));
534
- const isFresh = (w) => {
535
- const c = parseInt(w.getAttribute('data-created') || '0', 10);
536
- return c && (now() - c) < keepEmptyWrapMs();
537
- };
538
- let removed = 0;
539
- for (const w of wraps) {
540
- if (w.getAttribute && w.getAttribute('data-ezoic-pin') === '1') continue;
541
- if (w.classList && w.classList.contains('ez-orphan-hidden')) continue;
542
- let prev = w.previousElementSibling;
543
- for (let i = 0; i < 3 && prev; i++) {
544
- if (isWrap(prev)) {
545
- if (prev.getAttribute && prev.getAttribute('data-ezoic-pin') === '1') break;
546
- if (prev.classList && prev.classList.contains('ez-orphan-hidden')) break;
547
- const prevFilled = isFilled(prev);
548
- const curFilled = isFilled(w);
549
- if (curFilled) {
550
- if (!prevFilled && !isFresh(prev)) { evict(prev); removed++; }
551
- break;
552
- }
553
- if (prevFilled || !isFresh(w)) { evict(w); removed++; }
554
- break;
555
- }
556
- prev = prev.previousElementSibling;
421
+ const m = getDetachedMap(kindClass);
422
+ // If a cached wrap for that position already exists, prefer the newest
423
+ // and fully drop the old one.
424
+ if (m.has(afterPos)) {
425
+ try { dropWrap(m.get(afterPos)); } catch (e) {}
557
426
  }
558
- }
559
- return removed;
560
- }
561
-
562
- // ─── Pile-up repair for between-ads ─────────────────────────────────────────
563
- // When NodeBB's DOM structure requires between-ad DIVs to live inside a <ul>,
564
- // they must be wrapped in a <li> host. This module also corrects position
565
- // drift that occurs after infinite-scroll mutations.
566
- //
567
- // FIX (v18): replaced the previous blind `previousTopicLi()` DOM walk with
568
- // anchor lookup via `data-ezoic-after` + topic `data-index`. The old approach
569
- // was the ROOT CAUSE of ads piling below the first topic: during pile-up
570
- // detection it moved ALL piled wraps to after the SAME first topic it found.
571
- //
572
- // Now each wrap is moved to after the specific topic whose ordinal matches
573
- // the wrap's own `data-ezoic-after` attribute.
574
-
575
- function pileFixGetTopicList() {
576
- try {
577
- const li = document.querySelector(SELECTORS.topicItem);
578
- if (!li) return null;
579
- return li.closest ? li.closest('ul,ol') : null;
580
- } catch (e) { return null; }
581
- }
582
-
583
- function ensureHostForWrap(wrap, ul) {
584
- try {
585
- if (!wrap || wrap.nodeType !== 1) return null;
586
- const wrapSel = 'div.' + WRAP_CLASS + '.ezoic-ad-between';
587
- if (!wrap.matches || !wrap.matches(wrapSel)) return null;
588
- const existing = wrap.closest ? wrap.closest('li.' + HOST_CLASS) : null;
589
- if (existing) return existing;
590
- if (!ul) ul = wrap.closest ? wrap.closest('ul,ol') : null;
591
- if (!ul || !/^(UL|OL)$/i.test(ul.tagName)) return null;
592
- if (wrap.parentElement !== ul) return null;
593
- const host = document.createElement('li');
594
- host.className = HOST_CLASS;
595
- host.setAttribute('role', 'listitem');
596
- host.style.cssText = 'list-style:none;width:100%;';
597
- ul.insertBefore(host, wrap);
598
- host.appendChild(wrap);
599
- return host;
600
- } catch (e) { return null; }
601
- }
602
-
603
- function detectPileUp(ul) {
604
- try {
605
- let run = 0, maxRun = 0;
606
- const kids = ul.children;
607
- const wrapSel = 'div.' + WRAP_CLASS + '.ezoic-ad-between';
608
- for (let i = 0; i < kids.length; i++) {
609
- const el = kids[i];
610
- const isHost = el.tagName === 'LI' && el.classList && el.classList.contains(HOST_CLASS);
611
- const isBetween = (isHost && el.querySelector && el.querySelector(wrapSel)) ||
612
- (el.matches && el.matches(wrapSel));
613
- if (isBetween) { maxRun = Math.max(maxRun, ++run); }
614
- else if (el.matches && el.matches(SELECTORS.topicItem)) { run = 0; }
615
- else { run = 0; }
427
+ m.set(afterPos, wrap);
428
+
429
+ // Hard cap cache size per kind to avoid unbounded memory growth.
430
+ if (m.size > 80) {
431
+ const oldest = [...m.entries()].sort((a, b) => {
432
+ const ta = parseInt((a[1] && a[1].getAttribute('data-created')) || '0', 10);
433
+ const tb = parseInt((b[1] && b[1].getAttribute('data-created')) || '0', 10);
434
+ return ta - tb;
435
+ }).slice(0, m.size - 80);
436
+ oldest.forEach(([pos, node]) => {
437
+ m.delete(pos);
438
+ try { dropWrap(node); } catch (e) {}
439
+ });
616
440
  }
617
- return maxRun >= 2;
618
- } catch (e) { return false; }
619
- }
620
-
621
- /**
622
- * Move each pile-up host to after its correct anchor topic.
623
- *
624
- * Anchor is determined by matching the wrap's `data-ezoic-after` (1-based ordinal)
625
- * against each topic LI's `data-index` (0-based) → ordinal = data-index + 1.
626
- */
627
- function redistributePileUp(ul) {
628
- try {
629
- if (!ul) return;
630
- // Step 1: wrap bare between-ad DIVs that are direct <ul> children.
631
- const wrapSel = 'div.' + WRAP_CLASS + '.ezoic-ad-between';
632
- const directs = Array.from(ul.querySelectorAll(':scope > ' + wrapSel));
633
- directs.forEach((w) => ensureHostForWrap(w, ul));
634
-
635
- // Step 2: bail if no pile-up.
636
- if (!detectPileUp(ul)) return;
637
-
638
- // Step 3: build ordinal → topic LI map.
639
- const topicLis = Array.from(ul.querySelectorAll(':scope > ' + SELECTORS.topicItem));
640
- const ordinalMap = new Map();
641
- topicLis.forEach((li, i) => {
642
- const di = li.dataset && (li.dataset.index !== undefined ? li.dataset.index : null);
643
- const raw = di !== null && di !== undefined ? di : li.getAttribute('data-index');
644
- const ord = (raw !== null && raw !== undefined && raw !== '' && !isNaN(raw))
645
- ? parseInt(raw, 10) + 1 // data-index is 0-based; ordinals are 1-based
646
- : i + 1;
647
- ordinalMap.set(ord, li);
648
- });
649
-
650
- // Step 4: move each host to immediately after its correct anchor topic.
651
- const hosts = Array.from(ul.querySelectorAll(':scope > li.' + HOST_CLASS));
652
- hosts.forEach((host) => {
653
- try {
654
- const wrap = host.querySelector && host.querySelector(wrapSel);
655
- if (!wrap) return;
656
441
 
657
- const afterPos = parseInt(wrap.getAttribute('data-ezoic-after') || '0', 10);
658
- if (!afterPos) return;
659
-
660
- const anchor = ordinalMap.get(afterPos);
661
- if (!anchor) return; // anchor topic not loaded yet — leave in place
662
-
663
- if (host.previousElementSibling !== anchor) {
664
- anchor.insertAdjacentElement('afterend', host);
665
- }
666
- } catch (e) {}
667
- });
668
- } catch (e) {}
669
- }
670
-
671
- let _pileFixScheduled = false;
672
- let _pileFixLastRun = 0;
673
- const PILE_FIX_COOLDOWN = 180;
674
-
675
- function schedulePileFix() {
676
- if (now() - _pileFixLastRun < PILE_FIX_COOLDOWN) return;
677
- if (_pileFixScheduled) return;
678
- _pileFixScheduled = true;
679
- requestAnimationFrame(() => {
680
- _pileFixScheduled = false;
681
- _pileFixLastRun = now();
682
- try { const ul = pileFixGetTopicList(); if (ul) redistributePileUp(ul); } catch (e) {}
683
- });
442
+ // Actually detach from DOM
443
+ try { wrap.remove(); } catch (e) {}
444
+ } catch (e) {
445
+ try { dropWrap(wrap); } catch (x) {}
446
+ }
684
447
  }
685
448
 
686
- function initPileFixObserver() {
687
- try {
688
- if (typeof MutationObserver === 'undefined') return;
689
- const observe = (ul) => {
690
- new MutationObserver(() => schedulePileFix())
691
- .observe(ul, { childList: true, subtree: true });
692
- };
693
- const ul = pileFixGetTopicList();
694
- if (ul) {
695
- observe(ul);
696
- } else {
697
- const sentinel = new MutationObserver(() => {
698
- const u = pileFixGetTopicList();
699
- if (u) { try { sentinel.disconnect(); } catch (e) {} observe(u); }
700
- });
701
- sentinel.observe(document.documentElement || document.body, { childList: true, subtree: true });
702
- }
703
- } catch (e) {}
449
+ function pickId(poolKey, cursorKey) {
450
+ const pool = st.pools[poolKey];
451
+ if (!pool.length) return null;
452
+ const n = pool.length;
453
+ for (let tries = 0; tries < n; tries++) {
454
+ const idx = st.cursors[cursorKey] % n;
455
+ st.cursors[cursorKey] = (idx + 1) % n;
456
+ const id = pool[idx];
457
+ const ph = document.getElementById(PLACEHOLDER_PREFIX + id);
458
+ // ID is available if its placeholder is absent from DOM or is floating detached.
459
+ if (!ph || !ph.isConnected || !ph.closest('.' + WRAP_CLASS)) return id;
460
+ }
461
+ return null; // all IDs currently mounted
704
462
  }
705
463
 
706
- // ─── IntersectionObserver (preload / fast fill) ─────────────────────────────
707
- function ensurePreloadObserver() {
708
- const desiredMargin = getPreloadRootMargin();
709
- if (state.io && state.ioMargin === desiredMargin) return state.io;
710
-
711
- try { state.io && state.io.disconnect(); } catch (e) {}
712
- state.io = null;
464
+ // ─── IntersectionObserver ────────────────────────────────────────────────────
713
465
 
466
+ function ensureIO() {
467
+ const margin = getIoMargin();
468
+ if (st.io && st.ioMargin === margin) return st.io;
469
+ try { st.io && st.io.disconnect(); } catch (e) {}
470
+ st.io = null;
714
471
  try {
715
- state.io = new IntersectionObserver((entries) => {
716
- for (const ent of entries) {
717
- if (!ent.isIntersecting) continue;
718
- const el = ent.target;
719
- try { state.io && state.io.unobserve(el); } catch (e) {}
720
- const id = parseInt(el && el.getAttribute && el.getAttribute('data-ezoic-id'), 10);
721
- if (Number.isFinite(id) && id > 0) enqueueShow(id);
472
+ st.io = new IntersectionObserver(entries => {
473
+ for (const e of entries) {
474
+ if (!e.isIntersecting) continue;
475
+ try { st.io && st.io.unobserve(e.target); } catch (x) {}
476
+ const id = parseInt(e.target.getAttribute('data-ezoic-id'), 10);
477
+ if (id > 0) enqueueShow(id);
722
478
  }
723
- }, { root: null, rootMargin: desiredMargin, threshold: 0 });
724
- state.ioMargin = desiredMargin;
479
+ }, { rootMargin: margin, threshold: 0 });
480
+ st.ioMargin = margin;
481
+ // Re-observe all live placeholders.
725
482
  document.querySelectorAll('[id^="' + PLACEHOLDER_PREFIX + '"]')
726
- .forEach((n) => { try { state.io.observe(n); } catch (e) {} });
727
- } catch (e) { state.io = state.ioMargin = null; }
728
-
729
- return state.io;
483
+ .forEach(ph => { try { st.io.observe(ph); } catch (e) {} });
484
+ } catch (e) { st.io = st.ioMargin = null; }
485
+ return st.io;
730
486
  }
731
487
 
732
- function observePlaceholder(id) {
488
+ function observePh(id) {
733
489
  const ph = document.getElementById(PLACEHOLDER_PREFIX + id);
734
490
  if (!ph || !ph.isConnected) return;
735
491
  ph.setAttribute('data-ezoic-id', String(id));
736
- try { const io = ensurePreloadObserver(); io && io.observe(ph); } catch (e) {}
492
+ try { ensureIO() && ensureIO().observe(ph); } catch (e) {}
493
+ // Immediate fire if already near viewport.
737
494
  try {
738
495
  const r = ph.getBoundingClientRect();
739
- const mobile = isMobile(), boosted = isBoosted();
740
- const screens = boosted ? (mobile ? 9.0 : 5.0) : (mobile ? 6.0 : 3.0);
741
- const minBot = boosted ? (mobile ? -2600 : -1500) : (mobile ? -1400 : -800);
742
- if (r.top < window.innerHeight * screens && r.bottom > minBot) enqueueShow(id);
496
+ const mob = isMobile(), boost = isBoosted();
497
+ const scr = boost ? (mob ? 9 : 5) : (mob ? 6 : 3);
498
+ const bot = boost ? (mob ? -2600 : -1500) : (mob ? -1400 : -800);
499
+ if (r.top < window.innerHeight * scr && r.bottom > bot) enqueueShow(id);
743
500
  } catch (e) {}
744
501
  }
745
502
 
746
- // ─── Show queue ─────────────────────────────────────────────────────────────
503
+ // ─── Show queue ──────────────────────────────────────────────────────────────
504
+
747
505
  function enqueueShow(id) {
748
506
  if (!id || isBlocked()) return;
749
- const t = now();
750
- if (t - (state.lastShowById.get(id) || 0) < 900) return;
751
- const max = getMaxInflight();
752
- if (state.inflight >= max) {
753
- if (!state.pendingSet.has(id)) { state.pending.push(id); state.pendingSet.add(id); }
507
+ if (now() - (st.lastShow.get(id) || 0) < 900) return;
508
+ if (st.inflight >= getMaxInflight()) {
509
+ if (!st.pendingSet.has(id)) { st.pending.push(id); st.pendingSet.add(id); }
754
510
  return;
755
511
  }
756
512
  startShow(id);
@@ -758,414 +514,452 @@
758
514
 
759
515
  function drainQueue() {
760
516
  if (isBlocked()) return;
761
- const max = getMaxInflight();
762
- while (state.inflight < max && state.pending.length) {
763
- const id = state.pending.shift();
764
- state.pendingSet.delete(id);
517
+ while (st.inflight < getMaxInflight() && st.pending.length) {
518
+ const id = st.pending.shift();
519
+ st.pendingSet.delete(id);
765
520
  startShow(id);
766
521
  }
767
522
  }
768
523
 
769
- function markEmptyWrapper(id) {
770
- try {
771
- const ph = document.getElementById(PLACEHOLDER_PREFIX + id);
772
- if (!ph || !ph.isConnected) return;
773
- const wrap = ph.closest && ph.closest('.' + WRAP_CLASS);
774
- if (!wrap) return;
775
- setTimeout(() => {
776
- try {
777
- const ph2 = document.getElementById(PLACEHOLDER_PREFIX + id);
778
- if (!ph2 || !ph2.isConnected) return;
779
- const w2 = ph2.closest && ph2.closest('.' + WRAP_CLASS);
780
- if (!w2) return;
781
- const created = parseInt(w2.getAttribute('data-created') || '0', 10);
782
- if (created && (now() - created) < keepEmptyWrapMs()) return;
783
- if (!isFilled(ph2)) { w2.classList.add('is-empty'); watchWrapForFill(w2); }
784
- else { w2.classList.remove('is-empty'); tightenEzoicMinHeight(w2); }
785
- } catch (e) {}
786
- }, 15_000);
787
- } catch (e) {}
524
+ function scheduleMarkEmpty(id) {
525
+ setTimeout(() => {
526
+ try {
527
+ const ph = document.getElementById(PLACEHOLDER_PREFIX + id);
528
+ if (!ph || !ph.isConnected) return;
529
+ const w = ph.closest('.' + WRAP_CLASS);
530
+ if (!w) return;
531
+ if (isFilled(ph)) { w.classList.remove('is-empty'); tightenMinHeight(w); }
532
+ else { w.classList.add('is-empty'); watchFill(w); }
533
+ } catch (e) {}
534
+ }, 15_000);
788
535
  }
789
536
 
790
537
  function startShow(id) {
791
538
  if (!id || isBlocked()) return;
792
- state.inflight++;
793
- let released = false;
794
- const release = () => {
795
- if (released) return;
796
- released = true;
797
- state.inflight = Math.max(0, state.inflight - 1);
539
+ st.inflight++;
540
+ let done = false;
541
+ const finish = () => {
542
+ if (done) return; done = true;
543
+ st.inflight = Math.max(0, st.inflight - 1);
798
544
  drainQueue();
799
545
  };
800
- const hardTimer = setTimeout(release, 6500);
546
+ const timeout = setTimeout(finish, 6500);
547
+
801
548
  requestAnimationFrame(() => {
802
549
  try {
803
550
  if (isBlocked()) return;
804
551
  const ph = document.getElementById(PLACEHOLDER_PREFIX + id);
805
552
  if (!ph || !ph.isConnected) return;
806
- if (isFilled(ph)) { clearTimeout(hardTimer); release(); return; }
807
- const t = now();
808
- if (t - (state.lastShowById.get(id) || 0) < 900) return;
809
- state.lastShowById.set(id, t);
810
- window.ezstandalone = window.ezstandalone || {};
811
- const ez = window.ezstandalone;
553
+ if (isFilled(ph)) { clearTimeout(timeout); finish(); return; }
554
+ if (now() - (st.lastShow.get(id) || 0) < 900) return;
555
+ st.lastShow.set(id, now());
556
+
557
+ const ez = window.ezstandalone = window.ezstandalone || {};
812
558
  const doShow = () => {
813
559
  try { ez.showAds(id); } catch (e) {}
814
- try { markEmptyWrapper(id); } catch (e) {}
560
+ scheduleMarkEmpty(id);
815
561
  try {
816
- const ww = document.getElementById(PLACEHOLDER_PREFIX + id);
817
- const w2 = ww && ww.closest && ww.closest('.' + WRAP_CLASS);
818
- if (w2) {
819
- watchWrapForFill(w2);
820
- setTimeout(() => { try { tightenEzoicMinHeight(w2); } catch (e) {} }, 900);
821
- setTimeout(() => { try { tightenEzoicMinHeight(w2); } catch (e) {} }, 2200);
562
+ const w = document.getElementById(PLACEHOLDER_PREFIX + id)
563
+ ?.closest('.' + WRAP_CLASS);
564
+ if (w) {
565
+ watchFill(w);
566
+ setTimeout(() => tightenMinHeight(w), 900);
567
+ setTimeout(() => tightenMinHeight(w), 2200);
822
568
  }
823
569
  } catch (e) {}
824
- setTimeout(() => { clearTimeout(hardTimer); release(); }, 650);
570
+ setTimeout(() => { clearTimeout(timeout); finish(); }, 650);
825
571
  };
826
- if (Array.isArray(ez.cmd)) { try { ez.cmd.push(doShow); } catch (e) { doShow(); } }
572
+ if (Array.isArray(ez.cmd)) try { ez.cmd.push(doShow); } catch (e) { doShow(); }
827
573
  else doShow();
828
- } finally {
829
- // hardTimer covers early returns.
830
- }
574
+ } finally {} // timeout covers early returns
831
575
  });
832
576
  }
833
577
 
834
- // ─── Core injection ─────────────────────────────────────────────────────────
835
- function getItemOrdinal(el, fallbackIndex) {
578
+ // ─── Core injection ──────────────────────────────────────────────────────────
579
+
580
+ function getOrdinal(el, idx) {
836
581
  try {
837
- if (!el) return fallbackIndex + 1;
838
- const di = el.getAttribute('data-index') ||
839
- (el.dataset && (el.dataset.index || el.dataset.postIndex));
840
- if (di != null && di !== '' && !isNaN(di)) {
841
- const n = parseInt(di, 10);
842
- if (Number.isFinite(n) && n >= 0) return n + 1;
843
- }
844
- const d1 = el.getAttribute('data-idx') || el.getAttribute('data-position') ||
845
- (el.dataset && (el.dataset.idx || el.dataset.position));
846
- if (d1 != null && d1 !== '' && !isNaN(d1)) {
847
- const n = parseInt(d1, 10);
848
- if (Number.isFinite(n) && n > 0) return n;
849
- }
582
+ const v = el.getAttribute('data-index') || (el.dataset && el.dataset.index);
583
+ if (v != null && v !== '' && !isNaN(v)) return parseInt(v, 10) + 1;
850
584
  } catch (e) {}
851
- return fallbackIndex + 1;
585
+ return idx + 1;
852
586
  }
853
587
 
854
- function buildOrdinalMap(items) {
588
+ function computeTargets(items, interval, showFirst) {
589
+ // Build ordinal map and determine target positions.
855
590
  const map = new Map();
856
591
  let max = 0;
857
- for (let i = 0; i < items.length; i++) {
858
- const ord = getItemOrdinal(items[i], i);
859
- map.set(ord, items[i]);
592
+ items.forEach((el, i) => {
593
+ const ord = getOrdinal(el, i);
594
+ map.set(ord, el);
860
595
  if (ord > max) max = ord;
861
- }
862
- return { map, max };
596
+ });
597
+ const targets = [];
598
+ if (showFirst && max >= 1) targets.push(1);
599
+ for (let i = interval; i <= max; i += interval) targets.push(i);
600
+ return { map, targets: [...new Set(targets)].sort((a, b) => a - b) };
863
601
  }
864
602
 
865
- function computeTargets(count, interval, showFirst) {
866
- const out = [];
867
- if (count <= 0) return out;
868
- if (showFirst) out.push(1);
869
- for (let i = 1; i <= count; i++) { if (i % interval === 0) out.push(i); }
870
- return Array.from(new Set(out)).sort((a, b) => a - b);
871
- }
603
+ /**
604
+ * Remove all wraps whose anchor item is no longer in the live item set.
605
+ *
606
+ * Strategy: always fully remove (not just hide).
607
+ * - The placeholder ID returns to the pool naturally (no DOM element pickId skips).
608
+ * - When the user scrolls back down and those items reload, fresh wraps are injected.
609
+ * - The viewport guard in injectWraps (rect.bottom < 0) prevents re-injection
610
+ * on items that are above the fold (just loaded by NodeBB during upward scroll).
611
+ */
612
+ function removeOrphanWraps(kindClass, itemSet) {
613
+ document.querySelectorAll('.' + WRAP_CLASS + '.' + kindClass).forEach(wrap => {
614
+ if (wrap.getAttribute('data-ezoic-pin') === '1') return;
615
+
616
+ // Check if anchor is still alive using DOM proximity.
617
+ const pivot = (wrap.parentElement && wrap.parentElement.classList.contains(HOST_CLASS))
618
+ ? wrap.parentElement : wrap;
619
+ let found = false, el = pivot.previousElementSibling;
620
+ for (let i = 0; i < 4 && el; i++, el = el.previousElementSibling)
621
+ if (itemSet.has(el)) { found = true; break; }
622
+ if (!found) {
623
+ el = pivot.nextElementSibling;
624
+ for (let i = 0; i < 4 && el; i++, el = el.nextElementSibling)
625
+ if (itemSet.has(el)) { found = true; break; }
626
+ }
872
627
 
873
- function pickRecyclableWrap(kindClass) {
874
- const wraps = document.querySelectorAll('.' + kindClass + '[data-ezoic-wrapid]');
875
- if (!wraps || !wraps.length) return null;
876
- const vh = Math.max(300, window.innerHeight || 800);
877
- const threshold = -Math.min(9000, Math.round(vh * 6));
878
- let best = null, bestBottom = Infinity;
879
- for (const w of wraps) {
880
- if (!w.isConnected) continue;
881
- if (w.getAttribute('data-ezoic-pin') === '1') continue;
882
- // FIX: do not recycle near-top wraps (afterPos ≤ 6).
883
- // These are likely to be visible again when the user scrolls back up.
884
- const afterPos = parseInt(w.getAttribute('data-ezoic-after') || '0', 10);
885
- if (afterPos > 0 && afterPos <= 6) continue;
886
- const rect = w.getBoundingClientRect();
887
- if (rect.bottom < threshold && rect.bottom < bestBottom) {
888
- bestBottom = rect.bottom;
889
- best = w;
628
+ if (!found) {
629
+ // Detach instead of destroy so ads can survive DOM recycling.
630
+ withInternal(() => detachWrap(kindClass, wrap));
890
631
  }
891
- }
892
- return best;
632
+ });
893
633
  }
894
634
 
895
- function moveWrapAfter(anchorEl, wrap, kindClass, afterPos) {
635
+ function reapDetached(kindClass) {
636
+ // Soft-prune very old detached nodes.
896
637
  try {
897
- if (!anchorEl || !wrap || !wrap.isConnected) return null;
898
- wrap.setAttribute('data-ezoic-after', String(afterPos));
899
- anchorEl.insertAdjacentElement('afterend', wrap);
900
- try { wrap.style.contain = 'layout style paint'; } catch (e) {}
901
- return wrap;
902
- } catch (e) { return null; }
638
+ const m = st.detached.get(kindClass);
639
+ if (!m || !m.size) return;
640
+ for (const [pos, wrap] of m.entries()) {
641
+ if (!wrap) { m.delete(pos); continue; }
642
+ if (wrap.isConnected) { m.delete(pos); continue; }
643
+ const created = parseInt(wrap.getAttribute('data-created') || '0', 10);
644
+ if (created && (now() - created) > 15 * 60 * 1000) {
645
+ m.delete(pos);
646
+ try { dropWrap(wrap); } catch (e) {}
647
+ }
648
+ }
649
+ } catch (e) {}
903
650
  }
904
651
 
905
- function injectBetween(kindClass, items, interval, showFirst, allIds, cursorKey) {
652
+ function injectWraps(kindClass, items, interval, showFirst, poolKey, cursorKey) {
906
653
  if (!items.length) return 0;
907
- const { map: ordinalMap, max: maxOrdinal } = buildOrdinalMap(items);
908
- const targets = computeTargets(maxOrdinal, interval, showFirst);
909
- const maxInserts = MAX_INSERTS_PER_RUN + (isBoosted() ? 1 : 0);
910
- let inserted = 0;
654
+ const { map, targets } = computeTargets(items, interval, showFirst);
655
+ const max = MAX_INSERTS_PER_RUN + (isBoosted() ? 1 : 0);
656
+ let n = 0;
911
657
 
912
- for (const afterPos of targets) {
913
- if (inserted >= maxInserts) break;
914
- const el = ordinalMap.get(afterPos);
658
+ const detachedMap = st.detached.get(kindClass);
659
+
660
+ for (const pos of targets) {
661
+ if (n >= max) break;
662
+ const el = map.get(pos);
915
663
  if (!el || !el.isConnected) continue;
916
664
 
917
- // Never inject after an element that is fully above the viewport top.
918
- // This is the definitive guard: NodeBB loads items above the fold when the
919
- // user scrolls up. Those items have rect.bottom < 0 at the moment of load.
920
- // Any negative margin ("a little above is ok") still causes the pile-up
921
- // because NodeBB loads a whole batch just above we must use exactly 0.
922
- try {
923
- if (el.getBoundingClientRect().bottom < 0) continue;
924
- } catch (e) {}
665
+ // ── Viewport guard ────────────────────────────────────────────────────────
666
+ // Never inject after an element whose bottom is above the viewport top.
667
+ // NodeBB loads items above the fold when the user scrolls up; those items
668
+ // have rect.bottom < 0 at load time. Injecting there causes the pile-up.
669
+ try { if (el.getBoundingClientRect().bottom < 0) continue; } catch (e) {}
670
+
671
+ if (isAdjacentWrap(el)) continue;
672
+ if (findWrapAt(kindClass, pos)) continue;
673
+
674
+ // If we previously had a wrap at this exact position but NodeBB recycled
675
+ // the DOM, re-attach the cached wrap instead of creating a new one.
676
+ if (detachedMap && detachedMap.has(pos)) {
677
+ const cached = detachedMap.get(pos);
678
+ detachedMap.delete(pos);
679
+ if (cached) {
680
+ withInternal(() => el.insertAdjacentElement('afterend', cached));
681
+ // Re-observe placeholder and trigger show only if the cached wrap is empty.
682
+ try {
683
+ const ph = cached.querySelector('[id^="' + PLACEHOLDER_PREFIX + '"]');
684
+ const id = ph ? parseInt(ph.getAttribute('data-ezoic-id') || (ph.id || '').split('-').pop(), 10) : 0;
685
+ if (id > 0) {
686
+ observePh(id);
687
+ if (!isFilled(cached)) enqueueShow(id);
688
+ }
689
+ } catch (e) {}
690
+ n++;
691
+ continue;
692
+ }
693
+ }
925
694
 
926
- if (isAdjacentAd(el)) continue;
927
- if (findWrap(kindClass, afterPos)) continue;
695
+ const id = pickId(poolKey, cursorKey);
696
+ if (!id) break;
928
697
 
929
- let id = pickIdFromAll(allIds, cursorKey);
930
- let recycledWrap = null;
698
+ const wrap = buildWrap(id, kindClass, pos);
699
+ withInternal(() => el.insertAdjacentElement('afterend', wrap));
700
+ observePh(id);
701
+ n++;
702
+ }
703
+ return n;
704
+ }
931
705
 
932
- if (!id) {
933
- // Pool exhausted: try to recycle a wrap that is far above the viewport.
934
- // Recycling is disabled when scrolling up and for message ads.
935
- // Recycling is disabled for between/category ads (we never release those wraps).
936
- const allowRecycle = kindClass === 'ezoic-ad-message' && scrollDir > 0;
937
- recycledWrap = allowRecycle ? pickRecyclableWrap(kindClass) : null;
938
- if (recycledWrap) id = recycledWrap.getAttribute('data-ezoic-wrapid') || '';
939
- }
706
+ // ─── Pile-up fix for category topic lists ────────────────────────────────────
707
+ // If the skin requires ad divs inside a <ul>, wrap them in <li> hosts and
708
+ // correct their position after NodeBB re-renders.
940
709
 
941
- if (!id) break;
710
+ let _pileFixSched = false, _pileFixLast = 0;
711
+ function schedulePileFix() {
712
+ if (now() - _pileFixLast < 180 || _pileFixSched) return;
713
+ _pileFixSched = true;
714
+ requestAnimationFrame(() => {
715
+ _pileFixSched = false; _pileFixLast = now();
716
+ try {
717
+ const li = document.querySelector(SEL.topic);
718
+ const ul = li && li.closest('ul,ol');
719
+ if (!ul) return;
720
+
721
+ // Wrap bare ad divs that are direct <ul> children into <li> hosts.
722
+ const wrapSel = 'div.' + WRAP_CLASS + '.ezoic-ad-between';
723
+ ul.querySelectorAll(':scope > ' + wrapSel).forEach(w => {
724
+ if (w.parentElement !== ul) return;
725
+ const host = document.createElement('li');
726
+ host.className = HOST_CLASS;
727
+ host.setAttribute('role', 'listitem');
728
+ host.style.cssText = 'list-style:none;width:100%;';
729
+ ul.insertBefore(host, w);
730
+ host.appendChild(w);
731
+ });
942
732
 
943
- const wrap = recycledWrap
944
- ? moveWrapAfter(el, recycledWrap, kindClass, afterPos)
945
- : insertAfter(el, id, kindClass, afterPos);
946
- if (!wrap) continue;
733
+ // Detect pile-up: 2+ between-ad hosts adjacent with no topic between them.
734
+ let run = 0, maxRun = 0;
735
+ for (const c of ul.children) {
736
+ const isBetween = (c.tagName === 'LI' && c.classList.contains(HOST_CLASS) &&
737
+ c.querySelector(wrapSel)) ||
738
+ (c.matches && c.matches(wrapSel));
739
+ if (isBetween) maxRun = Math.max(maxRun, ++run);
740
+ else if (c.matches && c.matches(SEL.topic)) run = 0;
741
+ else run = 0;
742
+ }
743
+ if (maxRun < 2) return;
744
+
745
+ // Build ordinal → topic LI map.
746
+ const ordMap = new Map();
747
+ Array.from(ul.querySelectorAll(':scope > ' + SEL.topic)).forEach((li, i) => {
748
+ const di = li.dataset && li.dataset.index;
749
+ const ord = (di != null && di !== '' && !isNaN(di)) ? parseInt(di, 10) + 1 : i + 1;
750
+ ordMap.set(ord, li);
751
+ });
947
752
 
948
- observePlaceholder(id);
949
- inserted++;
950
- }
951
- return inserted;
753
+ // Move each host to after its correct anchor topic.
754
+ ul.querySelectorAll(':scope > li.' + HOST_CLASS).forEach(host => {
755
+ const wrap = host.querySelector(wrapSel);
756
+ if (!wrap) return;
757
+ const pos = parseInt(wrap.getAttribute('data-ezoic-after') || '0', 10);
758
+ const anchor = pos && ordMap.get(pos);
759
+ if (anchor && host.previousElementSibling !== anchor)
760
+ anchor.insertAdjacentElement('afterend', host);
761
+ });
762
+ } catch (e) {}
763
+ });
952
764
  }
953
765
 
954
- // ─── runCore ────────────────────────────────────────────────────────────────
766
+ // ─── runCore ─────────────────────────────────────────────────────────────────
767
+
955
768
  async function runCore() {
956
769
  if (isBlocked()) return 0;
957
770
  patchShowAds();
958
- const cfg = await fetchConfigOnce();
771
+ const cfg = await fetchConfig();
959
772
  if (!cfg || cfg.excluded) return 0;
960
773
  initPools(cfg);
961
774
 
962
775
  const kind = getKind();
963
776
  let inserted = 0;
964
777
 
965
- // When the user is scrolling UP, NodeBB loads content above the viewport.
966
- // Injecting new ad wraps at that moment targets those freshly-loaded top items
967
- // (low ordinals) and makes ads appear right at the top of the list.
968
- // While scrolling up we only restore previously-hidden wraps (pruneOrphanWraps
969
- // un-hides them as their anchor posts return) — no new injections.
970
- const canInject = scrollDir >= 0;
971
-
972
- if (kind === 'topic' && normalizeBool(cfg.enableMessageAds)) {
973
- const items = getPostContainers();
974
- pruneOrphanWraps('ezoic-ad-message', items);
975
- if (canInject) {
976
- inserted += injectBetween('ezoic-ad-message', items,
977
- Math.max(1, parseInt(cfg.messageIntervalPosts, 10) || 3),
978
- normalizeBool(cfg.showFirstMessageAd), state.allPosts, 'curPosts');
979
- decluster('ezoic-ad-message');
980
- }
981
-
982
- } else if (kind === 'categoryTopics' && normalizeBool(cfg.enableBetweenAds)) {
983
- const items = getTopicItems();
984
- pruneOrphanWraps('ezoic-ad-between', items);
985
- if (canInject) {
986
- inserted += injectBetween('ezoic-ad-between', items,
987
- Math.max(1, parseInt(cfg.intervalPosts, 10) || 6),
988
- normalizeBool(cfg.showFirstTopicAd), state.allTopics, 'curTopics');
989
- decluster('ezoic-ad-between');
990
- schedulePileFix();
991
- }
992
-
993
- } else if (kind === 'categories' && normalizeBool(cfg.enableCategoryAds)) {
994
- const items = getCategoryItems();
995
- pruneOrphanWraps('ezoic-ad-categories', items);
996
- if (canInject) {
997
- inserted += injectBetween('ezoic-ad-categories', items,
998
- Math.max(1, parseInt(cfg.intervalCategories, 10) || 4),
999
- normalizeBool(cfg.showFirstCategoryAd), state.allCategories, 'curCategories');
1000
- decluster('ezoic-ad-categories');
1001
- }
778
+ if (kind === 'topic' && normBool(cfg.enableMessageAds)) {
779
+ const items = getItems('topic');
780
+ const set = new Set(items);
781
+ removeOrphanWraps('ezoic-ad-message', set);
782
+ reapDetached('ezoic-ad-message');
783
+ inserted += injectWraps('ezoic-ad-message', items,
784
+ Math.max(1, parseInt(cfg.messageIntervalPosts, 10) || 3),
785
+ normBool(cfg.showFirstMessageAd), 'post', 'post');
786
+
787
+ } else if (kind === 'categoryTopics' && normBool(cfg.enableBetweenAds)) {
788
+ const items = getItems('categoryTopics');
789
+ const set = new Set(items);
790
+ removeOrphanWraps('ezoic-ad-between', set);
791
+ reapDetached('ezoic-ad-between');
792
+ inserted += injectWraps('ezoic-ad-between', items,
793
+ Math.max(1, parseInt(cfg.intervalPosts, 10) || 6),
794
+ normBool(cfg.showFirstTopicAd), 'topic', 'topic');
795
+ schedulePileFix();
796
+
797
+ } else if (kind === 'categories' && normBool(cfg.enableCategoryAds)) {
798
+ const items = getItems('categories');
799
+ const set = new Set(items);
800
+ removeOrphanWraps('ezoic-ad-categories', set);
801
+ reapDetached('ezoic-ad-categories');
802
+ inserted += injectWraps('ezoic-ad-categories', items,
803
+ Math.max(1, parseInt(cfg.intervalCategories, 10) || 4),
804
+ normBool(cfg.showFirstCategoryAd), 'category', 'category');
1002
805
  }
1003
806
 
1004
807
  return inserted;
1005
808
  }
1006
809
 
1007
- // ─── Hero ad (first item, early insert) ─────────────────────────────────────
1008
- async function insertHeroAdEarly() {
1009
- if (state.heroDoneForPage || isBlocked()) return;
1010
- const cfg = await fetchConfigOnce();
810
+ // ─── Hero ad (inject immediately after first item) ───────────────────────────
811
+
812
+ async function heroAd() {
813
+ if (st.heroDone || isBlocked()) return;
814
+ const cfg = await fetchConfig();
1011
815
  if (!cfg || cfg.excluded) return;
1012
816
  initPools(cfg);
1013
817
 
1014
818
  const kind = getKind();
1015
- let items = [], allIds = [], cursorKey = '', kindClass = '', showFirst = false;
1016
-
1017
- if (kind === 'topic' && normalizeBool(cfg.enableMessageAds)) {
1018
- items = getPostContainers(); allIds = state.allPosts; cursorKey = 'curPosts';
1019
- kindClass = 'ezoic-ad-message'; showFirst = normalizeBool(cfg.showFirstMessageAd);
1020
- } else if (kind === 'categoryTopics' && normalizeBool(cfg.enableBetweenAds)) {
1021
- items = getTopicItems(); allIds = state.allTopics; cursorKey = 'curTopics';
1022
- kindClass = 'ezoic-ad-between'; showFirst = normalizeBool(cfg.showFirstTopicAd);
1023
- } else if (kind === 'categories' && normalizeBool(cfg.enableCategoryAds)) {
1024
- items = getCategoryItems(); allIds = state.allCategories; cursorKey = 'curCategories';
1025
- kindClass = 'ezoic-ad-categories'; showFirst = normalizeBool(cfg.showFirstCategoryAd);
819
+ let items = [], showFirst = false, poolKey = '', cursorKey = '', kindClass = '';
820
+
821
+ if (kind === 'topic' && normBool(cfg.enableMessageAds)) {
822
+ items = getItems('topic'); showFirst = normBool(cfg.showFirstMessageAd);
823
+ poolKey = cursorKey = 'post'; kindClass = 'ezoic-ad-message';
824
+ } else if (kind === 'categoryTopics' && normBool(cfg.enableBetweenAds)) {
825
+ items = getItems('categoryTopics'); showFirst = normBool(cfg.showFirstTopicAd);
826
+ poolKey = cursorKey = 'topic'; kindClass = 'ezoic-ad-between';
827
+ } else if (kind === 'categories' && normBool(cfg.enableCategoryAds)) {
828
+ items = getItems('categories'); showFirst = normBool(cfg.showFirstCategoryAd);
829
+ poolKey = cursorKey = 'category'; kindClass = 'ezoic-ad-categories';
1026
830
  } else { return; }
1027
831
 
1028
- if (!items.length || !showFirst) { state.heroDoneForPage = true; return; }
832
+ if (!items.length || !showFirst) { st.heroDone = true; return; }
1029
833
 
1030
834
  const el = items[0];
1031
- if (!el || !el.isConnected || isAdjacentAd(el)) return;
1032
- if (findWrap(kindClass, 1)) { state.heroDoneForPage = true; return; }
835
+ if (!el || !el.isConnected || isAdjacentWrap(el) || findWrapAt(kindClass, 1))
836
+ { st.heroDone = true; return; }
1033
837
 
1034
- const id = pickIdFromAll(allIds, cursorKey);
838
+ const id = pickId(poolKey, cursorKey);
1035
839
  if (!id) return;
1036
840
 
1037
- const wrap = insertAfter(el, id, kindClass, 1);
1038
- if (!wrap) return;
1039
-
1040
- state.heroDoneForPage = true;
1041
- observePlaceholder(id);
841
+ const wrap = buildWrap(id, kindClass, 1);
842
+ withInternal(() => el.insertAdjacentElement('afterend', wrap));
843
+ st.heroDone = true;
844
+ observePh(id);
1042
845
  enqueueShow(id);
1043
- drainQueue(); // FIX: was calling undefined startShowQueue()
846
+ drainQueue();
1044
847
  }
1045
848
 
1046
- // ─── Scheduler ──────────────────────────────────────────────────────────────
1047
- function scheduleRun(delayMs, cb) {
1048
- if (state.runQueued) return;
1049
- state.runQueued = true;
849
+ // ─── Burst scheduler ─────────────────────────────────────────────────────────
850
+
851
+ function scheduleRun(delay, cb) {
852
+ if (st.runQueued) return;
853
+ st.runQueued = true;
1050
854
  const run = async () => {
1051
- state.runQueued = false;
1052
- const pk = getPageKey();
1053
- if (state.pageKey && pk !== state.pageKey) return;
1054
- let inserted = 0;
1055
- try { inserted = await runCore(); } catch (e) {}
1056
- try { cb && cb(inserted); } catch (e) {}
855
+ st.runQueued = false;
856
+ if (getPageKey() !== st.pageKey) return;
857
+ let n = 0;
858
+ try { n = await runCore(); } catch (e) {}
859
+ try { cb && cb(n); } catch (e) {}
1057
860
  };
1058
- const doRun = () => requestAnimationFrame(run);
1059
- if (delayMs > 0) setTimeout(doRun, delayMs);
1060
- else doRun();
861
+ const go = () => requestAnimationFrame(run);
862
+ delay > 0 ? setTimeout(go, delay) : go();
1061
863
  }
1062
864
 
1063
- function requestBurst() {
865
+ function burst() {
1064
866
  if (isBlocked()) return;
1065
867
  const t = now();
1066
- if (t - state.lastBurstReqTs < 120) return;
1067
- state.lastBurstReqTs = t;
1068
- const pk = getPageKey();
1069
- state.pageKey = pk;
1070
- state.burstDeadline = t + 1800;
1071
- if (state.burstActive) return;
1072
- state.burstActive = true;
1073
- state.burstCount = 0;
868
+ if (t - st.lastBurstTs < 120) return;
869
+ st.lastBurstTs = t;
870
+ const pk = st.pageKey = getPageKey();
871
+ st.burstDeadline = t + 1800;
872
+ if (st.burstActive) return;
873
+ st.burstActive = true;
874
+ st.burstCount = 0;
1074
875
  const step = () => {
1075
- if (getPageKey() !== pk || isBlocked() || now() > state.burstDeadline || state.burstCount >= 8) {
1076
- state.burstActive = false; return;
1077
- }
1078
- state.burstCount++;
1079
- scheduleRun(0, (inserted) => {
1080
- if (!inserted && !state.pending.length) { state.burstActive = false; return; }
1081
- setTimeout(step, inserted > 0 ? 120 : 220);
876
+ if (getPageKey() !== pk || isBlocked() ||
877
+ now() > st.burstDeadline || st.burstCount >= 8)
878
+ { st.burstActive = false; return; }
879
+ st.burstCount++;
880
+ scheduleRun(0, n => {
881
+ if (!n && !st.pending.length) { st.burstActive = false; return; }
882
+ setTimeout(step, n > 0 ? 120 : 220);
1082
883
  });
1083
884
  };
1084
885
  step();
1085
886
  }
1086
887
 
1087
- // ─── Lifecycle ──────────────────────────────────────────────────────────────
888
+ // ─── Lifecycle ───────────────────────────────────────────────────────────────
889
+
1088
890
  function cleanup() {
1089
891
  blockedUntil = now() + 1200;
1090
- try { document.querySelectorAll('.' + WRAP_CLASS).forEach((el) => releaseWrapNode(el)); } catch (e) {}
1091
- state.cfg = null;
1092
- state.allTopics = []; state.allPosts = []; state.allCategories = [];
1093
- state.curTopics = 0; state.curPosts = 0; state.curCategories = 0;
1094
- state.lastShowById.clear();
1095
- state.inflight = 0; state.pending = []; state.pendingSet.clear();
1096
- state.heroDoneForPage = false;
1097
- // Observers are intentionally kept alive across ajaxify cycles.
892
+ try { document.querySelectorAll('.' + WRAP_CLASS).forEach(dropWrap); } catch (e) {}
893
+ try {
894
+ for (const m of st.detached.values()) {
895
+ for (const w of m.values()) try { dropWrap(w); } catch (e) {}
896
+ m.clear();
897
+ }
898
+ st.detached.clear();
899
+ } catch (e) {}
900
+ st.cfg = null;
901
+ st.pools = { topic: [], post: [], category: [] };
902
+ st.cursors = { topic: 0, post: 0, category: 0 };
903
+ st.lastShow.clear();
904
+ st.inflight = 0;
905
+ st.pending = []; st.pendingSet.clear();
906
+ st.heroDone = false;
907
+ // IO and domObs kept alive intentionally.
1098
908
  }
1099
909
 
1100
- function shouldReactToMutations(mutations) {
910
+ function shouldReact(mutations) {
1101
911
  for (const m of mutations) {
1102
912
  if (!m.addedNodes || !m.addedNodes.length) continue;
1103
913
  for (const n of m.addedNodes) {
1104
914
  if (!n || n.nodeType !== 1) continue;
1105
- const el = n;
1106
- if (
1107
- (el.matches && (
1108
- el.matches(SELECTORS.postItem) ||
1109
- el.matches(SELECTORS.topicItem) ||
1110
- el.matches(SELECTORS.categoryItem)
1111
- )) ||
1112
- (el.querySelector && (
1113
- el.querySelector(SELECTORS.postItem) ||
1114
- el.querySelector(SELECTORS.topicItem) ||
1115
- el.querySelector(SELECTORS.categoryItem)
1116
- ))
1117
- ) return true;
915
+ if ((n.matches && (n.matches(SEL.post) || n.matches(SEL.topic) || n.matches(SEL.category))) ||
916
+ (n.querySelector && (n.querySelector(SEL.post) || n.querySelector(SEL.topic) || n.querySelector(SEL.category))))
917
+ return true;
1118
918
  }
1119
919
  }
1120
920
  return false;
1121
921
  }
1122
922
 
1123
- function ensureDomObserver() {
1124
- if (state.domObs) return;
1125
- state.domObs = new MutationObserver((mutations) => {
1126
- if (state.internalDomChange > 0 || isBlocked()) return;
1127
- if (!shouldReactToMutations(mutations)) return;
1128
- requestBurst();
923
+ function ensureDomObs() {
924
+ if (st.domObs) return;
925
+ st.domObs = new MutationObserver(muts => {
926
+ if (st.internalMut > 0 || isBlocked()) return;
927
+ if (shouldReact(muts)) burst();
1129
928
  });
1130
- try { state.domObs.observe(document.body, { childList: true, subtree: true }); } catch (e) {}
929
+ try { st.domObs.observe(document.body, { childList: true, subtree: true }); } catch (e) {}
1131
930
  }
1132
931
 
1133
- // ─── NodeBB / scroll bindings ────────────────────────────────────────────────
932
+ // ─── NodeBB event bindings ───────────────────────────────────────────────────
933
+
1134
934
  function bindNodeBB() {
1135
935
  if (!$) return;
1136
- $(window).off('.ezoicInfinite');
1137
- $(window).on('action:ajaxify.start.ezoicInfinite', () => { cleanup(); });
1138
- $(window).on('action:ajaxify.end.ezoicInfinite', () => {
1139
- state.pageKey = getPageKey();
936
+ $(window).off('.ezoicNbb');
937
+ $(window).on('action:ajaxify.start.ezoicNbb', () => cleanup());
938
+ $(window).on('action:ajaxify.end.ezoicNbb', () => {
939
+ st.pageKey = getPageKey();
1140
940
  blockedUntil = 0;
1141
- muteNoisyConsole(); ensureTcfApiLocator(); warmUpNetwork(); patchShowAds();
1142
- globalGapFixInit(); ensurePreloadObserver(); ensureDomObserver(); initPileFixObserver();
1143
- insertHeroAdEarly().catch(() => {});
1144
- requestBurst();
941
+ muteConsole(); warmNetwork(); patchShowAds();
942
+ globalGapFix(); ensureIO(); ensureDomObs();
943
+ heroAd().catch(() => {});
944
+ burst();
1145
945
  setTimeout(schedulePileFix, 80);
1146
946
  setTimeout(schedulePileFix, 500);
1147
947
  });
1148
- $(window).on('action:ajaxify.contentLoaded.ezoicInfinite', () => {
1149
- if (!isBlocked()) requestBurst();
1150
- });
948
+ $(window).on('action:ajaxify.contentLoaded.ezoicNbb', () => { if (!isBlocked()) burst(); });
1151
949
  $(window).on([
1152
950
  'action:posts.loaded', 'action:topics.loaded', 'action:categories.loaded',
1153
951
  'action:category.loaded', 'action:topic.loaded', 'action:infiniteScroll.loaded',
1154
- ].map((e) => e + '.ezoicInfinite').join(' '), () => {
1155
- if (!isBlocked()) {
1156
- requestBurst();
1157
- setTimeout(schedulePileFix, 80);
1158
- setTimeout(schedulePileFix, 500);
1159
- }
952
+ ].map(e => e + '.ezoicNbb').join(' '), () => {
953
+ if (!isBlocked()) { burst(); setTimeout(schedulePileFix, 80); setTimeout(schedulePileFix, 500); }
1160
954
  });
1161
955
  try {
1162
- require(['hooks'], (hooks) => {
1163
- if (!hooks || typeof hooks.on !== 'function') return;
1164
- ['action:ajaxify.end','action:ajaxify.contentLoaded','action:posts.loaded',
1165
- 'action:topics.loaded','action:categories.loaded','action:category.loaded',
1166
- 'action:topic.loaded','action:infiniteScroll.loaded'].forEach((ev) => {
1167
- try { hooks.on(ev, () => { if (!isBlocked()) requestBurst(); }); } catch (e) {}
1168
- });
956
+ require(['hooks'], hooks => {
957
+ if (typeof hooks.on !== 'function') return;
958
+ for (const ev of [
959
+ 'action:ajaxify.end', 'action:ajaxify.contentLoaded',
960
+ 'action:posts.loaded', 'action:topics.loaded', 'action:categories.loaded',
961
+ 'action:category.loaded', 'action:topic.loaded', 'action:infiniteScroll.loaded',
962
+ ]) try { hooks.on(ev, () => { if (!isBlocked()) burst(); }); } catch (e) {}
1169
963
  });
1170
964
  } catch (e) {}
1171
965
  }
@@ -1173,33 +967,35 @@
1173
967
  function bindScroll() {
1174
968
  let ticking = false;
1175
969
  window.addEventListener('scroll', () => {
970
+ // Fast-scroll boost: widen IO margins.
1176
971
  try {
1177
- const t = now(), y = window.scrollY || window.pageYOffset || 0;
1178
- if (state.lastScrollTs) {
1179
- const dt = t - state.lastScrollTs;
1180
- const dy = Math.abs(y - (state.lastScrollY || 0));
1181
- if (dt > 0 && dy / dt >= BOOST_SPEED_PX_PER_MS) {
1182
- const wasBoosted = isBoosted();
1183
- state.scrollBoostUntil = Math.max(state.scrollBoostUntil || 0, t + BOOST_DURATION_MS);
1184
- if (!wasBoosted) ensurePreloadObserver();
972
+ const t = now(), y = window.scrollY || 0;
973
+ if (st.lastScrollTs) {
974
+ const speed = Math.abs(y - st.lastScrollY) / (t - st.lastScrollTs);
975
+ if (speed >= BOOST_SPEED_PX_PER_MS) {
976
+ const was = isBoosted();
977
+ st.boostUntil = Math.max(st.boostUntil, t + BOOST_DURATION_MS);
978
+ if (!was) ensureIO();
1185
979
  }
1186
980
  }
1187
- state.lastScrollY = y; state.lastScrollTs = t;
981
+ st.lastScrollY = y; st.lastScrollTs = t;
1188
982
  } catch (e) {}
983
+
1189
984
  if (ticking) return;
1190
985
  ticking = true;
1191
- requestAnimationFrame(() => { ticking = false; requestBurst(); });
986
+ requestAnimationFrame(() => { ticking = false; burst(); });
1192
987
  }, { passive: true });
1193
988
  }
1194
989
 
1195
990
  // ─── Boot ────────────────────────────────────────────────────────────────────
1196
- state.pageKey = getPageKey();
1197
- muteNoisyConsole(); ensureTcfApiLocator(); warmUpNetwork(); patchShowAds();
1198
- ensurePreloadObserver(); ensureDomObserver(); initPileFixObserver();
991
+
992
+ st.pageKey = getPageKey();
993
+ muteConsole(); warmNetwork(); patchShowAds();
994
+ ensureIO(); ensureDomObs();
1199
995
  bindNodeBB(); bindScroll();
1200
996
  blockedUntil = 0;
1201
- insertHeroAdEarly().catch(() => {});
1202
- requestBurst();
997
+ heroAd().catch(() => {});
998
+ burst();
1203
999
  schedulePileFix();
1204
1000
 
1205
1001
  })();