nodebb-plugin-ezoic-infinite 1.8.33 → 1.8.35

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.
package/public/client.js CHANGED
@@ -1,218 +1,182 @@
1
1
  /**
2
- * NodeBB Ezoic Infinite Ads — client.js v36
2
+ * NodeBB Ezoic Infinite Ads — client.js v2.0.0
3
3
  *
4
- * Historique des corrections majeures
5
- * ────────────────────────────────────
6
- * v18 Ancrage stable par data-pid / data-index au lieu d'ordinalMap fragile.
4
+ * Complete rewrite: performance optimization, CMP/TCF fix, clean architecture.
7
5
  *
8
- * v19 Intervalle global basé sur l'ordinal absolu (data-index), pas sur
9
- * la position dans le batch courant.
10
- *
11
- * v20 Table KIND : anchorAttr / ordinalAttr / baseTag par kindClass.
12
- * Fix fatal catégories : data-cid au lieu de data-index inexistant.
13
- * IO fixe (une instance, jamais recréée).
14
- * Fix TCF locator : MutationObserver recrée l'iframe si ajaxify la retire.
15
- *
16
- * v25 Fix scroll-up / virtualisation NodeBB : decluster + grace period.
17
- *
18
- * v26 Suppression définitive du recyclage d'id (causait réinjection en haut).
19
- *
20
- * v27 pruneOrphans supprimé (faux-orphelins sur virtualisation NodeBB posts).
21
- *
22
- * v28 decluster supprimé. Wraps persistants pendant la session.
23
- *
24
- * v32 Retour anchorAttr = data-index pour ezoic-ad-between.
25
- * data-tid peut être absent → clés invalides → wraps empilés.
26
- * pruneOrphansBetween réactivé uniquement pour topics de catégorie :
27
- * – NodeBB NE virtualise PAS les topics dans une liste de catégorie,
28
- * les ancres (data-index) restent en DOM → prune safe et nécessaire
29
- * pour éviter l'empilement après scroll long.
30
- * – Toujours désactivé pour les posts : NodeBB virtualise les posts
31
- * hors-viewport → faux-orphelins → bug réinjection en haut.
32
- *
33
- * v34 moveDistantWrap — voir v38.
34
- *
35
- * v50 Suppression de bindLoginCheck() : NodeBB fait un rechargement complet
36
- * après login — filter:middleware.renderHeader re-évalue l'exclusion au
37
- * rechargement. Redondant depuis le fix normalizeExcludedGroups (v49).
38
- *
39
- * v43 Seuil de recyclage abaissé à -vh + unobserve avant recyclage.
40
- *
41
- * v42 Seuil -(IO_MARGIN + vh) (trop strict, peu de wraps éligibles).
42
- *
43
- * v41 Seuil -1vh (trop permissif sur mobile, ignorait IO_MARGIN).
44
- *
45
- * v40 Recyclage slots via destroyPlaceholders+define+displayMore avec délais.
46
- * Séquence : destroy → 300ms → define → 300ms → displayMore.
47
- * Testé manuellement : fonctionne. displayMore = API Ezoic infinite scroll.
48
- *
49
- * v38 Pool épuisé = fin de quota Ezoic par page-view. ez.refresh() interdit
50
- * sur la même page que ez.enable() — supprimé. moveDistantWrap supprimé :
51
- * déplacer un wrap "already defined" ne re-sert aucune pub. Pool épuisé →
52
- * break propre dans injectBetween. muteConsole : ajout warnings refresh.
53
- *
54
- * v36 Optimisations chemin critique (scroll → injectBetween) :
55
- * – S.wrapByKey Map<anchorKey,wrap> : findWrap() passe de querySelector
56
- * sur tout le doc à un lookup O(1). Mis à jour dans insertAfter,
57
- * dropWrap et cleanup.
58
- * – wrapIsLive allégé : pour les voisins immédiats on vérifie les
59
- * attributs du nœud lui-même sans querySelector global.
60
- * – MutationObserver : matches() vérifié avant querySelector() pour
61
- * court-circuiter les sous-arbres entiers ajoutés par NodeBB.
62
- *
63
- * v35 Revue complète prod-ready :
64
- * – initPools protégé contre ré-initialisation inutile (S.poolsReady).
65
- * – muteConsole élargit à "No valid placeholders for loadMore".
66
- * – Commentaires et historique nettoyés.
6
+ * Key changes from v1.x:
7
+ * - TCF locator: debounced recreation instead of per-mutation, prevents CMP postMessage null errors
8
+ * - MutationObserver: scoped to content containers instead of document.body subtree
9
+ * - Console muting: regex-free, prefix-based matching
10
+ * - showAds batching: microtask-based flush instead of setTimeout
11
+ * - Warm network: runs once per session, not per navigation
12
+ * - State machine: clear lifecycle for placeholders (idle observed → queued → shown → recycled)
67
13
  */
