nodebb-plugin-ezoic-infinite 1.5.20 → 1.5.22

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/library.js CHANGED
@@ -9,7 +9,11 @@ const plugin = {};
9
9
 
10
10
  function normalizeExcludedGroups(value) {
11
11
  if (!value) return [];
12
- if (Array.isArray(value)) return value;
12
+ // NodeBB settings may return arrays, strings, or objects like {0:'a',1:'b'}
13
+ if (Array.isArray(value)) return value.map(String).map(s => s.trim()).filter(Boolean);
14
+ if (typeof value === 'object') {
15
+ return Object.values(value).map(String).map(s => s.trim()).filter(Boolean);
16
+ }
13
17
  return String(value).split(',').map(s => s.trim()).filter(Boolean);
14
18
  }
15
19
 
@@ -27,13 +31,21 @@ async function getAllGroups() {
27
31
  }
28
32
  const filtered = names.filter(name => !groups.isPrivilegeGroup(name));
29
33
  const data = await groups.getGroupsData(filtered);
30
- // Sort alphabetically for ACP usability
31
- data.sort((a, b) => String(a.name).localeCompare(String(b.name), 'fr', { sensitivity: 'base' }));
32
- return data;
34
+ // Filter out nulls (groups deleted between the sorted-set read and getGroupsData)
35
+ const valid = data.filter(g => g && g.name);
36
+ valid.sort((a, b) => String(a.name).localeCompare(String(b.name), undefined, { sensitivity: 'base' }));
37
+ return valid;
33
38
  }
