nodebb-plugin-ezoic-infinite 1.4.99 → 1.5.0

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
@@ -107,7 +107,7 @@ plugin.init = async ({ router, middleware }) => {
107
107
 
108
108
  router.get('/api/plugins/ezoic-infinite/config', async (req, res) => {
109
109
  const settings = await getSettings();
110
- const excluded = await isUserExcluded(req.uid, settings.excludedGroups);
110
+ const excluded = await isUserExcluded((req.uid || (req.user && req.user.uid)), settings.excludedGroups);
111
111
 
112
112
  res.json({
113
113
  excluded,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nodebb-plugin-ezoic-infinite",
3
- "version": "1.4.99",
3
+ "version": "1.5.0",
4
4
  "description": "Production-ready Ezoic infinite ads integration for NodeBB 4.x",
5
5
  "main": "library.js",
6
6
  "license": "MIT",
package/public/client.js CHANGED
@@ -1,477 +1,375 @@
1
- (function () {
2
- 'use strict';
3
-
4
- const $ = (typeof window.jQuery === 'function') ? window.jQuery : null;
5
-
6
- const WRAP_CLASS = 'ezoic-ad';
7
- const PLACEHOLDER_PREFIX = 'ezoic-pub-ad-placeholder-';
8
-
9
- const SELECTORS = {
10
- topicItem: 'li[component="category/topic"]',
11
- categoryItem: 'li[component="categories/category"]',
12
- postItem: '[component="post"][data-pid]',
13
- postContent: '[component="post/content"]',
14
- };
15
-
16
- // ----------------------------
17
- // State
18
- // ----------------------------
19
- const state = {
20
- pageKey: null,
21
- pageToken: 0,
22
- cfgPromise: null,
23
- cfg: null,
24
- // throttle per id
25
- lastShowAt: new Map(),
26
- // observed placeholders -> ids
27
- io: null,
28
- mo: null,
29
- scheduled: false,
30
- };
31
-
32
- // ----------------------------
33
- // Small utils
34
- // ----------------------------
35
- function getPageKey() {
36
- try {
37
- const ax = window.ajaxify;
38
- if (ax && ax.data) {
39
- if (ax.data.tid) return `topic:${ax.data.tid}`;
40
- if (ax.data.cid) return `cid:${ax.data.cid}:${window.location.pathname}`;
41
- }
42
- } catch (e) {}
43
- return window.location.pathname || '';
44
- }
1
+ 'use strict';
2
+ /**
3
+ * Ezoic Infinite Ads for NodeBB 4.x
4
+ * - Event-driven only (ajaxify hooks + MutationObserver + IntersectionObserver)
5
+ * - Hard gate on server config (respects excluded groups BEFORE any injection)
6
+ * - Placeholder Registry: keeps all placeholder elements alive in a hidden pool so ez-standalone never complains
7
+ * - Safe showAds wrapper (array, varargs, single) + tokenized execution across ajaxify navigations
8
+ */
45
9
 
46
- function normalizeBool(v) {
47
- return v === true || v === 'true' || v === 1 || v === '1' || v === 'on' || v === 'yes';
10
+ (function () {
11
+ const CFG_URL = '/api/plugins/ezoic-infinite/config';
12
+
13
+ // -------------------------
14
+ // Utilities
15
+ // -------------------------
16
+ const now = () => (typeof performance !== 'undefined' && performance.now) ? performance.now() : Date.now();
17
+ const clamp = (n, a, b) => Math.max(a, Math.min(b, n));
18
+
19
+ function on(ev, fn) { document.addEventListener(ev, fn); }
20
+ function once(ev, fn) {
21
+ const h = (e) => { document.removeEventListener(ev, h); fn(e); };
22
+ document.addEventListener(ev, h);
48
23
  }
49
24
 
50
- function uniqInts(lines) {
51
- const out = [];
52
- const seen = new Set();
53
- for (const v of lines) {
54
- const n = parseInt(v, 10);
55
- if (Number.isFinite(n) && n > 0 && !seen.has(n)) {
56
- seen.add(n);
57
- out.push(n);
25
+ // -------------------------
26
+ // Global state / navigation token
27
+ // -------------------------
28
+ let navToken = 0; // increment on ajaxify.start (page is about to be replaced)
29
+ let rafScheduled = false;
30
+
31
+ // resolved config (or null if failed)
32
+ let cfgPromise = null;
33
+ let cfg = null;
34
+
35
+ // -------------------------
36
+ // Placeholder Registry (keeps IDs alive)
37
+ // -------------------------
38
+ const Pool = {
39
+ el: null,
40
+ ensurePool() {
41
+ if (this.el && document.body.contains(this.el)) return this.el;
42
+ const d = document.createElement('div');
43
+ d.id = 'ezoic-placeholder-pool';
44
+ d.style.display = 'none';
45
+ d.setAttribute('aria-hidden', 'true');
46
+ document.body.appendChild(d);
47
+ this.el = d;
48
+ return d;
49
+ },
50
+ ensurePlaceholder(id) {
51
+ if (!id) return null;
52
+ let ph = document.getElementById(id);
53
+ if (ph) return ph;
54
+ const pool = this.ensurePool();
55
+ ph = document.createElement('div');
56
+ ph.id = id;
57
+ pool.appendChild(ph);
58
+ return ph;
59
+ },
60
+ returnToPool(id) {
61
+ const ph = document.getElementById(id);
62
+ if (!ph) return;
63
+ const pool = this.ensurePool();
64
+ if (ph.parentElement !== pool) {
65
+ pool.appendChild(ph);
58
66
  }
59
67
  }
60
- return out;
61
- }
62
-
63
- function parsePool(raw) {
64
- if (!raw) return [];
65
- return uniqInts(String(raw).split(/\r?\n/).map(s => s.trim()).filter(Boolean));
66
- }
67
-
68
- function getKind() {
69
- const p = window.location.pathname || '';
70
- if (/^\/topic\//.test(p)) return 'topic';
71
- if (/^\/category\//.test(p)) return 'categoryTopics';
72
- if (p === '/' || /^\/categories/.test(p)) return 'categories';
73
- // fallback by DOM
74
- if (document.querySelector(SELECTORS.categoryItem)) return 'categories';
75
- if (document.querySelector(SELECTORS.postItem)) return 'topic';
76
- if (document.querySelector(SELECTORS.topicItem)) return 'categoryTopics';
77
- return 'other';
78
- }
79
-
80
- function getTopicItems() {
81
- return Array.from(document.querySelectorAll(SELECTORS.topicItem));
82
- }
68
+ };
83
69
 
84
- function getCategoryItems() {
85
- return Array.from(document.querySelectorAll(SELECTORS.categoryItem));
70
+ // -------------------------
71
+ // Ezoic showAds safe wrapper (installed even if defined later)
72
+ // -------------------------
73
+ function normalizeIds(argsLike) {
74
+ const args = Array.from(argsLike);
75
+ if (!args.length) return [];
76
+ // showAds([id1,id2]) or showAds([[...]]) edge
77
+ if (args.length === 1 && Array.isArray(args[0])) return args[0].flat().map(String);
78
+ return args.flat().map(String);
86
79
  }
87
80
 
88
- function getPostContainers() {
89
- const nodes = Array.from(document.querySelectorAll(SELECTORS.postItem));
90
- return nodes.filter((el) => {
91
- if (!el || !el.isConnected) return false;
92
- if (!el.querySelector(SELECTORS.postContent)) return false;
93
- const parentPost = el.parentElement && el.parentElement.closest(SELECTORS.postItem);
94
- if (parentPost && parentPost !== el) return false;
95
- if (el.getAttribute('component') === 'post/parent') return false;
96
- return true;
97
- });
98
- }
81
+ function wrapShowAds(original) {
82
+ if (!original || original.__nodebbSafeWrapped) return original;
83
+
84
+ const wrapped = function (...args) {
85
+ const ids = normalizeIds(args);
86
+ if (!ids.length) return;
87
+
88
+ for (const id of ids) {
89
+ // Ensure placeholder exists somewhere (pool), so ez-standalone won't log "does not exist"
90
+ Pool.ensurePlaceholder(id);
91
+
92
+ const el = document.getElementById(id);
93
+ if (el && document.body.contains(el)) {
94
+ try {
95
+ // Call one-by-one to avoid batch logging on missing nodes
96
+ original.call(window.ezstandalone, id);
97
+ } catch (e) {
98
+ // swallow: Ezoic can throw if called during transitions
99
+ }
100
+ }
101
+ }
102
+ };
99
103
 
100
- function isPlaceholderPresent(id) {
101
- const el = document.getElementById(`${PLACEHOLDER_PREFIX}${id}`);
102
- return !!(el && el.isConnected);
104
+ wrapped.__nodebbSafeWrapped = true;
105
+ return wrapped;
103
106
  }
104
107
 
105
- function schedule(fn) {
106
- if (state.scheduled) return;
107
- state.scheduled = true;
108
- requestAnimationFrame(() => {
109
- state.scheduled = false;
110
- try { fn(); } catch (e) {}
111
- });
112
- }
108
+ function installShowAdsHook() {
109
+ // If ezstandalone already exists, wrap now.
110
+ if (window.ezstandalone && window.ezstandalone.showAds) {
111
+ window.ezstandalone.showAds = wrapShowAds(window.ezstandalone.showAds);
112
+ }
113
113
 
114
- // ----------------------------
115
- // Ezoic showAds patch (handles arrays, varargs, and filters absent placeholders)
116
- // ----------------------------
117
- function patchShowAds() {
114
+ // Hook future assignment (no polling)
118
115
  try {
119
- window.ezstandalone = window.ezstandalone || {};
116
+ if (!window.ezstandalone) window.ezstandalone = {};
120
117
  const ez = window.ezstandalone;
121
- if (ez.__nodebbEzoicPatched) return;
122
-
123
- // If showAds isn't ready yet, patch when it appears via cmd (no polling)
124
- const apply = () => {
125
- try {
126
- if (!window.ezstandalone || typeof window.ezstandalone.showAds !== 'function') return;
127
- const ez2 = window.ezstandalone;
128
- if (ez2.__nodebbEzoicPatched) return;
129
-
130
- const orig = ez2.showAds;
131
- ez2.showAds = function () {
132
- // Normalize ids from:
133
- // - showAds([1,2])
134
- // - showAds(1,2,3)
135
- // - showAds(1)
136
- const ids = [];
137
- if (arguments.length === 1 && Array.isArray(arguments[0])) {
138
- for (const v of arguments[0]) ids.push(v);
139
- } else {
140
- for (let i = 0; i < arguments.length; i++) ids.push(arguments[i]);
141
- }
142
-
143
- const seen = new Set();
144
- for (const v of ids) {
145
- const id = parseInt(v, 10);
146
- if (!Number.isFinite(id) || id <= 0 || seen.has(id)) continue;
147
- if (!isPlaceholderPresent(id)) continue;
148
- seen.add(id);
149
- try { orig.call(ez2, id); } catch (e) {}
150
- }
151
- };
152
-
153
- ez2.__nodebbEzoicPatched = true;
154
- } catch (e) {}
155
- };
156
118
 
157
- apply();
158
- if (!window.ezstandalone.__nodebbEzoicPatchQueued) {
159
- window.ezstandalone.__nodebbEzoicPatchQueued = true;
160
- ez.cmd = ez.cmd || [];
161
- ez.cmd.push(apply);
162
- }
163
- } catch (e) {}
164
- }
119
+ // If already has a setter installed, do nothing
120
+ const desc = Object.getOwnPropertyDescriptor(ez, 'showAds');
121
+ if (desc && (desc.set || desc.get)) return;
165
122
 
166
- function safeCmd(token, fn) {
167
- try {
168
- window.ezstandalone = window.ezstandalone || {};
169
- window.ezstandalone.cmd = window.ezstandalone.cmd || [];
170
- window.ezstandalone.cmd.push(function () {
171
- // Drop stale work after ajaxify navigation
172
- if (token !== state.pageToken) return;
173
- if (getPageKey() !== state.pageKey) return;
174
- try { fn(); } catch (e) {}
123
+ let _showAds = ez.showAds;
124
+ Object.defineProperty(ez, 'showAds', {
125
+ configurable: true,
126
+ enumerable: true,
127
+ get() { return _showAds; },
128
+ set(v) { _showAds = wrapShowAds(v); }
175
129
  });
176
- } catch (e) {}
130
+
131
+ // Re-assign current value through setter
132
+ ez.showAds = _showAds;
133
+ } catch (e) {
134
+ // If defineProperty fails, best-effort wrap is still in place above.
135
+ }
177
136
  }
178
137
 
179
- function showAd(id) {
180
- if (!id) return;
181
- // throttle to avoid repeated calls during rerenders
182
- const now = Date.now();
183
- const last = state.lastShowAt.get(id) || 0;
184
- if (now - last < 1500) return;
185
- state.lastShowAt.set(id, now);
186
-
187
- const token = state.pageToken;
188
- safeCmd(token, () => {
189
- if (!isPlaceholderPresent(id)) return;
190
- patchShowAds();
191
- if (window.ezstandalone && typeof window.ezstandalone.showAds === 'function') {
192
- window.ezstandalone.showAds(id);
193
- }
138
+ function ezCmd(fn) {
139
+ // Tokenize so queued callbacks don't run after navigation
140
+ const tokenAtSchedule = navToken;
141
+ window.ezstandalone = window.ezstandalone || {};
142
+ window.ezstandalone.cmd = window.ezstandalone.cmd || [];
143
+ window.ezstandalone.cmd.push(function () {
144
+ if (tokenAtSchedule !== navToken) return;
145
+ try { fn(); } catch (e) {}
194
146
  });
195
147
  }
196
148
 
197
- // ----------------------------
198
- // DOM insertion (HTML-valid: if target is <li>, wrapper is <li>)
199
- // ----------------------------
200
- function buildWrap(id, kindClass, afterPos, liLike) {
201
- const wrap = document.createElement(liLike ? 'li' : 'div');
202
- wrap.className = `${WRAP_CLASS} ${kindClass}`;
203
- wrap.setAttribute('data-ezoic-after', String(afterPos));
204
- wrap.setAttribute('role', 'presentation');
205
- wrap.style.width = '100%';
206
-
207
- // Keep list styling if we're inside list-group
208
- if (liLike && !wrap.classList.contains('list-group-item')) {
209
- const targetIsListGroup = wrap.parentElement && wrap.parentElement.classList && wrap.parentElement.classList.contains('list-group');
210
- // can't detect parent yet; we'll keep it minimal
211
- }
149
+ // -------------------------
150
+ // Config (hard gate)
151
+ // -------------------------
152
+ function prefetchConfig() {
153
+ if (cfgPromise) return cfgPromise;
154
+
155
+ cfgPromise = fetch(CFG_URL, { credentials: 'same-origin' })
156
+ .then(r => r.ok ? r.json() : Promise.reject(new Error('config http ' + r.status)))
157
+ .then(data => {
158
+ cfg = data;
159
+ // Pre-create placeholders in pool so Ezoic never complains even before first injection
160
+ const ids = parsePlaceholderIds((cfg && cfg.placeholderIds) || '');
161
+ ids.forEach(id => Pool.ensurePlaceholder(id));
162
+ return cfg;
163
+ })
164
+ .catch(() => {
165
+ cfg = null;
166
+ return null;
167
+ });
212
168
 
213
- const ph = document.createElement('div');
214
- ph.id = `${PLACEHOLDER_PREFIX}${id}`;
215
- wrap.appendChild(ph);
216
- return wrap;
169
+ return cfgPromise;
217
170
  }
218
171
 
219
- function findWrap(kindClass, afterPos) {
220
- return document.querySelector(`.${WRAP_CLASS}.${kindClass}[data-ezoic-after="${afterPos}"]`);
172
+ function parsePlaceholderIds(s) {
173
+ return String(s || '')
174
+ .split(',')
175
+ .map(x => x.trim())
176
+ .filter(Boolean);
221
177
  }
222
178
 
223
- function insertAfter(target, id, kindClass, afterPos) {
224
- if (!target || !target.insertAdjacentElement) return null;
225
- if (findWrap(kindClass, afterPos)) return null;
226
-
227
- // avoid duplicates if already exists
228
- if (isPlaceholderPresent(id)) return null;
229
-
230
- const liLike = String(target.tagName).toUpperCase() === 'LI';
231
- const wrap = buildWrap(id, kindClass, afterPos, liLike);
179
+ // -------------------------
180
+ // Ad insertion logic
181
+ // -------------------------
182
+ const Ad = {
183
+ // throttle showAds per placeholder id
184
+ lastShowAt: new Map(),
185
+ minShowIntervalMs: 1500,
232
186
 
233
- // If list-group-item exists on target, mirror it to keep theme markup consistent
234
- if (liLike && target.classList && target.classList.contains('list-group-item')) {
235
- wrap.classList.add('list-group-item');
236
- }
187
+ // Observers
188
+ io: null,
189
+ mo: null,
237
190
 
238
- target.insertAdjacentElement('afterend', wrap);
239
- return wrap;
240
- }
191
+ // Bookkeeping
192
+ insertedKeys: new Set(), // avoid duplicate injection on re-renders
193
+
194
+ initObservers() {
195
+ if (!this.io) {
196
+ this.io = new IntersectionObserver((entries) => {
197
+ for (const e of entries) {
198
+ if (!e.isIntersecting) continue;
199
+ const id = e.target && e.target.getAttribute('data-ezoic-id');
200
+ if (id) this.requestShow(id);
201
+ }
202
+ }, { root: null, rootMargin: '1200px 0px', threshold: 0.01 });
203
+ }
241
204
 
242
- // ----------------------------
243
- // Observers: preload + rerun on NodeBB DOM changes
244
- // ----------------------------
245
- function ensureIO() {
246
- if (state.io) return;
247
- if (!('IntersectionObserver' in window)) return;
248
-
249
- state.io = new IntersectionObserver((entries) => {
250
- for (const e of entries) {
251
- if (!e.isIntersecting) continue;
252
- const ph = e.target;
253
- const id = parseInt(String(ph.id).replace(PLACEHOLDER_PREFIX, ''), 10);
254
- if (Number.isFinite(id)) showAd(id);
255
- try { state.io.unobserve(ph); } catch (err) {}
205
+ if (!this.mo) {
206
+ this.mo = new MutationObserver(() => this.scheduleScan());
207
+ const root = document.querySelector('#content, #panel, main, body');
208
+ if (root) this.mo.observe(root, { childList: true, subtree: true });
256
209
  }
257
- }, { root: null, rootMargin: '1200px 0px', threshold: 0 });
258
- }
210
+ },
211
+
212
+ disconnectObservers() {
213
+ try { this.io && this.io.disconnect(); } catch (e) {}
214
+ try { this.mo && this.mo.disconnect(); } catch (e) {}
215
+ this.io = null;
216
+ this.mo = null;
217
+ },
218
+
219
+ scheduleScan() {
220
+ if (rafScheduled) return;
221
+ rafScheduled = true;
222
+ requestAnimationFrame(() => {
223
+ rafScheduled = false;
224
+ this.scanAndInject();
225
+ });
226
+ },
259
227
 
260
- function observePlaceholder(id) {
261
- ensureIO();
262
- if (!state.io) return;
263
- const ph = document.getElementById(`${PLACEHOLDER_PREFIX}${id}`);
264
- if (!ph) return;
265
- try { state.io.observe(ph); } catch (e) {}
266
- }
228
+ async scanAndInject() {
229
+ const c = await prefetchConfig();
230
+ if (!c) return;
267
231
 
268
- function ensureMO() {
269
- if (state.mo) return;
270
- state.mo = new MutationObserver(() => schedule(runCore));
271
- try { state.mo.observe(document.body, { childList: true, subtree: true }); } catch (e) {}
272
- }
232
+ if (c.excluded) return; // HARD stop: never inject for excluded users
273
233
 
274
- // ----------------------------
275
- // Config / exclusion
276
- // ----------------------------
277
- async function fetchConfig() {
278
- if (state.cfg) return state.cfg;
279
- if (state.cfgPromise) return state.cfgPromise;
280
-
281
- state.cfgPromise = (async () => {
282
- try {
283
- const res = await fetch('/api/plugins/ezoic-infinite/config', { credentials: 'same-origin' });
284
- if (!res.ok) return null;
285
- const cfg = await res.json();
286
- state.cfg = cfg;
287
- return cfg;
288
- } catch (e) {
289
- return null;
290
- } finally {
291
- state.cfgPromise = null;
292
- }
293
- })();
234
+ installShowAdsHook();
235
+ this.initObservers();
294
236
 
295
- return state.cfgPromise;
296
- }
237
+ if (c.enableBetweenAds) this.injectBetweenTopics(c);
238
+ // Extend: categories ads if you use them
239
+ },
297
240
 
298
- function getUserGroupNamesFromAjaxify() {
299
- try {
300
- const ax = window.ajaxify;
301
- const u = ax && ax.data && (ax.data.user || ax.data.profile || null);
302
- if (!u) return [];
303
- // NodeBB varies by route/theme; handle multiple shapes
304
- const groupsA = u.groups || u.group_names || u.groupNames;
305
- if (Array.isArray(groupsA)) return groupsA.map(g => (g && g.name) ? g.name : String(g)).filter(Boolean);
306
- if (typeof groupsA === 'string') return groupsA.split(',').map(s => s.trim()).filter(Boolean);
307
- } catch (e) {}
308
- return [];
309
- }
241
+ // NodeBB topic list injection (between li rows)
242
+ injectBetweenTopics(c) {
243
+ const container =
244
+ document.querySelector('.category .topic-list') ||
245
+ document.querySelector('.topics .topic-list') ||
246
+ document.querySelector('.topic-list');
310
247
 
311
- function parseExcludedGroupsFromCfg(cfg) {
312
- const v = cfg && (cfg.excludedGroups || cfg.excludedGroupNames || cfg.excluded_groups);
313
- if (!v) return [];
314
- if (Array.isArray(v)) return v.map(x => (x && x.name) ? x.name : String(x)).filter(Boolean);
315
- return String(v).split(',').map(s => s.trim()).filter(Boolean);
316
- }
248
+ if (!container) return;
317
249
 
318
- function isExcludedClientSide(cfg) {
319
- // Prefer server decision if present
320
- if (cfg && cfg.excluded === true) return true;
250
+ const rows = Array.from(container.querySelectorAll('li, .topic-row, .topic-item'));
251
+ if (!rows.length) return;
321
252
 
322
- // Extra safety: if cfg contains excluded group names, cross-check client-side.
323
- const excludedGroups = parseExcludedGroupsFromCfg(cfg);
324
- if (!excludedGroups.length) return false;
325
- const userGroups = getUserGroupNamesFromAjaxify();
326
- if (!userGroups.length) return false;
253
+ const ids = parsePlaceholderIds(c.placeholderIds);
254
+ if (!ids.length) return;
327
255
 
328
- const set = new Set(userGroups);
329
- return excludedGroups.some(g => set.has(g));
330
- }
256
+ const interval = clamp(parseInt(c.intervalPosts, 10) || 6, 1, 50);
331
257
 
332
- // ----------------------------
333
- // Core injection
334
- // ----------------------------
335
- function computeTargets(count, interval, showFirst) {
336
- const out = [];
337
- if (count <= 0) return out;
338
- if (showFirst) out.push(1);
339
- for (let i = interval; i <= count; i += interval) out.push(i);
340
- return Array.from(new Set(out)).sort((a, b) => a - b);
341
- }
258
+ // HERO: early, above-the-fold (first eligible insertion point)
259
+ if (c.showFirstTopicAd) {
260
+ this.insertAdAfterRow(rows[0], ids[0], 'hero-topic-0');
261
+ }
342
262
 
343
- function injectBetween(kindClass, items, interval, showFirst, pool) {
344
- if (!items.length || !pool.length) return [];
345
-
346
- const targets = computeTargets(items.length, interval, showFirst);
347
- const insertedIds = [];
348
- for (const afterPos of targets) {
349
- const el = items[afterPos - 1];
350
- if (!el || !el.isConnected) continue;
351
- if (!pool.length) break;
352
- if (findWrap(kindClass, afterPos)) continue;
353
-
354
- const id = pool.shift();
355
- const wrap = insertAfter(el, id, kindClass, afterPos);
356
- if (!wrap) {
357
- // push back if couldn't insert
358
- pool.unshift(id);
359
- continue;
263
+ // Between rows
264
+ let idIndex = 0;
265
+ for (let i = interval; i < rows.length; i += interval) {
266
+ idIndex = (idIndex + 1) % ids.length;
267
+ this.insertAdAfterRow(rows[i - 1], ids[idIndex], `between-topic-${i}`);
360
268
  }
269
+ },
361
270
 
362
- insertedIds.push(id);
271
+ insertAdAfterRow(rowEl, placeholderId, key) {
272
+ if (!rowEl || !placeholderId || !key) return;
273
+ if (this.insertedKeys.has(key)) return;
363
274
 
364
- // Above-the-fold: immediate attempt
365
- const rect = wrap.getBoundingClientRect ? wrap.getBoundingClientRect() : null;
366
- const vh = window.innerHeight || 800;
367
- if (rect && rect.top < (vh * 1.5)) {
368
- showAd(id);
369
- } else {
370
- observePlaceholder(id);
275
+ // If already present nearby, skip
276
+ if (rowEl.nextElementSibling && rowEl.nextElementSibling.classList && rowEl.nextElementSibling.classList.contains('ezoic-ad-wrapper')) {
277
+ this.insertedKeys.add(key);
278
+ return;
371
279
  }
372
- }
373
- return insertedIds;
374
- }
375
-
376
- function removeAllAds() {
377
- try { document.querySelectorAll(`.${WRAP_CLASS}`).forEach(n => n.remove()); } catch (e) {}
378
- }
379
280
 
380
- async function runCore() {
381
- state.pageKey = getPageKey();
281
+ const isLi = rowEl.tagName && rowEl.tagName.toLowerCase() === 'li';
282
+ const wrapper = document.createElement(isLi ? 'li' : 'div');
283
+ wrapper.className = 'ezoic-ad-wrapper';
284
+ wrapper.setAttribute('role', 'presentation');
285
+ wrapper.setAttribute('data-ezoic-id', placeholderId);
382
286
 
383
- patchShowAds();
287
+ // Theme compatibility: if list uses list-group-item, keep it to avoid layout/JS breaks
288
+ if (isLi && rowEl.classList.contains('list-group-item')) {
289
+ wrapper.classList.add('list-group-item');
290
+ }
384
291
 
385
- const cfg = await fetchConfig();
386
- if (!cfg) return;
292
+ // Ensure placeholder exists (in pool) then move it into wrapper
293
+ const ph = Pool.ensurePlaceholder(placeholderId);
294
+ // If placeholder is currently rendered elsewhere, move it (Ezoic uses id lookup)
295
+ try { wrapper.appendChild(ph); } catch (e) {}
387
296
 
388
- // If excluded: ensure we remove any previously injected wrappers
389
- if (isExcludedClientSide(cfg)) {
390
- removeAllAds();
391
- return;
392
- }
297
+ // Insert into DOM
298
+ rowEl.insertAdjacentElement('afterend', wrapper);
299
+ this.insertedKeys.add(key);
393
300
 
394
- const kind = getKind();
395
-
396
- if (kind === 'topic' && normalizeBool(cfg.enableMessageAds)) {
397
- const pool = parsePool(cfg.messagePlaceholderIds);
398
- injectBetween(
399
- 'ezoic-ad-message',
400
- getPostContainers(),
401
- Math.max(1, parseInt(cfg.messageIntervalPosts, 10) || 3),
402
- normalizeBool(cfg.showFirstMessageAd),
403
- pool
404
- );
405
- return;
406
- }
301
+ // Observe for preloading and request a show
302
+ if (this.io) this.io.observe(wrapper);
407
303
 
408
- if (kind === 'categoryTopics' && normalizeBool(cfg.enableBetweenAds)) {
409
- const pool = parsePool(cfg.placeholderIds);
410
- injectBetween(
411
- 'ezoic-ad-between',
412
- getTopicItems(),
413
- Math.max(1, parseInt(cfg.intervalPosts, 10) || 6),
414
- normalizeBool(cfg.showFirstTopicAd),
415
- pool
416
- );
417
- return;
418
- }
304
+ // If above fold, request immediately (no timeout)
305
+ const rect = wrapper.getBoundingClientRect();
306
+ if (rect.top < (window.innerHeight * 1.5)) {
307
+ this.requestShow(placeholderId);
308
+ }
309
+ },
419
310
 
420
- if (kind === 'categories' && normalizeBool(cfg.enableCategoryAds)) {
421
- const pool = parsePool(cfg.categoryPlaceholderIds);
422
- injectBetween(
423
- 'ezoic-ad-categories',
424
- getCategoryItems(),
425
- Math.max(1, parseInt(cfg.intervalCategories, 10) || 4),
426
- normalizeBool(cfg.showFirstCategoryAd),
427
- pool
428
- );
429
- }
430
- }
311
+ requestShow(placeholderId) {
312
+ if (!placeholderId) return;
431
313
 
432
- function cleanupForNav() {
433
- // New token => any pending safeCmd work becomes stale
434
- state.pageToken += 1;
435
- state.cfg = null;
436
- state.cfgPromise = null;
437
- state.lastShowAt.clear();
314
+ const last = this.lastShowAt.get(placeholderId) || 0;
315
+ const t = now();
316
+ if ((t - last) < this.minShowIntervalMs) return;
317
+ this.lastShowAt.set(placeholderId, t);
438
318
 
439
- // Disconnect observers for old DOM
440
- try { if (state.io) state.io.disconnect(); } catch (e) {}
441
- state.io = null;
442
- try { if (state.mo) state.mo.disconnect(); } catch (e) {}
443
- state.mo = null;
319
+ // Ensure placeholder exists in DOM (pool or wrapper)
320
+ Pool.ensurePlaceholder(placeholderId);
444
321
 
445
- removeAllAds();
446
- }
322
+ // Use ez cmd queue if available
323
+ const doShow = () => {
324
+ if (!window.ezstandalone || !window.ezstandalone.showAds) return;
325
+ // showAds is wrapped; calling with one id is safest
326
+ window.ezstandalone.showAds(placeholderId);
327
+ };
447
328
 
448
- // ----------------------------
449
- // Bind to NodeBB 4.x ajaxify events
450
- // ----------------------------
451
- function bind() {
452
- if (!$) return;
329
+ if (window.ezstandalone && Array.isArray(window.ezstandalone.cmd)) {
330
+ ezCmd(doShow);
331
+ } else {
332
+ doShow();
333
+ }
334
+ },
335
+
336
+ // On navigation, return all placeholders to pool so they still exist
337
+ reclaimPlaceholders() {
338
+ const wrappers = document.querySelectorAll('.ezoic-ad-wrapper[data-ezoic-id]');
339
+ wrappers.forEach(w => {
340
+ const id = w.getAttribute('data-ezoic-id');
341
+ if (id) Pool.returnToPool(id);
342
+ });
343
+ // Let NodeBB wipe DOM; we do not aggressively remove nodes here.
344
+ this.insertedKeys.clear();
345
+ this.lastShowAt.clear();
346
+ }
347
+ };
453
348
 
454
- $(window).off('.ezoicInfinite');
349
+ // -------------------------
350
+ // NodeBB hooks
351
+ // -------------------------
352
+ function onAjaxifyStart() {
353
+ navToken++;
354
+ Ad.disconnectObservers();
355
+ Ad.reclaimPlaceholders();
356
+ }
455
357
 
456
- $(window).on('action:ajaxify.start.ezoicInfinite', () => cleanupForNav());
358
+ function onAjaxifyEnd() {
359
+ // Kick scan for new page
360
+ prefetchConfig();
361
+ Ad.scheduleScan();
362
+ }
457
363
 
458
- $(window).on('action:ajaxify.end.ezoicInfinite', () => {
459
- state.pageKey = getPageKey();
460
- ensureMO();
461
- schedule(runCore);
462
- });
364
+ // NodeBB exposes ajaxify events on document
365
+ on('ajaxify.start', onAjaxifyStart);
366
+ on('ajaxify.end', onAjaxifyEnd);
463
367
 
464
- // Infinite scroll hooks
465
- $(window).on('action:posts.loaded.ezoicInfinite', () => schedule(runCore));
466
- $(window).on('action:topics.loaded.ezoicInfinite', () => schedule(runCore));
467
- $(window).on('action:category.loaded.ezoicInfinite', () => schedule(runCore));
468
- $(window).on('action:topic.loaded.ezoicInfinite', () => schedule(runCore));
469
- }
368
+ // First load
369
+ once('DOMContentLoaded', () => {
370
+ installShowAdsHook();
371
+ prefetchConfig();
372
+ Ad.scheduleScan();
373
+ });
470
374
 
471
- // Boot
472
- cleanupForNav();
473
- bind();
474
- ensureMO();
475
- state.pageKey = getPageKey();
476
- schedule(runCore);
477
- })();
375
+ })();
package/public/style.css CHANGED
@@ -4,4 +4,6 @@ span.ezoic-ad,
4
4
  span[class*="ezoic"] {
5
5
  min-height: 0 !important;
6
6
  min-width: 0 !important;
7
- }
7
+ }
8
+
9
+ .ezoic-ad-wrapper{margin:0;padding:0;list-style:none;}