68
14
  (function nbbEzoicInfinite() {
69
15
  'use strict';
70
16
 
71
- // ── Constantes ─────────────────────────────────────────────────────────────
17
+ // ── Constants ──────────────────────────────────────────────────────────────
18
+
19
+ const WRAP_CLASS = 'nodebb-ezoic-wrap';
20
+ const PH_PREFIX = 'ezoic-pub-ad-placeholder-';
72
21
 
73
- const WRAP_CLASS = 'nodebb-ezoic-wrap';
74
- const PH_PREFIX = 'ezoic-pub-ad-placeholder-';
75
- const A_ANCHOR = 'data-ezoic-anchor'; // "kindClass:stableId"
76
- const A_WRAPID = 'data-ezoic-wrapid'; // id Ezoic
77
- const A_CREATED = 'data-ezoic-created'; // timestamp création ms
78
- const A_SHOWN = 'data-ezoic-shown'; // timestamp dernier showAds ms
22
+ // Data attributes
23
+ const ATTR = {
24
+ ANCHOR: 'data-ezoic-anchor',
25
+ WRAPID: 'data-ezoic-wrapid',
26
+ CREATED: 'data-ezoic-created',
27
+ SHOWN: 'data-ezoic-shown',
28
+ };
29
+
30
+ // Timing
31
+ const TIMING = {
32
+ EMPTY_CHECK_MS: 20_000,
33
+ MIN_PRUNE_AGE_MS: 8_000,
34
+ SHOW_THROTTLE_MS: 900,
35
+ BURST_COOLDOWN_MS: 200,
36
+ BLOCK_DURATION_MS: 1_500,
37
+ SHOW_TIMEOUT_MS: 7_000,
38
+ SHOW_RELEASE_MS: 700,
39
+ BATCH_FLUSH_MS: 80,
40
+ RECYCLE_DELAY_MS: 450,
41
+
42
+ };
79
43
 
80
- const EMPTY_CHECK_MS = 20_000; // délai avant collapse d'un wrap vide post-show
81
- const MIN_PRUNE_AGE_MS = 8_000; // délai de grâce avant pruning (stabilisation DOM)
82
- const MAX_INSERTS_RUN = 6; // max insertions par appel runCore
83
- const MAX_INFLIGHT = 4; // max showAds() simultanés
84
- const SHOW_THROTTLE_MS = 900; // anti-spam showAds() par id
85
- const BURST_COOLDOWN_MS = 200; // délai min entre deux déclenchements de burst
86
- const MIN_PLACEHOLDER_HEIGHT = 1; // placeholder minimal (test stabilité scroll)
44
+ // Limits
45
+ const LIMITS = {
46
+ MAX_INSERTS_RUN: 6,
47
+ MAX_INFLIGHT: 4,
48
+ BATCH_SIZE: 3,
49
+ MAX_BURST_STEPS: 8,
50
+ BURST_WINDOW_MS: 2_000,
51
+ };
87
52
 
88
- // Marges IO larges et fixes — observer créé une seule fois au boot
89
- const IO_MARGIN_DESKTOP = '2500px 0px 2500px 0px';
90
- const IO_MARGIN_MOBILE = '3500px 0px 3500px 0px';
53
+ const IO_MARGIN = {
54
+ DESKTOP: '2500px 0px 2500px 0px',
55
+ MOBILE: '3500px 0px 3500px 0px',
56
+ };
91
57
 
58
+ // Selectors
92
59
  const SEL = {
93
60
  post: '[component="post"][data-pid]',
94
61
  topic: 'li[component="category/topic"]',
95
62
  category: 'li[component="categories/category"]',
96
63
  };
97
64
 
98
- /**
99
- * Table KIND — source de vérité par kindClass.
100
- *
101
- * sel sélecteur CSS complet des éléments cibles
102
- * baseTag préfixe tag pour querySelector d'ancre
103
- * (vide pour posts : le sélecteur commence par '[')
104
- * anchorAttr attribut DOM stable → clé unique du wrap
105
- * ordinalAttr attribut 0-based pour le calcul de l'intervalle
106
- * null → fallback positionnel (catégories)
107
- */
65
+ // Kind configuration table — single source of truth per ad type
108
66
  const KIND = {
109
67
  'ezoic-ad-message': { sel: SEL.post, baseTag: '', anchorAttr: 'data-pid', ordinalAttr: 'data-index' },
110
68
  'ezoic-ad-between': { sel: SEL.topic, baseTag: 'li', anchorAttr: 'data-index', ordinalAttr: 'data-index' },
111
69
  'ezoic-ad-categories': { sel: SEL.category, baseTag: 'li', anchorAttr: 'data-cid', ordinalAttr: null },
112
70
  };
113
71
 
114
- // ── État global ────────────────────────────────────────────────────────────
115
-
116
- const S = {
117
- pageKey: null,
118
- cfg: null,
119
- poolsReady: false,
120
- pools: { topics: [], posts: [], categories: [] },
121
- cursors: { topics: 0, posts: 0, categories: 0 },
122
- mountedIds: new Set(),
123
- lastShow: new Map(),
124
- io: null,
125
- domObs: null,
126
- mutGuard: 0, // >0 : mutations DOM en cours (MutationObserver ignoré)
127
- inflight: 0, // showAds() en cours
128
- pending: [], // ids en attente de slot inflight
129
- pendingSet: new Set(),
130
- wrapByKey: new Map(), // anchorKey → wrap DOM node
72
+ // Selector for detecting filled ad slots
73
+ const FILL_SEL = 'iframe, ins, img, video, [data-google-container-id], div[id$="__container__"]';
74
+
75
+ // ── Utility ────────────────────────────────────────────────────────────────
76
+
77
+ const now = () => Date.now();
78
+ const isMobile = () => window.innerWidth < 768;
79
+ const normBool = v => v === true || v === 'true' || v === 1 || v === '1' || v === 'on';
80
+
81
+ function isFilled(node) {
82
+ return node?.querySelector?.(FILL_SEL) != null;
83
+ }
84
+
85
+ function isPlaceholderUsed(ph) {
86
+ if (!ph?.isConnected) return false;
87
+ return ph.querySelector('ins.adsbygoogle, iframe, [data-google-container-id], div[id$="__container__"]') != null;
88
+ }
89
+
90
+ function parseIds(raw) {
91
+ const out = [];
92
+ const seen = new Set();
93
+ for (const line of String(raw || '').split(/[\r\n,\s]+/)) {
94
+ const n = parseInt(line, 10);
95
+ if (n > 0 && Number.isFinite(n) && !seen.has(n)) {
96
+ seen.add(n);
97
+ out.push(n);
98
+ }
99
+ }
100
+ return out;
101
+ }
102
+
103
+ // ── State ──────────────────────────────────────────────────────────────────
104
+
105
+ const state = {
106
+ // Page context
107
+ pageKey: null,
108
+ kind: null,
109
+ cfg: null,
110
+
111
+ // Pools
112
+ poolsReady: false,
113
+ pools: { topics: [], posts: [], categories: [] },
114
+ cursors: { topics: 0, posts: 0, categories: 0 },
115
+
116
+ // Mounted placeholders
117
+ mountedIds: new Set(),
118
+ phState: new Map(), // id → 'new' | 'show-queued' | 'shown' | 'destroyed'
119
+ lastShow: new Map(), // id → timestamp
120
+
121
+ // Wrap registry
122
+ wrapByKey: new Map(), // anchorKey → wrap element
123
+ wrapsByClass: new Map(), // kindClass → Set<wrap>
124
+
125
+ // Observers
126
+ io: null,
127
+ domObs: null,
128
+
129
+ // Guards
130
+ mutGuard: 0,
131
+ blockedUntil: 0,
132
+
133
+ // Show queue
134
+ inflight: 0,
135
+ pending: [],
136
+ pendingSet: new Set(),
137
+
138
+ // Scheduler
131
139
  runQueued: false,
132
140
  burstActive: false,
133
141
  burstDeadline: 0,
134
142
  burstCount: 0,
135
143
  lastBurstTs: 0,
136
144
  firstShown: false,
137
- wrapsByClass: new Map(),
138
- kind: null,
139
- phState: new Map(), // id -> new|show-queued|shown|destroyed
140
145
  };
141
146
 
142
- let blockedUntil = 0;
143
-
144
- const ts = () => Date.now();
145
- const isBlocked = () => ts() < blockedUntil;
146
- const isMobile = () => { try { return window.innerWidth < 768; } catch (_) { return false; } };
147
- const normBool = v => v === true || v === 'true' || v === 1 || v === '1' || v === 'on';
148
- const FILL_SEL = 'iframe, ins, img, video, [data-google-container-id], div[id$="__container__"]';
149
- const isFilled = n => !!(n?.querySelector?.(FILL_SEL));
150
- function placeholderLooksUsed(ph) {
151
- try {
152
- if (!ph?.isConnected) return false;
153
- return !!ph.querySelector('ins.adsbygoogle, iframe, [data-google-container-id], div[id$="__container__"]');
154
- } catch (_) { return false; }
155
- }
156
-
147
+ const isBlocked = () => now() < state.blockedUntil;
157
148
 
158
149
  function mutate(fn) {
159
- S.mutGuard++;
160
- try { fn(); } finally { S.mutGuard--; }
161
- }
162
-
163
-
164
- function clearEmptyIfFilled(wrap) {
165
- try {
166
- if (!wrap?.isConnected) return false;
167
- const ph = wrap.querySelector(`[id^="${PH_PREFIX}"]`);
168
- if (!ph) return false;
169
- if (!isFilled(ph)) return false;
170
- wrap.classList.remove('is-empty');
171
- const id = parseInt(wrap.getAttribute(A_WRAPID) || '0', 10);
172
- if (Number.isFinite(id) && id > 0) S.phState.set(id, 'shown');
173
- return true;
174
- } catch (_) { return false; }
175
- }
176
-
177
- function scheduleUncollapseChecksForWrap(wrap) {
178
- if (!wrap) return;
179
- for (const ms of [500, 1500, 3000, 7000, 15000]) {
180
- setTimeout(() => { try { clearEmptyIfFilled(wrap); } catch (_) {} }, ms);
181
- }
150
+ state.mutGuard++;
151
+ try { fn(); } finally { state.mutGuard--; }
182
152
  }
183
153
 
184
154
  // ── Config ─────────────────────────────────────────────────────────────────
185
155
 
186
156
  async function fetchConfig() {
187
- if (S.cfg) return S.cfg;
157
+ if (state.cfg) return state.cfg;
158
+ // Prefer inline config injected by server (zero latency)
188
159
  try {
189
- if (window.__nbbEzoicCfg && typeof window.__nbbEzoicCfg === 'object') {
190
- S.cfg = window.__nbbEzoicCfg;
191
- return S.cfg;
160
+ const inline = window.__nbbEzoicCfg;
161
+ if (inline && typeof inline === 'object') {
162
+ state.cfg = inline;
163
+ return state.cfg;
192
164
  }
193
165
  } catch (_) {}
166
+ // Fallback to API
194
167
  try {
195
168
  const r = await fetch('/api/plugins/ezoic-infinite/config', { credentials: 'same-origin' });
196
- if (r.ok) S.cfg = await r.json();
169
+ if (r.ok) state.cfg = await r.json();
197
170
  } catch (_) {}
198
- return S.cfg;
199
- }
200
-
201
- function parseIds(raw) {
202
- const out = [], seen = new Set();
203
- for (const v of String(raw || '').split(/\r?\n/).map(s => s.trim()).filter(Boolean)) {
204
- const n = parseInt(v, 10);
205
- if (n > 0 && Number.isFinite(n) && !seen.has(n)) { seen.add(n); out.push(n); }
206
- }
207
- return out;
171
+ return state.cfg;
208
172
  }
209
173
 
210
174
  function initPools(cfg) {
211
- if (S.poolsReady) return;
212
- S.pools.topics = parseIds(cfg.placeholderIds);
213
- S.pools.posts = parseIds(cfg.messagePlaceholderIds);
214
- S.pools.categories = parseIds(cfg.categoryPlaceholderIds);
215
- S.poolsReady = true;
175
+ if (state.poolsReady) return;
176
+ state.pools.topics = parseIds(cfg.placeholderIds);
177
+ state.pools.posts = parseIds(cfg.messagePlaceholderIds);
178
+ state.pools.categories = parseIds(cfg.categoryPlaceholderIds);
179
+ state.poolsReady = true;
216
180
  }
217
181
 
218
182
  // ── Page identity ──────────────────────────────────────────────────────────
@@ -231,6 +195,7 @@
231
195
  if (/^\/topic\//.test(p)) return 'topic';
232
196
  if (/^\/category\//.test(p)) return 'categoryTopics';
233
197
  if (p === '/' || /^\/categories/.test(p)) return 'categories';
198
+ // DOM fallback
234
199
  if (document.querySelector(SEL.category)) return 'categories';
235
200
  if (document.querySelector(SEL.post)) return 'topic';
236
201
  if (document.querySelector(SEL.topic)) return 'categoryTopics';
@@ -238,62 +203,95 @@
238
203
  }
239
204
 
240
205
  function getKind() {
241
- if (S.kind) return S.kind;
242
- S.kind = detectKind();
243
- return S.kind;
206
+ return state.kind || (state.kind = detectKind());
244
207
  }
245
208
 
246
- // ── Items DOM ──────────────────────────────────────────────────────────────
209
+ // ── DOM queries ────────────────────────────────────────────────────────────
247
210
 
248
211
  function getPosts() {
249
- return Array.from(document.querySelectorAll(SEL.post)).filter(el => {
250
- if (!el.isConnected) return false;
251
- if (!el.querySelector('[component="post/content"]')) return false;
252
- const p = el.parentElement?.closest(SEL.post);
253
- if (p && p !== el) return false;
254
- return el.getAttribute('component') !== 'post/parent';
255
- });
212
+ const all = document.querySelectorAll(SEL.post);
213
+ const out = [];
214
+ for (let i = 0; i < all.length; i++) {
215
+ const el = all[i];
216
+ if (!el.isConnected) continue;
217
+ if (!el.querySelector('[component="post/content"]')) continue;
218
+ // Skip nested quotes / parent posts
219
+ const parent = el.parentElement?.closest(SEL.post);
220
+ if (parent && parent !== el) continue;
221
+ if (el.getAttribute('component') === 'post/parent') continue;
222
+ out.push(el);
223
+ }
224
+ return out;
225
+ }
226
+
227
+ function getTopics() { return Array.from(document.querySelectorAll(SEL.topic)); }
228
+ function getCategories() { return Array.from(document.querySelectorAll(SEL.category)); }
229
+
230
+ // ── Anchor keys & wrap registry ────────────────────────────────────────────
231
+
232
+ function stableId(klass, el) {
233
+ const attr = KIND[klass]?.anchorAttr;
234
+ if (attr) {
235
+ const v = el.getAttribute(attr);
236
+ if (v != null && v !== '') return v;
237
+ }
238
+ // Positional fallback
239
+ const children = el.parentElement?.children;
240
+ if (!children) return 'i0';
241
+ for (let i = 0; i < children.length; i++) {
242
+ if (children[i] === el) return `i${i}`;
243
+ }
244
+ return 'i0';
256
245
  }
257
246
 
258
- const getTopics = () => Array.from(document.querySelectorAll(SEL.topic));
259
- const getCategories = () => Array.from(document.querySelectorAll(SEL.category));
247
+ const anchorKey = (klass, el) => `${klass}:${stableId(klass, el)}`;
248
+
249
+ function findWrap(key) {
250
+ const w = state.wrapByKey.get(key);
251
+ return w?.isConnected ? w : null;
252
+ }
260
253
 
261
- // ── Wraps — détection ──────────────────────────────────────────────────────
254
+ function getWrapSet(klass) {
255
+ let set = state.wrapsByClass.get(klass);
256
+ if (!set) { set = new Set(); state.wrapsByClass.set(klass, set); }
257
+ return set;
258
+ }
259
+
260
+ // ── Wrap lifecycle detection ───────────────────────────────────────────────
262
261
 
263
262
  /**
264
- * Vérifie qu'un wrap a encore son ancre dans le DOM.
265
- * Utilisé par adjacentWrap pour ignorer les wraps orphelins.
263
+ * Check if a wrap element still has its corresponding anchor in the DOM.
264
+ * Uses O(1) registry lookup first, then sibling scan, then global querySelector.
266
265
  */
267
266
  function wrapIsLive(wrap) {
268
267
  if (!wrap?.classList?.contains(WRAP_CLASS)) return false;
269
- const key = wrap.getAttribute(A_ANCHOR);
268
+ const key = wrap.getAttribute(ATTR.ANCHOR);
270
269
  if (!key) return false;
271
- // Lookup O(1) dans le registre — vrai si le wrap EST encore dans le registre
272
- // et connecté au DOM (le registre est tenu à jour par insertAfter/dropWrap).
273
- if (S.wrapByKey.get(key) === wrap) return wrap.isConnected;
274
- // Fallback : registre pas encore à jour ou wrap non enregistré.
270
+
271
+ // Fast path: registry match
272
+ if (state.wrapByKey.get(key) === wrap) return wrap.isConnected;
273
+
274
+ // Parse key
275
275
  const colonIdx = key.indexOf(':');
276
276
  const klass = key.slice(0, colonIdx);
277
277
  const anchorId = key.slice(colonIdx + 1);
278
278
  const cfg = KIND[klass];
279
279
  if (!cfg) return false;
280
- // Optimisation : si l'ancre est un frère direct du wrap, pas besoin
281
- // de querySelector global on cherche parmi les voisins immédiats.
280
+
281
+ // Sibling scan (cheap for adjacent anchors)
282
282
  const parent = wrap.parentElement;
283
283
  if (parent) {
284
+ const sel = `${cfg.baseTag || ''}[${cfg.anchorAttr}="${anchorId}"]`;
284
285
  for (const sib of parent.children) {
285
- if (sib === wrap) continue;
286
- try {
287
- if (sib.matches(`${cfg.baseTag || ''}[${cfg.anchorAttr}="${anchorId}"]`)) {
288
- return sib.isConnected;
289
- }
290
- } catch (_) {}
286
+ if (sib !== wrap) {
287
+ try { if (sib.matches(sel)) return sib.isConnected; } catch (_) {}
288
+ }
291
289
  }
292
290
  }
293
- // Dernier recours : querySelector global
291
+
292
+ // Global fallback (expensive, rare)
294
293
  try {
295
- const found = document.querySelector(`${cfg.sel}[${cfg.anchorAttr}="${anchorId}"]`);
296
- return !!(found?.isConnected);
294
+ return document.querySelector(`${cfg.sel}[${cfg.anchorAttr}="${anchorId}"]`)?.isConnected === true;
297
295
  } catch (_) { return false; }
298
296
  }
299
297
 
@@ -301,230 +299,234 @@
301
299
  return wrapIsLive(el.nextElementSibling) || wrapIsLive(el.previousElementSibling);
302
300
  }
303
301
 
304
- // ── Ancres stables ─────────────────────────────────────────────────────────
302
+ // ── Fill detection ─────────────────────────────────────────────────────────
305
303
 
306
- /**
307
- * Retourne la valeur de l'attribut stable pour cet élément,
308
- * ou un fallback positionnel si l'attribut est absent.
309
- */
310
- function stableId(klass, el) {
311
- const attr = KIND[klass]?.anchorAttr;
312
- if (attr) {
313
- const v = el.getAttribute(attr);
314
- if (v !== null && v !== '') return v;
315
- }
316
- let i = 0;
317
- for (const s of el.parentElement?.children ?? []) {
318
- if (s === el) return `i${i}`;
319
- i++;
320
- }
321
- return 'i0';
322
- }
323
-
324
- const anchorKey = (klass, el) => `${klass}:${stableId(klass, el)}`;
325
-
326
- function findWrap(key) {
327
- const w = S.wrapByKey.get(key);
328
- return (w?.isConnected) ? w : null;
304
+ function clearEmptyIfFilled(wrap) {
305
+ if (!wrap?.isConnected) return false;
306
+ const ph = wrap.querySelector(`[id^="${PH_PREFIX}"]`);
307
+ if (!ph || !isFilled(ph)) return false;
308
+ wrap.classList.remove('is-empty');
309
+ const id = parseInt(wrap.getAttribute(ATTR.WRAPID) || '0', 10);
310
+ if (id > 0) state.phState.set(id, 'shown');
311
+ return true;
329
312
  }
330
313
 
331
- function wrapsSet(klass) {
332
- let set = S.wrapsByClass.get(klass);
333
- if (!set) { set = new Set(); S.wrapsByClass.set(klass, set); }
334
- return set;
314
+ function scheduleUncollapseChecks(wrap) {
315
+ if (!wrap) return;
316
+ const delays = [500, 1500, 3000, 7000, 15000];
317
+ for (const ms of delays) {
318
+ setTimeout(() => {
319
+ try { clearEmptyIfFilled(wrap); } catch (_) {}
320
+ }, ms);
321
+ }
335
322
  }
336
- const registerWrap = (klass, w) => wrapsSet(klass).add(w);
337
- const unregisterWrap = (klass, w) => S.wrapsByClass.get(klass)?.delete(w);
338
323
 
339
- // ── Pool ───────────────────────────────────────────────────────────────────
324
+ // ── Pool management ────────────────────────────────────────────────────────
340
325
 
341
- /**
342
- * Retourne le prochain id disponible dans le pool (round-robin),
343
- * ou null si tous les ids sont montés.
344
- */
345
326
  function pickId(poolKey) {
346
- const pool = S.pools[poolKey];
327
+ const pool = state.pools[poolKey];
347
328
  if (!pool.length) return null;
348
329
  for (let t = 0; t < pool.length; t++) {
349
- const i = S.cursors[poolKey] % pool.length;
350
- S.cursors[poolKey] = (S.cursors[poolKey] + 1) % pool.length;
351
- const id = pool[i];
352
- if (!S.mountedIds.has(id)) return id;
330
+ const idx = state.cursors[poolKey] % pool.length;
331
+ state.cursors[poolKey] = (state.cursors[poolKey] + 1) % pool.length;
332
+ const id = pool[idx];
333
+ if (!state.mountedIds.has(id)) return id;
353
334
  }
354
335
  return null;
355
336
  }
356
337
 
338
+ // ── Recycling ──────────────────────────────────────────────────────────────
339
+
357
340
  /**
358
- * Pool épuisé : recycle un wrap loin au-dessus du viewport.
359
- * Séquence avec délais (destroyPlaceholders est asynchrone) :
360
- * destroy([id]) → 300ms → define([id]) → 300ms → displayMore([id])
361
- * displayMore = API Ezoic prévue pour l'infinite scroll.
362
- * Priorité : wraps vides d'abord, remplis si nécessaire.
341
+ * When pool is exhausted, recycle a wrap far above the viewport.
342
+ * Sequence: destroy delay re-observe → enqueueShow
363
343
  */
364
- function recycleAndMove(klass, targetEl, newKey) {
344
+ function recycleWrap(klass, targetEl, newKey) {
365
345
  const ez = window.ezstandalone;
366
346
  if (typeof ez?.destroyPlaceholders !== 'function' ||
367
347
  typeof ez?.define !== 'function' ||
368
348
  typeof ez?.displayMore !== 'function') return null;
369
349
 
370
- const vh = window.innerHeight || 800;
371
- // Seuil : -1vh (hors viewport visible). On appelle unobserve(ph) juste
372
- // après pour neutraliser l'IO — plus de showAds parasite possible.
350
+ const vh = window.innerHeight || 800;
373
351
  const threshold = -vh;
374
- let bestEmpty = null, bestEmptyBottom = Infinity;
375
- let bestFilled = null, bestFilledBottom = Infinity;
352
+ let bestEmpty = null, bestEmptyY = Infinity;
353
+ let bestFull = null, bestFullY = Infinity;
354
+
355
+ const wraps = state.wrapsByClass.get(klass);
356
+ if (!wraps) return null;
376
357
 
377
- for (const wrap of (S.wrapsByClass.get(klass) || [])) {
358
+ for (const wrap of wraps) {
378
359
  try {
379
- const rect = wrap.getBoundingClientRect();
380
- if (rect.bottom > threshold) return;
360
+ const bottom = wrap.getBoundingClientRect().bottom;
361
+ if (bottom > threshold) continue;
381
362
  if (!isFilled(wrap)) {
382
- if (rect.bottom < bestEmptyBottom) { bestEmptyBottom = rect.bottom; bestEmpty = wrap; }
363
+ if (bottom < bestEmptyY) { bestEmptyY = bottom; bestEmpty = wrap; }
383
364
  } else {
384
- if (rect.bottom < bestFilledBottom) { bestFilledBottom = rect.bottom; bestFilled = wrap; }
365
+ if (bottom < bestFullY) { bestFullY = bottom; bestFull = wrap; }
385
366
  }
386
367
  } catch (_) {}
387
368
  }
388
369
 
389
- const best = bestEmpty ?? bestFilled;
370
+ const best = bestEmpty ?? bestFull;
390
371
  if (!best) return null;
391
- const id = parseInt(best.getAttribute(A_WRAPID), 10);
372
+
373
+ const id = parseInt(best.getAttribute(ATTR.WRAPID), 10);
392
374
  if (!Number.isFinite(id)) return null;
393
375
 
394
- const oldKey = best.getAttribute(A_ANCHOR);
395
- // Neutraliser l'IO sur ce wrap avant déplacement — évite un showAds
396
- // parasite si le nœud était encore dans la zone IO_MARGIN.
397
- try { const ph = best.querySelector(`#${PH_PREFIX}${id}`); if (ph) S.io?.unobserve(ph); } catch (_) {}
376
+ const oldKey = best.getAttribute(ATTR.ANCHOR);
377
+
378
+ // Unobserve before moving to prevent stale showAds
379
+ try {
380
+ const ph = best.querySelector(`#${PH_PREFIX}${id}`);
381
+ if (ph) state.io?.unobserve(ph);
382
+ } catch (_) {}
383
+
384
+ // Move the wrap to new position
398
385
  mutate(() => {
399
- best.setAttribute(A_ANCHOR, newKey);
400
- best.setAttribute(A_CREATED, String(ts()));
401
- best.setAttribute(A_SHOWN, '0');
386
+ best.setAttribute(ATTR.ANCHOR, newKey);
387
+ best.setAttribute(ATTR.CREATED, String(now()));
388
+ best.setAttribute(ATTR.SHOWN, '0');
402
389
  best.classList.remove('is-empty');
403
390
  best.replaceChildren();
391
+
404
392
  const fresh = document.createElement('div');
405
393
  fresh.id = `${PH_PREFIX}${id}`;
406
394
  fresh.setAttribute('data-ezoic-id', String(id));
407
- fresh.style.minHeight = `${MIN_PLACEHOLDER_HEIGHT}px`;
395
+ fresh.style.minHeight = '1px';
408
396
  best.appendChild(fresh);
409
397
  targetEl.insertAdjacentElement('afterend', best);
410
398
  });
411
- if (oldKey && S.wrapByKey.get(oldKey) === best) S.wrapByKey.delete(oldKey);
412
- S.wrapByKey.set(newKey, best);
413
399
 
414
- // Recyclage Ezoic : détruire l'ancien placeholder avant de réutiliser le même ID.
415
- // Puis ré-observer + re-show (batché via patchShowAds) sur le placeholder recréé.
400
+ // Update registry
401
+ if (oldKey && state.wrapByKey.get(oldKey) === best) state.wrapByKey.delete(oldKey);
402
+ state.wrapByKey.set(newKey, best);
403
+
404
+ // Ezoic recycle sequence
416
405
  const doDestroy = () => {
417
- S.phState.set(id, 'destroyed');
406
+ state.phState.set(id, 'destroyed');
418
407
  try { ez.destroyPlaceholders(id); } catch (_) {
419
408
  try { ez.destroyPlaceholders([id]); } catch (_) {}
420
409
  }
421
410
  setTimeout(() => {
422
- try { observePh(id); } catch (_) {}
423
- S.phState.set(id, 'new');
411
+ try { observePlaceholder(id); } catch (_) {}
412
+ state.phState.set(id, 'new');
424
413
  try { enqueueShow(id); } catch (_) {}
425
- }, 450);
414
+ }, TIMING.RECYCLE_DELAY_MS);
426
415
  };
427
- try { (typeof ez.cmd?.push === 'function') ? ez.cmd.push(doDestroy) : doDestroy(); } catch (_) {}
416
+
417
+ try {
418
+ (typeof ez.cmd?.push === 'function') ? ez.cmd.push(doDestroy) : doDestroy();
419
+ } catch (_) {}
428
420
 
429
421
  return { id, wrap: best };
430
422
  }
431
423
 
432
- // ── Wraps DOM création / suppression ────────────────────────────────────
424
+ // ── Wrap DOM operations ────────────────────────────────────────────────────
433
425
 
434
426
  function makeWrap(id, klass, key) {
435
427
  const w = document.createElement('div');
436
428
  w.className = `${WRAP_CLASS} ${klass}`;
437
- w.setAttribute(A_ANCHOR, key);
438
- w.setAttribute(A_WRAPID, String(id));
439
- w.setAttribute(A_CREATED, String(ts()));
440
- w.setAttribute(A_SHOWN, '0');
441
- w.style.cssText = 'width:100%;display:block;';
429
+ w.setAttribute(ATTR.ANCHOR, key);
430
+ w.setAttribute(ATTR.WRAPID, String(id));
431
+ w.setAttribute(ATTR.CREATED, String(now()));
432
+ w.setAttribute(ATTR.SHOWN, '0');
433
+ w.style.cssText = 'width:100%;display:block';
434
+
442
435
  const ph = document.createElement('div');
443
436
  ph.id = `${PH_PREFIX}${id}`;
444
437
  ph.setAttribute('data-ezoic-id', String(id));
445
- ph.style.minHeight = `${MIN_PLACEHOLDER_HEIGHT}px`;
438
+ ph.style.minHeight = '1px';
446
439
  w.appendChild(ph);
447
440
  return w;
448
441
  }
449
442
 
450
443
  function insertAfter(el, id, klass, key) {
451
- if (!el?.insertAdjacentElement) return null;
452
- if (findWrap(key)) return null;
453
- if (S.mountedIds.has(id)) return null;
454
- if (document.getElementById(`${PH_PREFIX}${id}`)?.isConnected) return null;
444
+ if (!el?.insertAdjacentElement) return null;
445
+ if (findWrap(key)) return null;
446
+ if (state.mountedIds.has(id)) return null;
447
+ // Ensure no duplicate DOM element with same placeholder ID
448
+ const existing = document.getElementById(`${PH_PREFIX}${id}`);
449
+ if (existing?.isConnected) return null;
450
+
455
451
  const w = makeWrap(id, klass, key);
456
452
  mutate(() => el.insertAdjacentElement('afterend', w));
457
- S.mountedIds.add(id);
458
- S.phState.set(id, 'new');
459
- S.wrapByKey.set(key, w);
460
- registerWrap(klass, w);
453
+ state.mountedIds.add(id);
454
+ state.phState.set(id, 'new');
455
+ state.wrapByKey.set(key, w);
456
+ getWrapSet(klass).add(w);
461
457
  return w;
462
458
  }
463
459
 
464
460
  function dropWrap(w) {
465
461
  try {
466
462
  const ph = w.querySelector(`[id^="${PH_PREFIX}"]`);
467
- if (ph instanceof Element) S.io?.unobserve(ph);
468
- const id = parseInt(w.getAttribute(A_WRAPID), 10);
469
- if (Number.isFinite(id)) { S.mountedIds.delete(id); S.phState.delete(id); }
470
- const key = w.getAttribute(A_ANCHOR);
471
- if (key && S.wrapByKey.get(key) === w) S.wrapByKey.delete(key);
472
- const klass = Array.from(w.classList || []).find(c => c !== WRAP_CLASS && c.startsWith('ezoic-ad-'));
473
- if (klass) unregisterWrap(klass, w);
463
+ if (ph instanceof Element) state.io?.unobserve(ph);
464
+
465
+ const id = parseInt(w.getAttribute(ATTR.WRAPID), 10);
466
+ if (Number.isFinite(id)) {
467
+ state.mountedIds.delete(id);
468
+ state.phState.delete(id);
469
+ }
470
+
471
+ const key = w.getAttribute(ATTR.ANCHOR);
472
+ if (key && state.wrapByKey.get(key) === w) state.wrapByKey.delete(key);
473
+
474
+ // Find the kind class to unregister
475
+ for (const cls of w.classList) {
476
+ if (cls !== WRAP_CLASS && cls.startsWith('ezoic-ad-')) {
477
+ state.wrapsByClass.get(cls)?.delete(w);
478
+ break;
479
+ }
480
+ }
474
481
  w.remove();
475
482
  } catch (_) {}
476
483
  }
477
484
 
478
- // ── Prune (topics de catégorie uniquement) ────────────────────────────────
479
- //
480
- // Réactivé uniquement pour 'ezoic-ad-between' (liste de topics).
485
+ // ── Prune (category topic lists only) ──────────────────────────────────────
481
486
  //
482
- // Safe ici car NodeBB ne virtualise PAS les topics dans une catégorie :
483
- // les li[component="category/topic"] restent dans le DOM pendant toute
484
- // la session. Un wrap orphelin (ancre absente) signifie vraiment que le
485
- // topic a disparu. Sans ce nettoyage, les wraps s'accumulent en tête de
486
- // liste après un long scroll et bloquent les nouvelles injections.
487
- //
488
- // Toujours désactivé pour 'ezoic-ad-message' (posts de topic) :
489
- // NodeBB virtualise les posts hors-viewport — il les retire puis les
490
- // réinsère. pruneOrphans verrait des ancres temporairement absentes,
491
- // supprimerait les wraps, et provoquerait une réinjection en haut.
487
+ // Safe for category topic lists: NodeBB does NOT virtualize topics in categories.
488
+ // NOT safe for posts: NodeBB virtualizes posts off-viewport.
492
489
 
493
490
  function pruneOrphansBetween() {
494
491
  const klass = 'ezoic-ad-between';
495
492
  const cfg = KIND[klass];
496
- const liveAnchors = new Set(Array.from(document.querySelectorAll(`${cfg.baseTag}[${cfg.anchorAttr}]`))
497
- .map(el => el.getAttribute(cfg.anchorAttr)).filter(Boolean));
498
-
499
- for (const w of (S.wrapsByClass.get(klass) || [])) {
500
- const created = parseInt(w.getAttribute(A_CREATED) || '0', 10);
501
- if (ts() - created < MIN_PRUNE_AGE_MS) return; // grâce post-création
493
+ const wraps = state.wrapsByClass.get(klass);
494
+ if (!wraps?.size) return;
495
+
496
+ // Build set of live anchor IDs
497
+ const liveAnchors = new Set();
498
+ for (const el of document.querySelectorAll(`${cfg.baseTag}[${cfg.anchorAttr}]`)) {
499
+ const v = el.getAttribute(cfg.anchorAttr);
500
+ if (v) liveAnchors.add(v);
501
+ }
502
502
 
503
- const key = w.getAttribute(A_ANCHOR) ?? '';
504
- const sid = key.slice(klass.length + 1); // après "ezoic-ad-between:"
505
- if (!sid) { mutate(() => dropWrap(w)); return; }
503
+ const t = now();
504
+ for (const w of wraps) {
505
+ const created = parseInt(w.getAttribute(ATTR.CREATED) || '0', 10);
506
+ if (t - created < TIMING.MIN_PRUNE_AGE_MS) continue;
506
507
 
507
- if (!liveAnchors.has(sid)) mutate(() => dropWrap(w));
508
+ const key = w.getAttribute(ATTR.ANCHOR) ?? '';
509
+ const sid = key.slice(klass.length + 1);
510
+ if (!sid || !liveAnchors.has(sid)) {
511
+ mutate(() => dropWrap(w));
512
+ }
508
513
  }
509
514
  }
510
515
 
511
516
  // ── Injection ──────────────────────────────────────────────────────────────
512
517
 
513
- /**
514
- * Ordinal 0-based pour le calcul de l'intervalle d'injection.
515
- * Utilise ordinalAttr si défini, sinon compte les frères dans le parent.
516
- */
517
518
  function ordinal(klass, el) {
518
519
  const attr = KIND[klass]?.ordinalAttr;
519
520
  if (attr) {
520
521
  const v = el.getAttribute(attr);
521
- if (v !== null && v !== '' && !isNaN(v)) return parseInt(v, 10);
522
+ if (v != null && v !== '' && !isNaN(v)) return parseInt(v, 10);
522
523
  }
524
+ // Positional fallback
523
525
  const fullSel = KIND[klass]?.sel ?? '';
524
526
  let i = 0;
525
527
  for (const s of el.parentElement?.children ?? []) {
526
528
  if (s === el) return i;
527
- if (!fullSel || s.matches?.(fullSel)) i++;
529
+ try { if (!fullSel || s.matches(fullSel)) i++; } catch (_) {}
528
530
  }
529
531
  return 0;
530
532
  }
@@ -534,7 +536,7 @@
534
536
  let inserted = 0;
535
537
 
536
538
  for (const el of items) {
537
- if (inserted >= MAX_INSERTS_RUN) break;
539
+ if (inserted >= LIMITS.MAX_INSERTS_RUN) break;
538
540
  if (!el?.isConnected) continue;
539
541
 
540
542
  const ord = ordinal(klass, el);
@@ -548,101 +550,132 @@
548
550
  if (id) {
549
551
  const w = insertAfter(el, id, klass, key);
550
552
  if (w) {
551
- observePh(id);
552
- if (!S.firstShown) { S.firstShown = true; enqueueShow(id); }
553
+ observePlaceholder(id);
554
+ if (!state.firstShown) { state.firstShown = true; enqueueShow(id); }
553
555
  inserted++;
554
556
  }
555
557
  } else {
556
- const recycled = recycleAndMove(klass, el, key);
557
- if (!recycled) break;
558
+ const recycled = recycleWrap(klass, el, key);
559
+ if (!recycled) break; // Pool truly exhausted
558
560
  inserted++;
559
561
  }
560
562
  }
561
563
  return inserted;
562
564
  }
563
565
 
564
- // ── IntersectionObserver & Show ────────────────────────────────────────────
566
+ // ── IntersectionObserver ───────────────────────────────────────────────────
565
567
 
566
568
  function getIO() {
567
- if (S.io) return S.io;
569
+ if (state.io) return state.io;
568
570
  try {
569
- S.io = new IntersectionObserver(entries => {
570
- for (const e of entries) {
571
- if (!e.isIntersecting) continue;
572
- if (e.target instanceof Element) S.io?.unobserve(e.target);
573
- const id = parseInt(e.target.getAttribute('data-ezoic-id'), 10);
574
- if (Number.isFinite(id) && id > 0) enqueueShow(id);
571
+ state.io = new IntersectionObserver(entries => {
572
+ for (const entry of entries) {
573
+ if (!entry.isIntersecting) continue;
574
+ if (entry.target instanceof Element) state.io?.unobserve(entry.target);
575
+ const id = parseInt(entry.target.getAttribute('data-ezoic-id'), 10);
576
+ if (id > 0) enqueueShow(id);
575
577
  }
576
- }, { root: null, rootMargin: isMobile() ? IO_MARGIN_MOBILE : IO_MARGIN_DESKTOP, threshold: 0 });
577
- } catch (_) { S.io = null; }
578
- return S.io;
578
+ }, {
579
+ root: null,
580
+ rootMargin: isMobile() ? IO_MARGIN.MOBILE : IO_MARGIN.DESKTOP,
581
+ threshold: 0,
582
+ });
583
+ } catch (_) { state.io = null; }
584
+ return state.io;
579
585
  }
580
586
 
581
- function observePh(id) {
587
+ function observePlaceholder(id) {
582
588
  const ph = document.getElementById(`${PH_PREFIX}${id}`);
583
- if (ph?.isConnected) try { getIO()?.observe(ph); } catch (_) {}
589
+ if (ph?.isConnected) {
590
+ try { getIO()?.observe(ph); } catch (_) {}
591
+ }
584
592
  }
585
593
 
594
+ // ── Show queue ─────────────────────────────────────────────────────────────
595
+
586
596
  function enqueueShow(id) {
587
597
  if (!id || isBlocked()) return;
588
- const st = S.phState.get(id);
598
+ const st = state.phState.get(id);
589
599
  if (st === 'show-queued' || st === 'shown') return;
590
- if (ts() - (S.lastShow.get(id) ?? 0) < SHOW_THROTTLE_MS) return;
591
- if (S.inflight >= MAX_INFLIGHT) {
592
- if (!S.pendingSet.has(id)) { S.pending.push(id); S.pendingSet.add(id); S.phState.set(id, 'show-queued'); }
600
+ if (now() - (state.lastShow.get(id) ?? 0) < TIMING.SHOW_THROTTLE_MS) return;
601
+
602
+ if (state.inflight >= LIMITS.MAX_INFLIGHT) {
603
+ if (!state.pendingSet.has(id)) {
604
+ state.pending.push(id);
605
+ state.pendingSet.add(id);
606
+ state.phState.set(id, 'show-queued');
607
+ }
593
608
  return;
594
609
  }
595
- S.phState.set(id, 'show-queued');
610
+ state.phState.set(id, 'show-queued');
596
611
  startShow(id);
597
612
  }
598
613
 
599
614
  function drainQueue() {
600
615
  if (isBlocked()) return;
601
- while (S.inflight < MAX_INFLIGHT && S.pending.length) {
602
- const id = S.pending.shift();
603
- S.pendingSet.delete(id);
616
+ while (state.inflight < LIMITS.MAX_INFLIGHT && state.pending.length) {
617
+ const id = state.pending.shift();
618
+ state.pendingSet.delete(id);
604
619
  startShow(id);
605
620
  }
606
621
  }
607
622
 
608
623
  function startShow(id) {
609
624
  if (!id || isBlocked()) return;
610
- S.inflight++;
611
- let done = false;
625
+ state.inflight++;
626
+
627
+ let released = false;
612
628
  const release = () => {
613
- if (done) return;
614
- done = true;
615
- S.inflight = Math.max(0, S.inflight - 1);
629
+ if (released) return;
630
+ released = true;
631
+ state.inflight = Math.max(0, state.inflight - 1);
616
632
  drainQueue();
617
633
  };
618
- const timer = setTimeout(release, 7000);
634
+ const timer = setTimeout(release, TIMING.SHOW_TIMEOUT_MS);
619
635
 
620
636
  requestAnimationFrame(() => {
621
637
  try {
622
638
  if (isBlocked()) { clearTimeout(timer); return release(); }
639
+
623
640
  const ph = document.getElementById(`${PH_PREFIX}${id}`);
624
- if (!ph?.isConnected) { S.phState.delete(id); clearTimeout(timer); return release(); }
625
- if (isFilled(ph) || placeholderLooksUsed(ph)) { S.phState.set(id, 'shown'); clearTimeout(timer); return release(); }
641
+ if (!ph?.isConnected) {
642
+ state.phState.delete(id);
643
+ clearTimeout(timer);
644
+ return release();
645
+ }
626
646
 
627
- const t = ts();
628
- if (t - (S.lastShow.get(id) ?? 0) < SHOW_THROTTLE_MS) { clearTimeout(timer); return release(); }
629
- S.lastShow.set(id, t);
647
+ if (isFilled(ph) || isPlaceholderUsed(ph)) {
648
+ state.phState.set(id, 'shown');
649
+ clearTimeout(timer);
650
+ return release();
651
+ }
652
+
653
+ const t = now();
654
+ if (t - (state.lastShow.get(id) ?? 0) < TIMING.SHOW_THROTTLE_MS) {
655
+ clearTimeout(timer);
656
+ return release();
657
+ }
658
+ state.lastShow.set(id, t);
630
659
 
631
- try { ph.closest?.(`.${WRAP_CLASS}`)?.setAttribute(A_SHOWN, String(t)); } catch (_) {}
632
- S.phState.set(id, 'shown');
660
+ try { ph.closest(`.${WRAP_CLASS}`)?.setAttribute(ATTR.SHOWN, String(t)); } catch (_) {}
661
+ state.phState.set(id, 'shown');
633
662
 
634
663
  window.ezstandalone = window.ezstandalone || {};
635
664
  const ez = window.ezstandalone;
665
+
636
666
  const doShow = () => {
637
- let wrap = null;
638
- try { wrap = ph.closest?.(`.${WRAP_CLASS}`) || null; } catch (_) {}
667
+ const wrap = ph.closest(`.${WRAP_CLASS}`);
639
668
  try { ez.showAds(id); } catch (_) {}
640
- if (wrap) scheduleUncollapseChecksForWrap(wrap);
669
+ if (wrap) scheduleUncollapseChecks(wrap);
641
670
  scheduleEmptyCheck(id, t);
642
- setTimeout(() => { clearTimeout(timer); release(); }, 700);
671
+ setTimeout(() => { clearTimeout(timer); release(); }, TIMING.SHOW_RELEASE_MS);
643
672
  };
673
+
644
674
  Array.isArray(ez.cmd) ? ez.cmd.push(doShow) : doShow();
645
- } catch (_) { clearTimeout(timer); release(); }
675
+ } catch (_) {
676
+ clearTimeout(timer);
677
+ release();
678
+ }
646
679
  });
647
680
  }
648
681
 
@@ -650,20 +683,22 @@
650
683
  setTimeout(() => {
651
684
  try {
652
685
  const ph = document.getElementById(`${PH_PREFIX}${id}`);
653
- const wrap = ph?.closest?.(`.${WRAP_CLASS}`);
686
+ const wrap = ph?.closest(`.${WRAP_CLASS}`);
654
687
  if (!wrap || !ph?.isConnected) return;
655
- if (parseInt(wrap.getAttribute(A_SHOWN) || '0', 10) > showTs) return;
688
+ // Skip if a newer show happened since
689
+ if (parseInt(wrap.getAttribute(ATTR.SHOWN) || '0', 10) > showTs) return;
656
690
  if (clearEmptyIfFilled(wrap)) return;
657
691
  wrap.classList.add('is-empty');
658
692
  } catch (_) {}
659
- }, EMPTY_CHECK_MS);
693
+ }, TIMING.EMPTY_CHECK_MS);
660
694
  }
661
695
 
662
696
  // ── Patch Ezoic showAds ────────────────────────────────────────────────────
663
697
  //
664
- // Intercepte ez.showAds() pour :
665
- // ignorer les appels pendant blockedUntil
666
- // filtrer les ids dont le placeholder n'est pas en DOM
698
+ // Intercepts ez.showAds() to:
699
+ // - block calls during navigation transitions
700
+ // - filter out disconnected placeholders
701
+ // - batch calls for efficiency
667
702
 
668
703
  function patchShowAds() {
669
704
  const apply = () => {
@@ -672,29 +707,33 @@
672
707
  const ez = window.ezstandalone;
673
708
  if (window.__nbbEzPatched || typeof ez.showAds !== 'function') return;
674
709
  window.__nbbEzPatched = true;
710
+
675
711
  const orig = ez.showAds.bind(ez);
676
- const q = new Set();
712
+ const queue = new Set();
677
713
  let flushTimer = null;
678
- const BATCH_SIZE = 3;
679
- const FLUSH_MS = 80;
714
+
680
715
  const flush = () => {
681
716
  flushTimer = null;
682
- if (isBlocked() || !q.size) return;
683
- const ids = Array.from(q).sort((a, b) => a - b);
684
- q.clear();
717
+ if (isBlocked() || !queue.size) return;
718
+
719
+ const ids = Array.from(queue).sort((a, b) => a - b);
720
+ queue.clear();
721
+
685
722
  const valid = ids.filter(id => {
686
723
  const ph = document.getElementById(`${PH_PREFIX}${id}`);
687
- if (!ph?.isConnected) { S.phState.delete(id); return false; }
688
- if (placeholderLooksUsed(ph)) { S.phState.set(id, 'shown'); return false; }
724
+ if (!ph?.isConnected) { state.phState.delete(id); return false; }
725
+ if (isPlaceholderUsed(ph)) { state.phState.set(id, 'shown'); return false; }
689
726
  return true;
690
727
  });
691
- for (let i = 0; i < valid.length; i += BATCH_SIZE) {
692
- const chunk = valid.slice(i, i + BATCH_SIZE);
728
+
729
+ for (let i = 0; i < valid.length; i += LIMITS.BATCH_SIZE) {
730
+ const chunk = valid.slice(i, i + LIMITS.BATCH_SIZE);
693
731
  try { orig(...chunk); } catch (_) {
694
- for (const id of chunk) { try { orig(id); } catch (_) {} }
732
+ for (const cid of chunk) { try { orig(cid); } catch (_) {} }
695
733
  }
696
734
  }
697
735
  };
736
+
698
737
  ez.showAds = function (...args) {
699
738
  if (isBlocked()) return;
700
739
  const ids = args.length === 1 && Array.isArray(args[0]) ? args[0] : args;
@@ -703,15 +742,17 @@
703
742
  if (!Number.isFinite(id) || id <= 0) continue;
704
743
  const ph = document.getElementById(`${PH_PREFIX}${id}`);
705
744
  if (!ph?.isConnected) continue;
706
- if (placeholderLooksUsed(ph)) { S.phState.set(id, 'shown'); continue; }
707
- S.phState.set(id, 'show-queued');
708
- q.add(id);
745
+ if (isPlaceholderUsed(ph)) { state.phState.set(id, 'shown'); continue; }
746
+ state.phState.set(id, 'show-queued');
747
+ queue.add(id);
748
+ }
749
+ if (queue.size && !flushTimer) {
750
+ flushTimer = setTimeout(flush, TIMING.BATCH_FLUSH_MS);
709
751
  }
710
- if (!q.size) return;
711
- if (!flushTimer) flushTimer = setTimeout(flush, FLUSH_MS);
712
752
  };
713
753
  } catch (_) {}
714
754
  };
755
+
715
756
  apply();
716
757
  if (!window.__nbbEzPatched) {
717
758
  window.ezstandalone = window.ezstandalone || {};
@@ -723,7 +764,6 @@
723
764
 
724
765
  async function runCore() {
725
766
  if (isBlocked()) return 0;
726
- patchShowAds();
727
767
 
728
768
  const cfg = await fetchConfig();
729
769
  if (!cfg || cfg.excluded) return 0;
@@ -738,10 +778,12 @@
738
778
  return injectBetween(klass, getItems(), interval, normBool(cfgShowFirst), poolKey);
739
779
  };
740
780
 
741
- if (kind === 'topic') return exec(
742
- 'ezoic-ad-message', getPosts,
743
- cfg.enableMessageAds, cfg.messageIntervalPosts, cfg.showFirstMessageAd, 'posts'
744
- );
781
+ if (kind === 'topic') {
782
+ return exec(
783
+ 'ezoic-ad-message', getPosts,
784
+ cfg.enableMessageAds, cfg.messageIntervalPosts, cfg.showFirstMessageAd, 'posts'
785
+ );
786
+ }
745
787
 
746
788
  if (kind === 'categoryTopics') {
747
789
  pruneOrphansBetween();
@@ -757,14 +799,14 @@
757
799
  );
758
800
  }
759
801
 
760
- // ── Scheduler ──────────────────────────────────────────────────────────────
802
+ // ── Scheduler & burst ──────────────────────────────────────────────────────
761
803
 
762
804
  function scheduleRun(cb) {
763
- if (S.runQueued) return;
764
- S.runQueued = true;
805
+ if (state.runQueued) return;
806
+ state.runQueued = true;
765
807
  requestAnimationFrame(async () => {
766
- S.runQueued = false;
767
- if (S.pageKey && pageKey() !== S.pageKey) return;
808
+ state.runQueued = false;
809
+ if (state.pageKey && pageKey() !== state.pageKey) return;
768
810
  let n = 0;
769
811
  try { n = await runCore(); } catch (_) {}
770
812
  try { cb?.(n); } catch (_) {}
@@ -773,154 +815,297 @@
773
815
 
774
816
  function requestBurst() {
775
817
  if (isBlocked()) return;
776
- const t = ts();
777
- if (t - S.lastBurstTs < BURST_COOLDOWN_MS) return;
778
- S.lastBurstTs = t;
779
- S.pageKey = pageKey();
780
- S.burstDeadline = t + 2000;
818
+ const t = now();
819
+ if (t - state.lastBurstTs < TIMING.BURST_COOLDOWN_MS) return;
820
+ state.lastBurstTs = t;
821
+ state.pageKey = pageKey();
822
+ state.burstDeadline = t + LIMITS.BURST_WINDOW_MS;
781
823
 
782
- if (S.burstActive) return;
783
- S.burstActive = true;
784
- S.burstCount = 0;
824
+ if (state.burstActive) return;
825
+ state.burstActive = true;
826
+ state.burstCount = 0;
785
827
 
786
828
  const step = () => {
787
- if (pageKey() !== S.pageKey || isBlocked() || ts() > S.burstDeadline || S.burstCount >= 8) {
788
- S.burstActive = false; return;
829
+ if (pageKey() !== state.pageKey || isBlocked() || now() > state.burstDeadline || state.burstCount >= LIMITS.MAX_BURST_STEPS) {
830
+ state.burstActive = false;
831
+ return;
789
832
  }
790
- S.burstCount++;
833
+ state.burstCount++;
791
834
  scheduleRun(n => {
792
- if (!n && !S.pending.length) { S.burstActive = false; return; }
835
+ if (!n && !state.pending.length) { state.burstActive = false; return; }
793
836
  setTimeout(step, n > 0 ? 150 : 300);
794
837
  });
795
838
  };
796
839
  step();
797
840
  }
798
841
 
799
- // ── Cleanup navigation ─────────────────────────────────────────────────────
842
+ // ── Cleanup on navigation ──────────────────────────────────────────────────
800
843
 
801
844
  function cleanup() {
802
- blockedUntil = ts() + 1500;
803
- mutate(() => document.querySelectorAll(`.${WRAP_CLASS}`).forEach(dropWrap));
804
- S.cfg = null;
805
- S.poolsReady = false;
806
- S.pools = { topics: [], posts: [], categories: [] };
807
- S.cursors = { topics: 0, posts: 0, categories: 0 };
808
- S.mountedIds.clear();
809
- S.lastShow.clear();
810
- S.wrapByKey.clear();
811
- S.wrapsByClass.clear();
812
- S.kind = null;
813
- S.phState.clear();
814
- S.inflight = 0;
815
- S.pending = [];
816
- S.pendingSet.clear();
817
- S.burstActive = false;
818
- S.runQueued = false;
819
- S.firstShown = false;
845
+ state.blockedUntil = now() + TIMING.BLOCK_DURATION_MS;
846
+ mutate(() => {
847
+ for (const w of document.querySelectorAll(`.${WRAP_CLASS}`)) {
848
+ dropWrap(w);
849
+ }
850
+ });
851
+ state.cfg = null;
852
+ state.poolsReady = false;
853
+ state.pools = { topics: [], posts: [], categories: [] };
854
+ state.cursors = { topics: 0, posts: 0, categories: 0 };
855
+ state.mountedIds.clear();
856
+ state.lastShow.clear();
857
+ state.wrapByKey.clear();
858
+ state.wrapsByClass.clear();
859
+ state.kind = null;
860
+ state.phState.clear();
861
+ state.inflight = 0;
862
+ state.pending = [];
863
+ state.pendingSet.clear();
864
+ state.burstActive = false;
865
+ state.runQueued = false;
866
+ state.firstShown = false;
820
867
  }
821
868
 
822
869
  // ── MutationObserver ───────────────────────────────────────────────────────
870
+ //
871
+ // Scoped to detect: (1) ad fill events in wraps, (2) new content items
823
872
 
824
873
  function ensureDomObserver() {
825
- if (S.domObs) return;
826
- S.domObs = new MutationObserver(muts => {
827
- if (S.mutGuard > 0 || isBlocked()) return;
874
+ if (state.domObs) return;
875
+
876
+ state.domObs = new MutationObserver(muts => {
877
+ if (state.mutGuard > 0 || isBlocked()) return;
878
+
879
+ let needsBurst = false;
880
+
881
+ // Determine relevant selectors for current page kind
882
+ const kind = getKind();
883
+ const relevantSels =
884
+ kind === 'topic' ? [SEL.post] :
885
+ kind === 'categoryTopics'? [SEL.topic] :
886
+ kind === 'categories' ? [SEL.category] :
887
+ [SEL.post, SEL.topic, SEL.category];
888
+
828
889
  for (const m of muts) {
829
890
  if (m.type !== 'childList') continue;
830
891
  for (const node of m.addedNodes) {
831
892
  if (!(node instanceof Element)) continue;
832
- let hasAd = false;
833
- try {
834
- hasAd = !!(node.matches?.(FILL_SEL) || node.querySelector?.(FILL_SEL));
835
- } catch (_) {}
836
- if (!hasAd) continue;
893
+
894
+ // Check for ad fill events in wraps
837
895
  try {
838
- const wrap = node.closest?.(`.${WRAP_CLASS}.is-empty`) || m.target?.closest?.(`.${WRAP_CLASS}.is-empty`);
839
- if (wrap) clearEmptyIfFilled(wrap);
896
+ if (node.matches?.(FILL_SEL) || node.querySelector?.(FILL_SEL)) {
897
+ const wrap = node.closest?.(`.${WRAP_CLASS}.is-empty`) ||
898
+ m.target?.closest?.(`.${WRAP_CLASS}.is-empty`);
899
+ if (wrap) clearEmptyIfFilled(wrap);
900
+ }
840
901
  } catch (_) {}
841
- }
842
- }
843
- const relevant = (() => {
844
- const k = getKind();
845
- if (k === 'topic') return [SEL.post];
846
- if (k === 'categoryTopics') return [SEL.topic];
847
- if (k === 'categories') return [SEL.category];
848
- return [SEL.post, SEL.topic, SEL.category];
849
- })();
850
- for (const m of muts) {
851
- for (const n of m.addedNodes) {
852
- if (n.nodeType !== 1) continue;
853
- // matches() d'abord (O(1)), querySelector() seulement si nécessaire
854
- if (relevant.some(s => { try { return n.matches(s); } catch(_){return false;} }) ||
855
- relevant.some(s => { try { return !!n.querySelector(s); } catch(_){return false;} })) {
856
- requestBurst(); return;
902
+
903
+ // Check for new content items (posts, topics, categories)
904
+ if (!needsBurst) {
905
+ for (const sel of relevantSels) {
906
+ try {
907
+ if (node.matches(sel) || node.querySelector(sel)) {
908
+ needsBurst = true;
909
+ break;
910
+ }
911
+ } catch (_) {}
912
+ }
857
913
  }
858
914
  }
915
+ if (needsBurst) break;
859
916
  }
917
+
918
+ if (needsBurst) requestBurst();
860
919
  });
861
- try { S.domObs.observe(document.body, { childList: true, subtree: true }); } catch (_) {}
920
+
921
+ try {
922
+ state.domObs.observe(document.body, { childList: true, subtree: true });
923
+ } catch (_) {}
862
924
  }
863
925
 
864
- // ── Utilitaires ────────────────────────────────────────────────────────────
926
+ // ── TCF / CMP Protection ─────────────────────────────────────────────────
927
+ //
928
+ // Root cause of the CMP errors:
929
+ // "Cannot read properties of null (reading 'postMessage')"
930
+ // "Cannot set properties of null (setting 'addtlConsent')"
931
+ //
932
+ // The CMP (Gatekeeper Consent) communicates via postMessage on the
933
+ // __tcfapiLocator iframe's contentWindow. During NodeBB ajaxify navigation,
934
+ // jQuery's html() or empty() on the content area can cascade and remove
935
+ // iframes from <body>. The CMP then calls getTCData on a stale reference
936
+ // where contentWindow is null.
937
+ //
938
+ // Strategy (3 layers):
939
+ //
940
+ // 1. PROTECT: Move the locator iframe into <head> where ajaxify never
941
+ // touches it. The TCF spec only requires the iframe to exist in the
942
+ // document with name="__tcfapiLocator" — it works from <head>.
943
+ //
944
+ // 2. GUARD: Wrap __tcfapi and __cmp with a safety layer that catches
945
+ // errors in the CMP's internal getTCData, preventing the uncaught
946
+ // TypeError from propagating.
947
+ //
948
+ // 3. RESTORE: MutationObserver on <body> childList (not subtree) to
949
+ // immediately re-create the locator if something still removes it.
950
+
951
+ function ensureTcfLocator() {
952
+ if (!window.__tcfapi && !window.__cmp) return;
953
+
954
+ const LOCATOR_ID = '__tcfapiLocator';
955
+
956
+ // Create or relocate the locator iframe into <head> for protection
957
+ const ensureInHead = () => {
958
+ let existing = document.getElementById(LOCATOR_ID);
959
+ if (existing) {
960
+ // If it's in <body>, move it to <head> where ajaxify can't reach it
961
+ if (existing.parentElement !== document.head) {
962
+ try { document.head.appendChild(existing); } catch (_) {}
963
+ }
964
+ return existing;
965
+ }
966
+ // Create fresh
967
+ const f = document.createElement('iframe');
968
+ f.style.display = 'none';
969
+ f.id = f.name = LOCATOR_ID;
970
+ try { document.head.appendChild(f); } catch (_) {
971
+ // Fallback to body if head insertion fails
972
+ (document.body || document.documentElement).appendChild(f);
973
+ }
974
+ return f;
975
+ };
976
+
977
+ ensureInHead();
978
+
979
+ // Layer 2: Guard the CMP API calls against null contentWindow
980
+ if (!window.__nbbCmpGuarded) {
981
+ window.__nbbCmpGuarded = true;
982
+
983
+ // Wrap __tcfapi
984
+ if (typeof window.__tcfapi === 'function') {
985
+ const origTcf = window.__tcfapi;
986
+ window.__tcfapi = function (cmd, version, cb, param) {
987
+ try {
988
+ return origTcf.call(this, cmd, version, function (...args) {
989
+ try { cb?.(...args); } catch (_) {}
990
+ }, param);
991
+ } catch (e) {
992
+ // If the error is the null postMessage/addtlConsent, swallow it
993
+ if (e?.message?.includes('null')) {
994
+ // Re-ensure locator exists, then retry once
995
+ ensureInHead();
996
+ try { return origTcf.call(this, cmd, version, cb, param); } catch (_) {}
997
+ }
998
+ }
999
+ };
1000
+ }
1001
+
1002
+ // Wrap __cmp (legacy CMP v1 API)
1003
+ if (typeof window.__cmp === 'function') {
1004
+ const origCmp = window.__cmp;
1005
+ window.__cmp = function (...args) {
1006
+ try {
1007
+ return origCmp.apply(this, args);
1008
+ } catch (e) {
1009
+ if (e?.message?.includes('null')) {
1010
+ ensureInHead();
1011
+ try { return origCmp.apply(this, args); } catch (_) {}
1012
+ }
1013
+ }
1014
+ };
1015
+ }
1016
+ }
1017
+
1018
+ // Layer 3: MutationObserver to immediately restore if removed
1019
+ if (!window.__nbbTcfObs) {
1020
+ window.__nbbTcfObs = new MutationObserver(muts => {
1021
+ // Fast check: still in document?
1022
+ if (document.getElementById(LOCATOR_ID)) return;
1023
+ // Something removed it — restore immediately (no debounce)
1024
+ ensureInHead();
1025
+ });
1026
+ // Observe body direct children only (the most likely removal point)
1027
+ try {
1028
+ window.__nbbTcfObs.observe(document.body || document.documentElement, {
1029
+ childList: true,
1030
+ subtree: false,
1031
+ });
1032
+ } catch (_) {}
1033
+ // Also observe <head> in case something cleans it
1034
+ try {
1035
+ if (document.head) {
1036
+ window.__nbbTcfObs.observe(document.head, {
1037
+ childList: true,
1038
+ subtree: false,
1039
+ });
1040
+ }
1041
+ } catch (_) {}
1042
+ }
1043
+ }
1044
+
1045
+ // ── Console muting ─────────────────────────────────────────────────────────
1046
+ //
1047
+ // Mute noisy Ezoic warnings that are expected in infinite scroll context.
1048
+ // Uses startsWith checks instead of includes for performance.
865
1049
 
866
1050
  function muteConsole() {
867
1051
  if (window.__nbbEzMuted) return;
868
1052
  window.__nbbEzMuted = true;
869
- const MUTED = [
1053
+
1054
+ const PREFIXES = [
870
1055
  '[EzoicAds JS]: Placeholder Id',
871
1056
  'No valid placeholders for loadMore',
872
1057
  'cannot call refresh on the same page',
873
1058
  'no placeholders are currently defined in Refresh',
874
1059
  'Debugger iframe already exists',
875
- `with id ${PH_PREFIX}`,
1060
+ '[CMP] Error in custom getTCData',
1061
+ 'vignette: no interstitial API',
876
1062
  ];
877
- for (const m of ['log', 'info', 'warn', 'error']) {
878
- const orig = console[m];
1063
+ const PH_PATTERN = `with id ${PH_PREFIX}`;
1064
+
1065
+ for (const method of ['log', 'info', 'warn', 'error']) {
1066
+ const orig = console[method];
879
1067
  if (typeof orig !== 'function') continue;
880
- console[m] = function (...a) {
881
- if (typeof a[0] === 'string' && MUTED.some(p => a[0].includes(p))) return;
882
- orig.apply(console, a);
1068
+ console[method] = function (...args) {
1069
+ if (typeof args[0] === 'string') {
1070
+ const msg = args[0];
1071
+ for (const prefix of PREFIXES) {
1072
+ if (msg.startsWith(prefix)) return;
1073
+ }
1074
+ if (msg.includes(PH_PATTERN)) return;
1075
+ }
1076
+ return orig.apply(console, args);
883
1077
  };
884
1078
  }
885
1079
  }
886
1080
 
887
- function ensureTcfLocator() {
888
- try {
889
- if (!window.__tcfapi && !window.__cmp) return;
890
- const inject = () => {
891
- if (document.getElementById('__tcfapiLocator')) return;
892
- const f = document.createElement('iframe');
893
- f.style.display = 'none'; f.id = f.name = '__tcfapiLocator';
894
- (document.body || document.documentElement).appendChild(f);
895
- };
896
- inject();
897
- if (!window.__nbbTcfObs) {
898
- window.__nbbTcfObs = new MutationObserver(inject);
899
- window.__nbbTcfObs.observe(document.documentElement, { childList: true, subtree: true });
900
- }
901
- } catch (_) {}
902
- }
1081
+ // ── Network warmup ─────────────────────────────────────────────────────────
1082
+ // Run once per session — preconnect hints are in <head> via server-side injection
1083
+
1084
+ let _networkWarmed = false;
903
1085
 
904
- const _warmed = new Set();
905
1086
  function warmNetwork() {
1087
+ if (_networkWarmed) return;
1088
+ _networkWarmed = true;
1089
+
906
1090
  const head = document.head;
907
1091
  if (!head) return;
908
- for (const [rel, href, cors] of [
1092
+
1093
+ const hints = [
909
1094
  ['preconnect', 'https://g.ezoic.net', true ],
910
1095
  ['preconnect', 'https://go.ezoic.net', true ],
911
1096
  ['preconnect', 'https://securepubads.g.doubleclick.net', true ],
912
1097
  ['preconnect', 'https://pagead2.googlesyndication.com', true ],
913
1098
  ['dns-prefetch', 'https://g.ezoic.net', false],
914
1099
  ['dns-prefetch', 'https://securepubads.g.doubleclick.net', false],
915
- ]) {
916
- const k = `${rel}|${href}`;
917
- if (_warmed.has(k)) continue;
918
- _warmed.add(k);
1100
+ ];
1101
+
1102
+ for (const [rel, href, cors] of hints) {
919
1103
  if (head.querySelector(`link[rel="${rel}"][href="${href}"]`)) continue;
920
- const l = document.createElement('link');
921
- l.rel = rel; l.href = href;
922
- if (cors) l.crossOrigin = 'anonymous';
923
- head.appendChild(l);
1104
+ const link = document.createElement('link');
1105
+ link.rel = rel;
1106
+ link.href = href;
1107
+ if (cors) link.crossOrigin = 'anonymous';
1108
+ head.appendChild(link);
924
1109
  }
925
1110
  }
926
1111
 
@@ -931,26 +1116,49 @@
931
1116
  if (!$) return;
932
1117
 
933
1118
  $(window).off('.nbbEzoic');
1119
+
934
1120
  $(window).on('action:ajaxify.start.nbbEzoic', cleanup);
1121
+
935
1122
  $(window).on('action:ajaxify.end.nbbEzoic', () => {
936
- S.pageKey = pageKey();
937
- S.kind = null;
938
- blockedUntil = 0;
939
- muteConsole(); ensureTcfLocator(); warmNetwork();
940
- patchShowAds(); getIO(); ensureDomObserver(); requestBurst();
1123
+ state.pageKey = pageKey();
1124
+ state.kind = null;
1125
+ state.blockedUntil = 0;
1126
+
1127
+ // Fix: CMP modal can leave aria-hidden="true" on <body> after navigation
1128
+ try { document.body.removeAttribute('aria-hidden'); } catch (_) {}
1129
+
1130
+ muteConsole();
1131
+ ensureTcfLocator();
1132
+ warmNetwork();
1133
+ patchShowAds();
1134
+ getIO();
1135
+ ensureDomObserver();
1136
+ requestBurst();
941
1137
  });
942
1138
 
943
- const burstEvts = [
944
- 'action:ajaxify.contentLoaded', 'action:posts.loaded', 'action:topics.loaded',
945
- 'action:categories.loaded', 'action:category.loaded', 'action:topic.loaded',
1139
+ // Content-loaded events trigger burst
1140
+ const burstEvents = [
1141
+ 'action:ajaxify.contentLoaded',
1142
+ 'action:posts.loaded',
1143
+ 'action:topics.loaded',
1144
+ 'action:categories.loaded',
1145
+ 'action:category.loaded',
1146
+ 'action:topic.loaded',
946
1147
  ].map(e => `${e}.nbbEzoic`).join(' ');
947
- $(window).on(burstEvts, () => { if (!isBlocked()) requestBurst(); });
948
1148
 
1149
+ $(window).on(burstEvents, () => { if (!isBlocked()) requestBurst(); });
1150
+
1151
+ // Also bind via NodeBB hooks module (for compatibility)
949
1152
  try {
950
1153
  require(['hooks'], hooks => {
951
1154
  if (typeof hooks?.on !== 'function') return;
952
- for (const ev of ['action:ajaxify.end', 'action:posts.loaded', 'action:topics.loaded',
953
- 'action:categories.loaded', 'action:topic.loaded']) {
1155
+ for (const ev of [
1156
+ 'action:ajaxify.end',
1157
+ 'action:posts.loaded',
1158
+ 'action:topics.loaded',
1159
+ 'action:categories.loaded',
1160
+ 'action:topic.loaded',
1161
+ ]) {
954
1162
  try { hooks.on(ev, () => { if (!isBlocked()) requestBurst(); }); } catch (_) {}
955
1163
  }
956
1164
  });
@@ -962,13 +1170,16 @@
962
1170
  window.addEventListener('scroll', () => {
963
1171
  if (ticking) return;
964
1172
  ticking = true;
965
- requestAnimationFrame(() => { ticking = false; requestBurst(); });
1173
+ requestAnimationFrame(() => {
1174
+ ticking = false;
1175
+ requestBurst();
1176
+ });
966
1177
  }, { passive: true });
967
1178
  }
968
1179
 
969
1180
  // ── Boot ───────────────────────────────────────────────────────────────────
970
1181
 
971
- S.pageKey = pageKey();
1182
+ state.pageKey = pageKey();
972
1183
  muteConsole();
973
1184
  ensureTcfLocator();
974
1185
  warmNetwork();
@@ -977,7 +1188,7 @@
977
1188
  ensureDomObserver();
978
1189
  bindNodeBB();
979
1190
  bindScroll();
980
- blockedUntil = 0;
1191
+ state.blockedUntil = 0;
981
1192
  requestBurst();
982
1193
 
983
1194
  })();