39
+ let _settingsCache = null;
40
+ let _settingsCacheAt = 0;
41
+ const SETTINGS_TTL = 30000; // 30s
42
+
34
43
  async function getSettings() {
44
+ const now = Date.now();
45
+ if (_settingsCache && (now - _settingsCacheAt) < SETTINGS_TTL) return _settingsCache;
35
46
  const s = await meta.settings.get(SETTINGS_KEY);
36
- return {
47
+ _settingsCacheAt = Date.now();
48
+ _settingsCache = {
37
49
  // Between-post ads (simple blocks) in category topic list
38
50
  enableBetweenAds: parseBool(s.enableBetweenAds, true),
39
51
  showFirstTopicAd: parseBool(s.showFirstTopicAd, false),
@@ -54,14 +66,32 @@ async function getSettings() {
54
66
 
55
67
  excludedGroups: normalizeExcludedGroups(s.excludedGroups),
56
68
  };
69
+ return _settingsCache;
57
70
  }
58
71
 
59
72
  async function isUserExcluded(uid, excludedGroups) {
60
- if (!uid || !excludedGroups.length) return false;
61
- const userGroups = await groups.getUserGroups([uid]);
62
- return (userGroups[0] || []).some(g => excludedGroups.includes(g.name));
73
+ const list = (excludedGroups || []).map(g => String(g).toLowerCase());
74
+ const id = Number(uid) || 0;
75
+
76
+ if (!list.length) return false;
77
+
78
+ // Guests (uid=0) are not in groups.getUserGroups; treat explicitly if configured
79
+ if (id === 0) {
80
+ return list.includes('guests') || list.includes('guest');
81
+ }
82
+
83
+ const userGroups = await groups.getUserGroups([id]);
84
+ const names = (userGroups[0] || []).map(g => (g && g.name) ? g.name : String(g));
85
+ return names.some(name => list.includes(String(name).toLowerCase()));
63
86
  }
64
87
 
88
+ plugin.onSettingsSet = function (data) {
89
+ // Invalider le cache dès que les settings de ce plugin sont sauvegardés via l'ACP
90
+ if (data && data.hash === SETTINGS_KEY) {
91
+ _settingsCache = null;
92
+ }
93
+ };
94
+
65
95
  plugin.addAdminNavigation = async (header) => {
66
96
  header.plugins = header.plugins || [];
67
97
  header.plugins.push({
@@ -89,9 +119,13 @@ plugin.init = async ({ router, middleware }) => {
89
119
  router.get('/admin/plugins/ezoic-infinite', middleware.admin.buildHeader, render);
90
120
  router.get('/api/admin/plugins/ezoic-infinite', render);
91
121
 
92
- router.get('/api/plugins/ezoic-infinite/config', middleware.buildHeader, async (req, res) => {
122
+ router.get('/api/plugins/ezoic-infinite/config', async (req, res) => {
93
123
  const settings = await getSettings();
94
- const excluded = await isUserExcluded(req.uid, settings.excludedGroups);
124
+ const uid = (typeof req.uid !== 'undefined' && req.uid !== null) ? req.uid
125
+ : (req.user && req.user.uid) ? req.user.uid
126
+ : (res.locals && res.locals.uid) ? res.locals.uid
127
+ : 0;
128
+ const excluded = await isUserExcluded(uid, settings.excludedGroups);
95
129
 
96
130
  res.json({
97
131
  excluded,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nodebb-plugin-ezoic-infinite",
3
- "version": "1.5.20",
3
+ "version": "1.5.22",
4
4
  "description": "Production-ready Ezoic infinite ads integration for NodeBB 4.x",
5
5
  "main": "library.js",
6
6
  "license": "MIT",
package/plugin.json CHANGED
@@ -11,6 +11,10 @@
11
11
  {
12
12
  "hook": "filter:admin.header.build",
13
13
  "method": "addAdminNavigation"
14
+ },
15
+ {
16
+ "hook": "action:settings.set",
17
+ "method": "onSettingsSet"
14
18
  }
15
19
  ],
16
20
  "staticDirs": {
package/public/admin.js CHANGED
@@ -13,11 +13,10 @@
13
13
  e.preventDefault();
14
14
 
15
15
  Settings.save('ezoic-infinite', $form, function () {
16
- // Toast vert (NodeBB core)
17
16
  if (alerts && typeof alerts.success === 'function') {
18
- alerts.success('Enregistré');
17
+ alerts.success('[[admin/settings:saved]]');
19
18
  } else if (window.app && typeof window.app.alertSuccess === 'function') {
20
- window.app.alertSuccess('Enregistré');
19
+ window.app.alertSuccess('[[admin/settings:saved]]');
21
20
  }
22
21
  });
23
22
  });
package/public/client.js CHANGED
@@ -1,45 +1,93 @@
1
1
  (function () {
2
2
  'use strict';
3
3
 
4
- const $ = (typeof window.jQuery === 'function') ? window.jQuery : null;
4
+ var $ = (typeof window.jQuery === 'function') ? window.jQuery : null;
5
5
 
6
- const SELECTORS = {
6
+ var SELECTORS = {
7
7
  topicItem: 'li[component="category/topic"]',
8
8
  postItem: '[component="post"][data-pid]',
9
- categoryItem: 'li[component="categories/category"]',
9
+ categoryItem: 'li[component="categories/category"]'
10
10
  };
11
11
 
12
- const WRAP_CLASS = 'ezoic-ad';
13
- const PLACEHOLDER_PREFIX = 'ezoic-pub-ad-placeholder-';
14
- const MAX_INSERTS_PER_RUN = 3;
12
+ var WRAP_CLASS = 'ezoic-ad';
13
+ var PLACEHOLDER_PREFIX = 'ezoic-pub-ad-placeholder-';
15
14
 
16
- const state = {
15
+ // Hard limits to avoid runaway insertion during mutations / infinite scroll.
16
+ var MAX_INSERTS_PER_RUN = 3;
17
+
18
+ // Placeholders that have been "defined" (filled) at least once in this browser session.
19
+ // This survives ajaxify navigations, which is important for safe recycle/destroy logic.
20
+ var sessionDefinedIds = new Set();
21
+
22
+ // Prevent re-entrant insertion of the same id while Ezoic is processing it.
23
+ var insertingIds = new Set();
24
+
25
+ var state = {
17
26
  pageKey: null,
27
+
18
28
  cfg: null,
19
29
  cfgPromise: null,
30
+
20
31
  poolTopics: [],
21
32
  poolPosts: [],
22
33
  poolCategories: [],
34
+
23
35
  usedTopics: new Set(),
24
36
  usedPosts: new Set(),
25
37
  usedCategories: new Set(),
38
+
39
+ // Track wrappers that are still in the DOM to recycle ids once they are far above viewport.
40
+ liveTopics: [],
41
+ livePosts: [],
42
+ liveCategories: [],
43
+
44
+ // Throttle showAds calls per id.
26
45
  lastShowById: new Map(),
27
- canShowAds: false,
46
+
47
+ // Ids for which we scheduled/attempted showAds and should not schedule again immediately.
48
+ pendingById: new Set(),
49
+
50
+ // Timeouts created by this script (so we can cancel on ajaxify.start).
51
+ activeTimeouts: new Set(),
52
+
53
+ // Run scheduling / mutation observer.
28
54
  scheduled: false,
29
55
  timer: null,
30
56
  obs: null,
31
- };
32
57
 
33
- const sessionDefinedIds = new Set();
58
+ // Scroll throttling.
59
+ lastScrollRun: 0,
60
+
61
+ // Navigation safety gate: we only insert after ajaxify.end settles.
62
+ canShowAds: false,
63
+
64
+ // Retry counters.
65
+ poolWaitAttempts: 0,
66
+ awaitItemsAttempts: 0
67
+ };
34
68
 
35
69
  function normalizeBool(v) {
36
70
  return v === true || v === 'true' || v === 1 || v === '1' || v === 'on';
37
71
  }
38
72
 
73
+ function setTimeoutTracked(fn, ms) {
74
+ var id = setTimeout(fn, ms);
75
+ state.activeTimeouts.add(id);
76
+ return id;
77
+ }
78
+
79
+ function clearAllTrackedTimeouts() {
80
+ state.activeTimeouts.forEach(function (id) {
81
+ try { clearTimeout(id); } catch (e) {}
82
+ });
83
+ state.activeTimeouts.clear();
84
+ }
85
+
39
86
  function uniqInts(lines) {
40
- const out = [], seen = new Set();
41
- for (const v of lines) {
42
- const n = parseInt(v, 10);
87
+ var out = [];
88
+ var seen = new Set();
89
+ for (var i = 0; i < lines.length; i++) {
90
+ var n = parseInt(lines[i], 10);
43
91
  if (Number.isFinite(n) && n > 0 && !seen.has(n)) {
44
92
  seen.add(n);
45
93
  out.push(n);
@@ -50,26 +98,34 @@
50
98
 
51
99
  function parsePool(raw) {
52
100
  if (!raw) return [];
53
- const lines = String(raw).split(/\r?\n/).map(s => s.trim()).filter(Boolean);
101
+ var lines = String(raw)
102
+ .split(/\r?\n/)
103
+ .map(function (s) { return s.trim(); })
104
+ .filter(Boolean);
54
105
  return uniqInts(lines);
55
106
  }
56
107
 
57
108
  function getPageKey() {
58
109
  try {
59
- const ax = window.ajaxify;
110
+ var ax = window.ajaxify;
60
111
  if (ax && ax.data) {
61
- if (ax.data.tid) return `topic:${ax.data.tid}`;
62
- if (ax.data.cid) return `cid:${ax.data.cid}:${window.location.pathname}`;
112
+ if (ax.data.tid) return 'topic:' + ax.data.tid;
113
+ if (ax.data.cid) return 'cid:' + ax.data.cid + ':' + window.location.pathname;
63
114
  }
64
115
  } catch (e) {}
65
116
  return window.location.pathname;
66
117
  }
67
118
 
68
119
  function getKind() {
69
- const p = window.location.pathname || '';
120
+ var p = window.location.pathname || '';
70
121
  if (/^\/topic\//.test(p)) return 'topic';
71
122
  if (/^\/category\//.test(p)) return 'categoryTopics';
72
123
  if (p === '/' || /^\/categories/.test(p)) return 'categories';
124
+
125
+ // Fallback by DOM.
126
+ if (document.querySelector(SELECTORS.categoryItem)) return 'categories';
127
+ if (document.querySelector(SELECTORS.postItem)) return 'topic';
128
+ if (document.querySelector(SELECTORS.topicItem)) return 'categoryTopics';
73
129
  return 'other';
74
130
  }
75
131
 
@@ -77,295 +133,715 @@
77
133
  return Array.from(document.querySelectorAll(SELECTORS.topicItem));
78
134
  }
79
135
 
80
- function getPostContainers() {
81
- return Array.from(document.querySelectorAll(SELECTORS.postItem));
82
- }
83
-
84
136
  function getCategoryItems() {
85
137
  return Array.from(document.querySelectorAll(SELECTORS.categoryItem));
86
138
  }
87
139
 
88
- async function fetchConfig() {
89
- if (state.cfg) return state.cfg;
90
- if (state.cfgPromise) return state.cfgPromise;
140
+ function getPostContainers() {
141
+ var nodes = Array.from(document.querySelectorAll(SELECTORS.postItem));
142
+ return nodes.filter(function (el) {
143
+ if (!el || !el.isConnected) return false;
144
+ if (!el.querySelector('[component="post/content"]')) return false;
91
145
 
92
- state.cfgPromise = (async () => {
93
- try {
94
- const res = await fetch('/api/ezoic-infinite/config');
95
- if (!res.ok) return null;
96
- const cfg = await res.json();
97
- state.cfg = cfg;
98
- return cfg;
99
- } catch (e) {
100
- return null;
101
- }
102
- })();
146
+ // Prevent nested / duplicated post wrappers.
147
+ var parentPost = el.parentElement && el.parentElement.closest('[component="post"][data-pid]');
148
+ if (parentPost && parentPost !== el) return false;
149
+ if (el.getAttribute('component') === 'post/parent') return false;
103
150
 
104
- return state.cfgPromise;
151
+ return true;
152
+ });
105
153
  }
106
154
 
107
- function initPools(cfg) {
108
- if (!state.poolTopics.length) state.poolTopics = parsePool(cfg.poolTopics);
109
- if (!state.poolPosts.length) state.poolPosts = parsePool(cfg.poolPosts);
110
- if (!state.poolCategories.length) state.poolCategories = parsePool(cfg.poolCategories);
155
+ function safeRect(el) {
156
+ try { return el.getBoundingClientRect(); } catch (e) { return null; }
111
157
  }
112
158
 
113
159
  function destroyPlaceholderIds(ids) {
114
160
  if (!ids || !ids.length) return;
115
- const filtered = ids.filter(id => Number.isFinite(id) && id > 0);
161
+
162
+ // Only destroy ids that were actually "defined" (filled at least once) to avoid Ezoic warnings.
163
+ var filtered = ids.filter(function (id) {
164
+ try { return sessionDefinedIds.has(id); } catch (e) { return true; }
165
+ });
166
+
116
167
  if (!filtered.length) return;
117
168
 
118
- try {
119
- window.ezstandalone = window.ezstandalone || {};
120
- window.ezstandalone.cmd = window.ezstandalone.cmd || [];
121
-
122
- const call = () => {
123
- if (typeof window.ezstandalone.destroyPlaceholders === 'function') {
169
+ var call = function () {
170
+ try {
171
+ if (window.ezstandalone && typeof window.ezstandalone.destroyPlaceholders === 'function') {
124
172
  window.ezstandalone.destroyPlaceholders(filtered);
125
173
  }
126
- };
174
+ } catch (e) {}
175
+ };
127
176
 
177
+ try {
178
+ // Do NOT initialize ezstandalone here; if you load Ezoic elsewhere, it will manage its own queue.
128
179
  if (window.ezstandalone && typeof window.ezstandalone.destroyPlaceholders === 'function') {
129
180
  call();
130
- } else {
181
+ } else if (window.ezstandalone && window.ezstandalone.cmd && Array.isArray(window.ezstandalone.cmd)) {
131
182
  window.ezstandalone.cmd.push(call);
132
183
  }
184
+ } catch (e) {}}
185
+
186
+ function getRecyclable(liveArr) {
187
+ var margin = 600; // px above viewport
188
+ for (var i = 0; i < liveArr.length; i++) {
189
+ var entry = liveArr[i];
190
+ if (!entry || !entry.wrap || !entry.wrap.isConnected) {
191
+ liveArr.splice(i, 1);
192
+ i--;
193
+ continue;
194
+ }
195
+ var r = safeRect(entry.wrap);
196
+ if (r && r.bottom < -margin) {
197
+ liveArr.splice(i, 1);
198
+ return entry;
199
+ }
200
+ }
201
+ return null;
202
+ }
133
203
 
134
- // Recyclage: libérer après 100ms
135
- setTimeout(() => {
136
- filtered.forEach(id => sessionDefinedIds.delete(id));
137
- }, 100);
138
- } catch (e) {}
204
+ function pickId(pool, liveArr) {
205
+ if (pool.length) return { id: pool.shift(), recycled: null };
206
+
207
+ var recycled = getRecyclable(liveArr);
208
+ if (recycled) return { id: recycled.id, recycled: recycled };
209
+
210
+ return { id: null, recycled: null };
139
211
  }
140
212
 
141
- function buildWrap(id, kindClass, afterPos) {
142
- const wrap = document.createElement('div');
143
- wrap.className = `${WRAP_CLASS} ${kindClass}`;
144
- wrap.setAttribute('data-ezoic-after', String(afterPos));
145
- wrap.style.width = '100%';
213
+ function resetPlaceholderInWrap(wrap, id) {
214
+ if (!wrap) return null;
215
+ try { wrap.innerHTML = ''; } catch (e) {}
146
216
 
147
- const ph = document.createElement('div');
148
- ph.id = `${PLACEHOLDER_PREFIX}${id}`;
217
+ var ph = document.createElement('div');
218
+ ph.id = PLACEHOLDER_PREFIX + id;
149
219
  wrap.appendChild(ph);
220
+ return ph;
221
+ }
222
+
223
+ function isAdjacentAd(el) {
224
+ var next = el && el.nextElementSibling;
225
+ return !!(next && next.classList && next.classList.contains(WRAP_CLASS));
226
+ }
227
+
228
+ function isPrevAd(el) {
229
+ var prev = el && el.previousElementSibling;
230
+ return !!(prev && prev.classList && prev.classList.contains(WRAP_CLASS));
231
+ }
232
+
233
+ function buildWrap(id, kindClass) {
234
+ var wrap = document.createElement('div');
235
+ wrap.className = WRAP_CLASS + ' ' + kindClass;
236
+ wrap.setAttribute('data-ezoic-id', String(id));
237
+ resetPlaceholderInWrap(wrap, id);
150
238
  return wrap;
151
239
  }
152
240
 
153
241
  function findWrap(kindClass, afterPos) {
154
- return document.querySelector(`.${WRAP_CLASS}.${kindClass}[data-ezoic-after="${afterPos}"]`);
242
+ // Search a wrapper marker that we set on insertion.
243
+ return document.querySelector('.' + kindClass + '[data-after-pos="' + afterPos + '"]');
155
244
  }
156
245
 
157
- function insertAfter(target, id, kindClass, afterPos) {
158
- if (!target || !target.insertAdjacentElement) return null;
159
- if (findWrap(kindClass, afterPos)) return null;
160
-
246
+ function insertAfter(el, id, kindClass, afterPos) {
161
247
  try {
162
- const wrap = buildWrap(id, kindClass, afterPos);
163
- target.insertAdjacentElement('afterend', wrap);
248
+ var wrap = buildWrap(id, kindClass);
249
+ wrap.setAttribute('data-after-pos', String(afterPos));
250
+
251
+ if (!el || !el.parentNode) return null;
252
+ if (el.nextSibling) el.parentNode.insertBefore(wrap, el.nextSibling);
253
+ else el.parentNode.appendChild(wrap);
254
+
255
+ attachFillObserver(wrap, id);
164
256
  return wrap;
165
- } catch (e) {
166
- return null;
167
- }
257
+ } catch (e) {}
258
+ return null;
168
259
  }
169
260
 
170
- function pickId(pool) {
171
- if (!pool || !pool.length) return null;
172
- return pool.shift();
261
+ function destroyUsedPlaceholders() {
262
+ var ids = [];
263
+ state.usedTopics.forEach(function (id) { ids.push(id); });
264
+ state.usedPosts.forEach(function (id) { ids.push(id); });
265
+ state.usedCategories.forEach(function (id) { ids.push(id); });
266
+
267
+ // Only destroy placeholders that were filled at least once in this session.
268
+ destroyPlaceholderIds(ids);
173
269
  }
174
270
 
175
- function callShowAds(id) {
176
- if (!id) return;
177
-
271
+ function patchShowAds() {
272
+ // Intentionally left blank: ezstandalone is managed elsewhere.
273
+ }
274
+
275
+ function markFilled(id) {
276
+ try { sessionDefinedIds.add(id); } catch (e) {}
277
+ }
278
+
279
+ function isWrapMarkedFilled(wrap) {
280
+ try { return !!(wrap && wrap.getAttribute && wrap.getAttribute('data-ezoic-filled') === '1'); } catch (e) { return false; }
281
+ }
282
+
283
+ function attachFillObserver(wrap, id) {
284
+ if (!wrap || !wrap.isConnected) return;
285
+
286
+ // If already filled, mark and return.
287
+ if (isPlaceholderFilled(wrap)) {
288
+ try { wrap.setAttribute('data-ezoic-filled', '1'); } catch (e) {}
289
+ markFilled(id);
290
+ return;
291
+ }
292
+
293
+ // Observe for Ezoic inserting ad content into placeholder.
178
294
  try {
179
- window.ezstandalone = window.ezstandalone || {};
180
- window.ezstandalone.cmd = window.ezstandalone.cmd || [];
181
-
182
- window.ezstandalone.cmd.push(function() {
183
- if (typeof window.ezstandalone.showAds === 'function') {
184
- window.ezstandalone.showAds(id);
185
- sessionDefinedIds.add(id);
295
+ var obs = new MutationObserver(function () {
296
+ if (!wrap.isConnected) {
297
+ try { obs.disconnect(); } catch (e) {}
298
+ return;
299
+ }
300
+ if (isPlaceholderFilled(wrap)) {
301
+ try { wrap.setAttribute('data-ezoic-filled', '1'); } catch (e) {}
302
+ markFilled(id);
303
+ try { obs.disconnect(); } catch (e) {}
186
304
  }
187
305
  });
306
+ obs.observe(wrap, { childList: true, subtree: true });
307
+ wrap.__ezoicFillObs = obs;
308
+ } catch (e) {}
309
+ }
310
+
311
+ function isPlaceholderFilled(wrap) {
312
+ // Heuristic: placeholder exists AND has descendants or meaningful height.
313
+ try {
314
+ var ph = wrap.querySelector('[id^="' + PLACEHOLDER_PREFIX + '"]');
315
+ if (!ph) return false;
316
+ if (ph.children && ph.children.length) return true;
317
+ var r = safeRect(wrap);
318
+ if (r && r.height > 20) return true;
188
319
  } catch (e) {}
320
+ return false;
189
321
  }
190
322
 
191
- function injectBetween(kindClass, items, interval, showFirst, pool, usedSet) {
192
- if (!items || items.length === 0) return 0;
323
+ function scheduleShowAdsBatch(ids) {
324
+ if (!ids || !ids.length) return;
193
325
 
194
- let inserted = 0;
195
- const targets = [];
326
+ // Ezoic expects DOM to be settled.
327
+ var call = function () {
328
+ try {
329
+ if (window.ezstandalone && typeof window.ezstandalone.showAds === 'function') {
330
+ window.ezstandalone.showAds(ids);
331
+ }
332
+ } catch (e) {}
333
+ };
196
334
 
197
- for (let i = 0; i < items.length; i++) {
198
- const afterPos = i + 1;
199
- if (afterPos === 1 && !showFirst) continue;
200
- if (afterPos % interval !== (showFirst ? 1 : 0)) continue;
201
- targets.push(afterPos);
335
+ try {
336
+ if (window.ezstandalone && typeof window.ezstandalone.showAds === 'function') call();
337
+ else if (window.ezstandalone && window.ezstandalone.cmd && Array.isArray(window.ezstandalone.cmd)) window.ezstandalone.cmd.push(call);
338
+ } catch (e) {}
339
+ }
340
+
341
+ function callShowAdsWhenReady(id) {
342
+ if (!id) return;
343
+
344
+ // Throttle per-id.
345
+ var now = Date.now();
346
+ var last = state.lastShowById.get(id) || 0;
347
+ if (now - last < 1200) return;
348
+
349
+ if (state.pendingById.has(id)) return;
350
+ state.pendingById.add(id);
351
+ state.lastShowById.set(id, now);
352
+
353
+ // Guard against re-entrancy.
354
+ if (insertingIds.has(id)) {
355
+ state.pendingById.delete(id);
356
+ return;
202
357
  }
358
+ insertingIds.add(id);
359
+
360
+ var attempts = 0;
203
361
 
204
- for (const afterPos of targets) {
362
+ (function waitForPh() {
363
+ attempts++;
364
+
365
+ // Navigation safety: if we navigated away, stop.
366
+ if (!state.canShowAds) {
367
+ state.pendingById.delete(id);
368
+ insertingIds.delete(id);
369
+ return;
370
+ }
371
+
372
+ var ph = document.getElementById(PLACEHOLDER_PREFIX + id);
373
+
374
+ var doCall = function () {
375
+ try {
376
+ // If placeholder is gone, stop.
377
+ if (!ph || !ph.isConnected) return false;
378
+ scheduleShowAdsBatch([id]);
379
+ return true;
380
+ } catch (e) {}
381
+ return false;
382
+ };
383
+
384
+ if (ph && ph.isConnected) {
385
+ doCall();
386
+ state.pendingById.delete(id);
387
+ insertingIds.delete(id);
388
+ return;
389
+ }
390
+
391
+ if (attempts < 100) {
392
+ setTimeoutTracked(waitForPh, 50);
393
+ return;
394
+ }
395
+
396
+ // Timeout: give up silently.
397
+ state.pendingById.delete(id);
398
+ insertingIds.delete(id);
399
+ })();
400
+ }
401
+
402
+ function initPools(cfg) {
403
+ if (!state.poolTopics.length) state.poolTopics = parsePool(cfg.placeholderIds);
404
+ if (!state.poolPosts.length) state.poolPosts = parsePool(cfg.messagePlaceholderIds);
405
+ if (!state.poolCategories.length) state.poolCategories = parsePool(cfg.categoryPlaceholderIds);
406
+ }
407
+
408
+ function computeTargets(count, interval, showFirst) {
409
+ var out = [];
410
+ if (count <= 0) return out;
411
+
412
+ if (showFirst) out.push(1);
413
+
414
+ for (var i = 1; i <= count; i++) {
415
+ if (i % interval === 0) out.push(i);
416
+ }
417
+
418
+ // Unique + sorted.
419
+ return Array.from(new Set(out)).sort(function (a, b) { return a - b; });
420
+ }
421
+
422
+ function injectBetween(kindClass, items, interval, showFirst, kindPool, usedSet, liveArr) {
423
+ if (!items || !items.length) return 0;
424
+
425
+ var targets = computeTargets(items.length, interval, showFirst);
426
+ var inserted = 0;
427
+
428
+ for (var t = 0; t < targets.length; t++) {
429
+ var afterPos = targets[t];
205
430
  if (inserted >= MAX_INSERTS_PER_RUN) break;
206
431
 
207
- const el = items[afterPos - 1];
432
+ var el = items[afterPos - 1];
208
433
  if (!el || !el.isConnected) continue;
434
+
435
+ // Prevent adjacent ads.
436
+ if (isAdjacentAd(el) || isPrevAd(el)) continue;
437
+
438
+ // Prevent duplicates at same logical position.
439
+ if (findWrap(kindClass, afterPos - 1)) continue;
209
440
  if (findWrap(kindClass, afterPos)) continue;
210
441
 
211
- const id = pickId(pool);
442
+ var pick = pickId(kindPool, liveArr);
443
+ var id = pick.id;
212
444
  if (!id) break;
213
445
 
214
- const wrap = insertAfter(el, id, kindClass, afterPos);
215
- if (!wrap) continue;
446
+ var wrap = null;
447
+
448
+ if (pick.recycled && pick.recycled.wrap) {
449
+ // Recycle: only destroy if Ezoic has actually defined this placeholder before.
450
+ if (sessionDefinedIds.has(id)) destroyPlaceholderIds([id]);
451
+
452
+ // Remove old wrapper.
453
+ var oldWrap = pick.recycled.wrap;
454
+ try { if (oldWrap && oldWrap.__ezoicFillObs) oldWrap.__ezoicFillObs.disconnect(); } catch (e) {}
455
+ try { if (oldWrap) oldWrap.remove(); } catch (e) {}
456
+
457
+ wrap = insertAfter(el, id, kindClass, afterPos);
458
+ if (!wrap) continue;
216
459
 
217
- usedSet.add(id);
218
- callShowAds(id);
219
- inserted += 1;
460
+ // Give Ezoic a moment after DOM insertion.
461
+ setTimeoutTracked(function () { callShowAdsWhenReady(id); }, 700);
462
+ } else {
463
+ usedSet.add(id);
464
+ wrap = insertAfter(el, id, kindClass, afterPos);
465
+ if (!wrap) continue;
466
+
467
+ // Micro-delay to allow layout/DOM settle.
468
+ setTimeoutTracked(function () { callShowAdsWhenReady(id); }, 10);
469
+ }
470
+
471
+ liveArr.push({ id: id, wrap: wrap });
472
+
473
+ // Final safety: if adjacency happened due to DOM shifts, rollback.
474
+ var prev = wrap && wrap.previousElementSibling;
475
+ var next = wrap && wrap.nextElementSibling;
476
+ if (wrap && ((prev && prev.classList && prev.classList.contains(WRAP_CLASS)) || (next && next.classList && next.classList.contains(WRAP_CLASS)))) {
477
+ try { wrap.remove(); } catch (e) {}
478
+
479
+ if (!(pick.recycled && pick.recycled.wrap)) {
480
+ try { kindPool.unshift(id); } catch (e) {}
481
+ usedSet.delete(id);
482
+ }
483
+ continue;
484
+ }
485
+
486
+ inserted++;
220
487
  }
221
488
 
222
489
  return inserted;
223
490
  }
224
491
 
225
- async function runCore() {
226
- if (!state.canShowAds) return;
227
-
228
- const cfg = await fetchConfig();
229
- if (!cfg || cfg.excluded) return;
230
-
231
- initPools(cfg);
232
-
233
- const kind = getKind();
234
- let inserted = 0;
235
-
236
- if (kind === 'topic' && normalizeBool(cfg.enableMessageAds)) {
237
- inserted = injectBetween(
238
- 'ezoic-ad-message',
239
- getPostContainers(),
240
- Math.max(1, parseInt(cfg.messageIntervalPosts, 10) || 3),
241
- normalizeBool(cfg.showFirstMessageAd),
242
- state.poolPosts,
243
- state.usedPosts
244
- );
245
- } else if (kind === 'categoryTopics' && normalizeBool(cfg.enableBetweenAds)) {
246
- inserted = injectBetween(
247
- 'ezoic-ad-between',
248
- getTopicItems(),
249
- Math.max(1, parseInt(cfg.intervalPosts, 10) || 6),
250
- normalizeBool(cfg.showFirstTopicAd),
251
- state.poolTopics,
252
- state.usedTopics
253
- );
254
- } else if (kind === 'categories' && normalizeBool(cfg.enableCategoryAds)) {
255
- inserted = injectBetween(
256
- 'ezoic-ad-category',
257
- getCategoryItems(),
258
- Math.max(1, parseInt(cfg.intervalCategories, 10) || 6),
259
- normalizeBool(cfg.showFirstCategoryAd),
260
- state.poolCategories,
261
- state.usedCategories
262
- );
492
+ function enforceNoAdjacentAds() {
493
+ var ads = Array.from(document.querySelectorAll('.' + WRAP_CLASS));
494
+ for (var i = 0; i < ads.length; i++) {
495
+ var ad = ads[i];
496
+ var prev = ad.previousElementSibling;
497
+
498
+ if (prev && prev.classList && prev.classList.contains(WRAP_CLASS)) {
499
+ // Remove adjacent wrapper (do not hide).
500
+ try {
501
+ var ph = ad.querySelector('[id^="' + PLACEHOLDER_PREFIX + '"]');
502
+ if (ph) {
503
+ var id = parseInt(ph.id.replace(PLACEHOLDER_PREFIX, ''), 10);
504
+ if (Number.isFinite(id) && id > 0 && sessionDefinedIds.has(id)) {
505
+ destroyPlaceholderIds([id]);
506
+ }
507
+ }
508
+ ad.remove();
509
+ } catch (e) {}
510
+ }
263
511
  }
264
512
  }
265
513
 
266
- function scheduleRun() {
267
- if (state.scheduled) return;
268
- state.scheduled = true;
514
+ function cleanup() {
515
+ // Stop any insertion during navigation / DOM teardown.
516
+ state.canShowAds = false;
517
+ state.poolWaitAttempts = 0;
518
+ state.awaitItemsAttempts = 0;
269
519
 
270
- clearTimeout(state.timer);
271
- state.timer = setTimeout(() => {
272
- state.scheduled = false;
273
- const pk = getPageKey();
274
- if (state.pageKey && pk !== state.pageKey) return;
275
- runCore().catch(() => {});
276
- }, 50);
277
- }
520
+ // Cancel any pending showAds timeouts.
521
+ clearAllTrackedTimeouts();
278
522
 
279
- function cleanup() {
280
- const allIds = [...state.usedTopics, ...state.usedPosts, ...state.usedCategories];
281
- if (allIds.length) destroyPlaceholderIds(allIds);
523
+ // Disconnect global observer to avoid mutations during teardown.
524
+ if (state.obs) {
525
+ try { state.obs.disconnect(); } catch (e) {}
526
+ state.obs = null;
527
+ }
282
528
 
283
- document.querySelectorAll('.ezoic-ad').forEach(el => {
284
- try { el.remove(); } catch (e) {}
285
- });
529
+ // Destroy placeholders that were used (only those that were actually defined).
530
+ destroyUsedPlaceholders();
286
531
 
532
+ // Remove wrappers from DOM (safe because insertion is now blocked).
533
+ try {
534
+ document.querySelectorAll('.' + WRAP_CLASS).forEach(function (el) {
535
+ try { if (el && el.__ezoicFillObs) el.__ezoicFillObs.disconnect(); } catch (e) {}
536
+ try { el.remove(); } catch (e) {}
537
+ });
538
+ } catch (e) {}
539
+
540
+ // Reset runtime caches.
287
541
  state.pageKey = getPageKey();
288
542
  state.cfg = null;
289
543
  state.cfgPromise = null;
544
+
290
545
  state.poolTopics = [];
291
546
  state.poolPosts = [];
292
547
  state.poolCategories = [];
548
+
293
549
  state.usedTopics.clear();
294
550
  state.usedPosts.clear();
295
551
  state.usedCategories.clear();
296
- state.lastShowById.clear();
297
- sessionDefinedIds.clear();
298
552
 
299
- if (state.obs) {
300
- state.obs.disconnect();
301
- state.obs = null;
302
- }
553
+ state.liveTopics = [];
554
+ state.livePosts = [];
555
+ state.liveCategories = [];
556
+
557
+ state.lastShowById.clear();
558
+ state.pendingById.clear();
559
+ insertingIds.clear();
303
560
 
304
561
  state.scheduled = false;
305
- clearTimeout(state.timer);
306
- state.timer = null;
562
+ if (state.timer) {
563
+ try { clearTimeout(state.timer); } catch (e) {}
564
+ state.timer = null;
565
+ }
307
566
  }
308
567
 
309
568
  function ensureObserver() {
310
569
  if (state.obs) return;
311
- state.obs = new MutationObserver(() => scheduleRun());
312
570
  try {
571
+ state.obs = new MutationObserver(function () { scheduleRun('mutation'); });
313
572
  state.obs.observe(document.body, { childList: true, subtree: true });
314
573
  } catch (e) {}
315
574
  }
316
575
 
576
+ function scheduleRun(/* reason */) {
577
+ if (state.scheduled) return;
578
+ state.scheduled = true;
579
+
580
+ if (state.timer) {
581
+ try { clearTimeout(state.timer); } catch (e) {}
582
+ state.timer = null;
583
+ }
584
+
585
+ state.timer = setTimeoutTracked(function () {
586
+ state.scheduled = false;
587
+
588
+ // If user navigated away, stop.
589
+ var pk = getPageKey();
590
+ if (state.pageKey && pk !== state.pageKey) return;
591
+
592
+ runCore().catch(function () {});
593
+ }, 80);
594
+ }
595
+
596
+ function waitForItemsThenRun(kind) {
597
+ // If list isn't in DOM yet (ajaxify transition), retry a bit.
598
+ var count = 0;
599
+ if (kind === 'topic') count = getPostContainers().length;
600
+ else if (kind === 'categoryTopics') count = getTopicItems().length;
601
+ else if (kind === 'categories') count = getCategoryItems().length;
602
+
603
+ if (count > 0) return true;
604
+
605
+ if (state.awaitItemsAttempts < 25) {
606
+ state.awaitItemsAttempts++;
607
+ setTimeoutTracked(function () { scheduleRun('await-items'); }, 120);
608
+ }
609
+ return false;
610
+ }
611
+
317
612
  function waitForContentThenRun() {
318
- const kind = getKind();
319
- let selector = SELECTORS.postItem;
320
- if (kind === 'categoryTopics') selector = SELECTORS.topicItem;
321
- else if (kind === 'categories') selector = SELECTORS.categoryItem;
322
-
323
- const check = () => {
324
- if (document.querySelector(selector)) {
325
- scheduleRun();
613
+ // Avoid inserting ads on pages with too little content.
614
+ var MIN_WORDS = 250;
615
+ var attempts = 0;
616
+ var maxAttempts = 20; // 20 × 200ms = 4s
617
+
618
+ (function check() {
619
+ attempts++;
620
+
621
+ var text = '';
622
+ try { text = document.body.innerText || ''; } catch (e) {}
623
+ var wordCount = text.split(/\s+/).filter(Boolean).length;
624
+
625
+ if (wordCount >= MIN_WORDS) {
626
+ scheduleRun('content-ok');
627
+ return;
628
+ }
629
+
630
+ if (attempts >= maxAttempts) {
631
+ scheduleRun('content-timeout');
632
+ return;
633
+ }
634
+
635
+ setTimeoutTracked(check, 200);
636
+ })();
637
+ }
638
+
639
+ function waitForEzoicThenRun() {
640
+ var attempts = 0;
641
+ var maxAttempts = 50; // 50 × 200ms = 10s
642
+
643
+ (function check() {
644
+ attempts++;
645
+
646
+ if (window.ezstandalone && typeof window.ezstandalone.showAds === 'function') {
647
+ scheduleRun('ezoic-ready');
648
+ waitForContentThenRun();
649
+ return;
650
+ }
651
+
652
+ if (attempts >= maxAttempts) {
653
+ scheduleRun('ezoic-timeout');
654
+ return;
655
+ }
656
+
657
+ setTimeoutTracked(check, 200);
658
+ })();
659
+ }
660
+
661
+ function fetchConfig() {
662
+ if (state.cfg) return Promise.resolve(state.cfg);
663
+ if (state.cfgPromise) return state.cfgPromise;
664
+
665
+ state.cfgPromise = (function () {
666
+ var MAX_TRIES = 3;
667
+ var delay = 800;
668
+
669
+ function attemptFetch(attempt) {
670
+ return fetch('/api/plugins/ezoic-infinite/config', { credentials: 'same-origin' })
671
+ .then(function (res) {
672
+ if (!res || !res.ok) throw new Error('bad response');
673
+ return res.json();
674
+ })
675
+ .then(function (json) {
676
+ state.cfg = json;
677
+ return json;
678
+ })
679
+ .catch(function () {
680
+ if (attempt >= MAX_TRIES) return null;
681
+ return new Promise(function (r) { setTimeoutTracked(r, delay); }).then(function () {
682
+ delay *= 2;
683
+ return attemptFetch(attempt + 1);
684
+ });
685
+ });
686
+ }
687
+
688
+ return attemptFetch(1).finally(function () { state.cfgPromise = null; });
689
+ })();
690
+
691
+ return state.cfgPromise;
692
+ }
693
+
694
+ function runCore() {
695
+ // Navigation safety: never insert during ajaxify teardown.
696
+ if (!state.canShowAds) return Promise.resolve();
697
+
698
+ patchShowAds();
699
+
700
+ return fetchConfig().then(function (cfg) {
701
+ if (!cfg || cfg.excluded) return;
702
+
703
+ initPools(cfg);
704
+
705
+ var kind = getKind();
706
+ var inserted = 0;
707
+
708
+ if (!waitForItemsThenRun(kind)) return;
709
+
710
+ if (kind === 'topic') {
711
+ if (normalizeBool(cfg.enableMessageAds)) {
712
+ inserted = injectBetween(
713
+ 'ezoic-ad-message',
714
+ getPostContainers(),
715
+ Math.max(1, parseInt(cfg.messageIntervalPosts, 10) || 3),
716
+ normalizeBool(cfg.showFirstMessageAd),
717
+ state.poolPosts,
718
+ state.usedPosts,
719
+ state.livePosts
720
+ );
721
+ }
722
+ } else if (kind === 'categoryTopics') {
723
+ if (normalizeBool(cfg.enableBetweenAds)) {
724
+ inserted = injectBetween(
725
+ 'ezoic-ad-between',
726
+ getTopicItems(),
727
+ Math.max(1, parseInt(cfg.intervalPosts, 10) || 6),
728
+ normalizeBool(cfg.showFirstTopicAd),
729
+ state.poolTopics,
730
+ state.usedTopics,
731
+ state.liveTopics
732
+ );
733
+ }
734
+ } else if (kind === 'categories') {
735
+ if (normalizeBool(cfg.enableCategoryAds)) {
736
+ inserted = injectBetween(
737
+ 'ezoic-ad-categories',
738
+ getCategoryItems(),
739
+ Math.max(1, parseInt(cfg.intervalCategories, 10) || 4),
740
+ normalizeBool(cfg.showFirstCategoryAd),
741
+ state.poolCategories,
742
+ state.usedCategories,
743
+ state.liveCategories
744
+ );
745
+ }
746
+ }
747
+
748
+ enforceNoAdjacentAds();
749
+
750
+ // Recycling: if pool is exhausted, retry a few times to allow old wrappers to scroll off-screen.
751
+ if (inserted === 0) {
752
+ if (state.poolWaitAttempts < 8) {
753
+ state.poolWaitAttempts++;
754
+ setTimeoutTracked(function () { scheduleRun('pool-wait'); }, 400);
755
+ }
326
756
  } else {
327
- setTimeout(check, 200);
757
+ // Reset pool wait attempts once we successfully insert something.
758
+ state.poolWaitAttempts = 0;
328
759
  }
329
- };
330
- check();
760
+
761
+ // If we hit max inserts, continue quickly.
762
+ if (inserted >= MAX_INSERTS_PER_RUN) {
763
+ setTimeoutTracked(function () { scheduleRun('continue'); }, 140);
764
+ }
765
+ }).catch(function () {});
331
766
  }
332
767
 
333
768
  function bind() {
334
769
  if (!$) return;
335
770
 
336
771
  $(window).off('.ezoicInfinite');
337
- $(window).on('action:ajaxify.start.ezoicInfinite', () => cleanup());
338
- $(window).on('action:ajaxify.end.ezoicInfinite', () => {
772
+
773
+ $(window).on('action:ajaxify.start.ezoicInfinite', function () {
774
+ cleanup();
775
+ });
776
+
777
+ $(window).on('action:ajaxify.end.ezoicInfinite', function () {
339
778
  state.pageKey = getPageKey();
340
779
  ensureObserver();
341
- state.canShowAds = true;
342
- scheduleRun();
780
+
781
+ // Delay gate to avoid racing NodeBB DOM swap vs Ezoic processing.
782
+ setTimeoutTracked(function () {
783
+ state.canShowAds = true;
784
+ waitForEzoicThenRun();
785
+ }, 300);
343
786
  });
344
787
 
345
- $(window).on('action:category.loaded.ezoicInfinite', () => {
788
+ // Infinite-scroll and "loaded" events.
789
+ $(window).on('action:category.loaded.ezoicInfinite', function () {
346
790
  ensureObserver();
347
791
  waitForContentThenRun();
348
792
  });
349
793
 
350
- $(window).on('action:topics.loaded.ezoicInfinite', () => {
794
+ $(window).on('action:topics.loaded.ezoicInfinite', function () {
351
795
  ensureObserver();
352
796
  waitForContentThenRun();
353
797
  });
354
- }
355
798
 
356
- function init() {
357
- state.pageKey = getPageKey();
358
- state.canShowAds = true;
359
- bind();
360
- ensureObserver();
361
- waitForContentThenRun();
799
+ $(window).on('action:topic.loaded.ezoicInfinite', function () {
800
+ ensureObserver();
801
+ waitForContentThenRun();
802
+ });
803
+
804
+ $(window).on('action:posts.loaded.ezoicInfinite', function () {
805
+ ensureObserver();
806
+ waitForContentThenRun();
807
+ });
362
808
  }
363
809
 
364
- if ($ && $(document).ready) {
365
- $(document).ready(init);
366
- } else if (document.readyState === 'loading') {
367
- document.addEventListener('DOMContentLoaded', init);
368
- } else {
369
- init();
810
+ function bindScroll() {
811
+ if (state.lastScrollRun > 0) return;
812
+ state.lastScrollRun = Date.now();
813
+
814
+ var ticking = false;
815
+ window.addEventListener('scroll', function () {
816
+ if (ticking) return;
817
+ ticking = true;
818
+
819
+ window.requestAnimationFrame(function () {
820
+ ticking = false;
821
+
822
+ enforceNoAdjacentAds();
823
+
824
+ // Debounce scheduleRun (max once every 2s on scroll).
825
+ var now = Date.now();
826
+ if (!state.lastScrollRun || (now - state.lastScrollRun > 2000)) {
827
+ state.lastScrollRun = now;
828
+ scheduleRun('scroll');
829
+ }
830
+ });
831
+ }, { passive: true });
370
832
  }
833
+
834
+ // Boot.
835
+ cleanup();
836
+ bind();
837
+ bindScroll();
838
+ ensureObserver();
839
+
840
+ state.pageKey = getPageKey();
841
+
842
+ // Direct page load: allow insertion after initial tick (no ajaxify.end).
843
+ setTimeoutTracked(function () {
844
+ state.canShowAds = true;
845
+ waitForEzoicThenRun();
846
+ }, 0);
371
847
  })();
package/public/style.css CHANGED
@@ -1,11 +1,21 @@
1
+ /* Keep Ezoic wrappers “CLS-safe” and remove the extra vertical spacing Ezoic often injects */
1
2
  .ezoic-ad {
3
+ display: block;
4
+ width: 100%;
5
+ margin: 0 !important;
2
6
  padding: 0 !important;
7
+ overflow: hidden;
8
+ }
9
+
10
+ .ezoic-ad > [id^="ezoic-pub-ad-placeholder-"] {
3
11
  margin: 0 !important;
4
- min-height: 0 !important;
5
- min-width: 0 !important;
12
+ padding: 0 !important;
13
+ min-height: 1px; /* keeps placeholder measurable for IO */
6
14
  }
7
15
 
8
- .ezoic-ad * {
9
- min-height: 0 !important;
10
- min-width: 0 !important;
16
+ /* Ezoic sometimes wraps in extra spans/divs with margins */
17
+ .ezoic-ad span.ezoic-ad,
18
+ .ezoic-ad .ezoic-ad {
19
+ margin: 0 !important;
20
+ padding: 0 !important;
11
21
  }