nodebb-plugin-ezoic-infinite 1.5.7 → 1.5.9

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.5.7",
3
+ "version": "1.5.9",
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,367 @@
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
- };
1
+ 'use strict';
31
2
 
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
- }
3
+ /**
4
+ * Ezoic Infinite Ads for NodeBB 4.x
5
+ * - Event-driven only (ajaxify hooks + MutationObserver + IntersectionObserver)
6
+ * - Hard gate on server config (respects excluded groups BEFORE any injection)
7
+ * - Placeholder Registry: keeps all placeholder elements alive in a hidden pool so ez-standalone never complains
8
+ * - Safe showAds wrapper (array, varargs, single) + tokenized execution across ajaxify navigations
9
+ */
45
10
 
46
- function normalizeBool(v) {
47
- return v === true || v === 'true' || v === 1 || v === '1' || v === 'on' || v === 'yes';
11
+ (function () {
12
+ const CFG_URL = '/api/plugins/ezoic-infinite/config';
13
+
14
+ // -------------------------
15
+ // Utilities
16
+ // -------------------------
17
+ const now = () => (typeof performance !== 'undefined' && performance.now) ? performance.now() : Date.now();
18
+ const clamp = (n, a, b) => Math.max(a, Math.min(b, n));
19
+
20
+ function on(ev, fn) { document.addEventListener(ev, fn); }
21
+ function once(ev, fn) {
22
+ const h = (e) => { document.removeEventListener(ev, h); fn(e); };
23
+ document.addEventListener(ev, h);
48
24
  }
49
25
 
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);
26
+ // -------------------------
27
+ // Global state / navigation token
28
+ // -------------------------
29
+ let navToken = 0; // increment on ajaxify.start (page is about to be replaced)
30
+ let rafScheduled = false;
31
+
32
+ // resolved config (or null if failed)
33
+ let cfgPromise = null;
34
+ let cfg = null;
35
+
36
+ // -------------------------
37
+ // Placeholder Registry (keeps IDs alive)
38
+ // -------------------------
39
+ const Pool = {
40
+ el: null,
41
+ ensurePool() {
42
+ if (this.el && document.body.contains(this.el)) return this.el;
43
+ const d = document.createElement('div');
44
+ d.id = 'ezoic-placeholder-pool';
45
+ d.style.display = 'none';
46
+ d.setAttribute('aria-hidden', 'true');
47
+ document.body.appendChild(d);
48
+ this.el = d;
49
+ return d;
50
+ },
51
+ ensurePlaceholder(id) {
52
+ if (!id) return null;
53
+ let ph = document.getElementById(id);
54
+ if (ph) return ph;
55
+ const pool = this.ensurePool();
56
+ ph = document.createElement('div');
57
+ ph.id = id;
58
+ pool.appendChild(ph);
59
+ return ph;
60
+ },
61
+ returnToPool(id) {
62
+ const ph = document.getElementById(id);
63
+ if (!ph) return;
64
+ const pool = this.ensurePool();
65
+ if (ph.parentElement !== pool) {
66
+ pool.appendChild(ph);
58
67
  }
59
68
  }
60
- return out;
61
- }
69
+ };
62
70
 
63
- function parsePool(raw) {
64
- if (!raw) return [];
65
- return uniqInts(String(raw).split(/\r?\n/).map(s => s.trim()).filter(Boolean));
71
+ // -------------------------
72
+ // Ezoic showAds safe wrapper (installed even if defined later)
73
+ // -------------------------
74
+ function normalizeIds(argsLike) {
75
+ const args = Array.from(argsLike);
76
+ if (!args.length) return [];
77
+ // showAds([id1,id2]) or showAds([[...]]) edge
78
+ if (args.length === 1 && Array.isArray(args[0])) return args[0].flat().map(String);
79
+ return args.flat().map(String);
66
80
  }
67
81
 
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
- }
82
+ function wrapShowAds(original) {
83
+ if (!original || original.__nodebbSafeWrapped) return original;
84
+
85
+ const wrapped = function (...args) {
86
+ const ids = normalizeIds(args);
87
+ if (!ids.length) return;
88
+
89
+ for (const id of ids) {
90
+ // Ensure placeholder exists somewhere (pool), so ez-standalone won't log "does not exist"
91
+ Pool.ensurePlaceholder(id);
92
+
93
+ const el = document.getElementById(id);
94
+ if (el && document.body.contains(el)) {
95
+ try {
96
+ // Call one-by-one to avoid batch logging on missing nodes
97
+ original.call(window.ezstandalone, id);
98
+ } catch (e) {
99
+ // swallow: Ezoic can throw if called during transitions
100
+ }
101
+ }
102
+ }
103
+ };
79
104
 
80
- function getTopicItems() {
81
- return Array.from(document.querySelectorAll(SELECTORS.topicItem));
105
+ wrapped.__nodebbSafeWrapped = true;
106
+ return wrapped;
82
107
  }
83
108
 
84
- function getCategoryItems() {
85
- return Array.from(document.querySelectorAll(SELECTORS.categoryItem));
86
- }
109
+ function installShowAdsHook() {
110
+ // Minimal + Ezoic-safe:
111
+ // - ensure queues exist (prevents `_ezaq` / `cmd` undefined)
112
+ // - wrap showAds when it exists
113
+ // - ALSO queue a wrap inside ezstandalone.cmd for when Ezoic initializes later
114
+ window._ezaq = window._ezaq || [];
115
+ window.ezstandalone = window.ezstandalone || {};
116
+ window.ezstandalone.cmd = window.ezstandalone.cmd || [];
117
+
118
+ if (window.ezstandalone.showAds) {
119
+ window.ezstandalone.showAds = wrapShowAds(window.ezstandalone.showAds);
120
+ }
87
121
 
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;
122
+ // When standalone finishes loading, it will run cmd queue — wrap then too.
123
+ window.ezstandalone.cmd.push(function () {
124
+ if (window.ezstandalone && window.ezstandalone.showAds) {
125
+ window.ezstandalone.showAds = wrapShowAds(window.ezstandalone.showAds);
126
+ }
97
127
  });
98
128
  }
99
129
 
100
- function isPlaceholderPresent(id) {
101
- const el = document.getElementById(`${PLACEHOLDER_PREFIX}${id}`);
102
- return !!(el && el.isConnected);
103
- }
104
-
105
- function schedule(fn) {
106
- if (state.scheduled) return;
107
- state.scheduled = true;
108
- requestAnimationFrame(() => {
109
- state.scheduled = false;
130
+ function ezCmd(fn) {
131
+ // Tokenize so queued callbacks don't run after navigation
132
+ const tokenAtSchedule = navToken;
133
+ window.ezstandalone = window.ezstandalone || {};
134
+ window.ezstandalone.cmd = window.ezstandalone.cmd || [];
135
+ window.ezstandalone.cmd.push(function () {
136
+ if (tokenAtSchedule !== navToken) return;
110
137
  try { fn(); } catch (e) {}
111
138
  });
112
139
  }
113
140
 
114
- // ----------------------------
115
- // Ezoic showAds patch (handles arrays, varargs, and filters absent placeholders)
116
- // ----------------------------
117
- function patchShowAds() {
118
- try {
119
- window.ezstandalone = window.ezstandalone || {};
120
- 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
-
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
- }
165
-
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) {}
141
+ // -------------------------
142
+ // Config (hard gate)
143
+ // -------------------------
144
+ function prefetchConfig() {
145
+ if (cfgPromise) return cfgPromise;
146
+
147
+ cfgPromise = fetch(CFG_URL, { credentials: 'same-origin' })
148
+ .then(r => r.ok ? r.json() : Promise.reject(new Error('config http ' + r.status)))
149
+ .then(data => {
150
+ cfg = data;
151
+ // Pre-create placeholders in pool so Ezoic never complains even before first injection
152
+ const ids = parsePlaceholderIds((cfg && cfg.placeholderIds) || '');
153
+ ids.forEach(id => Pool.ensurePlaceholder(id));
154
+ return cfg;
155
+ })
156
+ .catch(() => {
157
+ cfg = null;
158
+ return null;
175
159
  });
176
- } catch (e) {}
177
- }
178
160
 
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
- }
194
- });
195
- }
196
-
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
- }
212
-
213
- const ph = document.createElement('div');
214
- ph.id = `${PLACEHOLDER_PREFIX}${id}`;
215
- wrap.appendChild(ph);
216
- return wrap;
161
+ return cfgPromise;
217
162
  }
218
163
 
219
- function findWrap(kindClass, afterPos) {
220
- return document.querySelector(`.${WRAP_CLASS}.${kindClass}[data-ezoic-after="${afterPos}"]`);
164
+ function parsePlaceholderIds(s) {
165
+ return String(s || '')
166
+ .split(',')
167
+ .map(x => x.trim())
168
+ .filter(Boolean);
221
169
  }
222
170
 
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);
171
+ // -------------------------
172
+ // Ad insertion logic
173
+ // -------------------------
174
+ const Ad = {
175
+ // throttle showAds per placeholder id
176
+ lastShowAt: new Map(),
177
+ minShowIntervalMs: 1500,
232
178
 
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
- }
179
+ // Observers
180
+ io: null,
181
+ mo: null,
237
182
 
238
- target.insertAdjacentElement('afterend', wrap);
239
- return wrap;
240
- }
183
+ // Bookkeeping
184
+ insertedKeys: new Set(), // avoid duplicate injection on re-renders
185
+
186
+ initObservers() {
187
+ if (!this.io) {
188
+ this.io = new IntersectionObserver((entries) => {
189
+ for (const e of entries) {
190
+ if (!e.isIntersecting) continue;
191
+ const id = e.target && e.target.getAttribute('data-ezoic-id');
192
+ if (id) this.requestShow(id);
193
+ }
194
+ }, { root: null, rootMargin: '1200px 0px', threshold: 0.01 });
195
+ }
241
196
 
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) {}
197
+ if (!this.mo) {
198
+ this.mo = new MutationObserver(() => this.scheduleScan());
199
+ const root = document.querySelector('#content, #panel, main, body');
200
+ if (root) this.mo.observe(root, { childList: true, subtree: true });
256
201
  }
257
- }, { root: null, rootMargin: '1200px 0px', threshold: 0 });
258
- }
202
+ },
203
+
204
+ disconnectObservers() {
205
+ try { this.io && this.io.disconnect(); } catch (e) {}
206
+ try { this.mo && this.mo.disconnect(); } catch (e) {}
207
+ this.io = null;
208
+ this.mo = null;
209
+ },
210
+
211
+ scheduleScan() {
212
+ if (rafScheduled) return;
213
+ rafScheduled = true;
214
+ requestAnimationFrame(() => {
215
+ rafScheduled = false;
216
+ this.scanAndInject();
217
+ });
218
+ },
259
219
 
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
- }
220
+ async scanAndInject() {
221
+ const c = await prefetchConfig();
222
+ if (!c) return;
267
223
 
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
- }
224
+ if (c.excluded) return; // HARD stop: never inject for excluded users
273
225
 
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
- })();
226
+ installShowAdsHook();
227
+ this.initObservers();
294
228
 
295
- return state.cfgPromise;
296
- }
229
+ if (c.enableBetweenAds) this.injectBetweenTopics(c);
230
+ // Extend: categories ads if you use them
231
+ },
297
232
 
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
- }
233
+ // NodeBB topic list injection (between li rows)
234
+ injectBetweenTopics(c) {
235
+ const container =
236
+ document.querySelector('.category .topic-list') ||
237
+ document.querySelector('.topics .topic-list') ||
238
+ document.querySelector('.topic-list');
310
239
 
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
- }
240
+ if (!container) return;
317
241
 
318
- function isExcludedClientSide(cfg) {
319
- // Prefer server decision if present
320
- if (cfg && cfg.excluded === true) return true;
242
+ const rows = Array.from(container.querySelectorAll('li, .topic-row, .topic-item'));
243
+ if (!rows.length) return;
321
244
 
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;
245
+ const ids = parsePlaceholderIds(c.placeholderIds);
246
+ if (!ids.length) return;
327
247
 
328
- const set = new Set(userGroups);
329
- return excludedGroups.some(g => set.has(g));
330
- }
248
+ const interval = clamp(parseInt(c.intervalPosts, 10) || 6, 1, 50);
331
249
 
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
- }
250
+ // HERO: early, above-the-fold (first eligible insertion point)
251
+ if (c.showFirstTopicAd) {
252
+ this.insertAdAfterRow(rows[0], ids[0], 'hero-topic-0');
253
+ }
342
254
 
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;
255
+ // Between rows
256
+ let idIndex = 0;
257
+ for (let i = interval; i < rows.length; i += interval) {
258
+ idIndex = (idIndex + 1) % ids.length;
259
+ this.insertAdAfterRow(rows[i - 1], ids[idIndex], `between-topic-${i}`);
360
260
  }
261
+ },
361
262
 
362
- insertedIds.push(id);
263
+ insertAdAfterRow(rowEl, placeholderId, key) {
264
+ if (!rowEl || !placeholderId || !key) return;
265
+ if (this.insertedKeys.has(key)) return;
363
266
 
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);
267
+ // If already present nearby, skip
268
+ if (rowEl.nextElementSibling && rowEl.nextElementSibling.classList && rowEl.nextElementSibling.classList.contains('ezoic-ad-wrapper')) {
269
+ this.insertedKeys.add(key);
270
+ return;
371
271
  }
372
- }
373
- return insertedIds;
374
- }
375
272
 
376
- function removeAllAds() {
377
- try { document.querySelectorAll(`.${WRAP_CLASS}`).forEach(n => n.remove()); } catch (e) {}
378
- }
273
+ const isLi = rowEl.tagName && rowEl.tagName.toLowerCase() === 'li';
274
+ const wrapper = document.createElement(isLi ? 'li' : 'div');
275
+ wrapper.className = 'ezoic-ad-wrapper';
276
+ wrapper.setAttribute('role', 'presentation');
277
+ wrapper.setAttribute('data-ezoic-id', placeholderId);
379
278
 
380
- async function runCore() {
381
- state.pageKey = getPageKey();
279
+ // Theme compatibility: if list uses list-group-item, keep it to avoid layout/JS breaks
280
+ if (isLi && rowEl.classList.contains('list-group-item')) {
281
+ wrapper.classList.add('list-group-item');
282
+ }
382
283
 
383
- patchShowAds();
284
+ // Ensure placeholder exists (in pool) then move it into wrapper
285
+ const ph = Pool.ensurePlaceholder(placeholderId);
286
+ // If placeholder is currently rendered elsewhere, move it (Ezoic uses id lookup)
287
+ try { wrapper.appendChild(ph); } catch (e) {}
384
288
 
385
- const cfg = await fetchConfig();
386
- if (!cfg) return;
289
+ // Insert into DOM
290
+ rowEl.insertAdjacentElement('afterend', wrapper);
291
+ this.insertedKeys.add(key);
387
292
 
388
- // If excluded: ensure we remove any previously injected wrappers
389
- if (isExcludedClientSide(cfg)) {
390
- removeAllAds();
391
- return;
392
- }
393
-
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
- }
293
+ // Observe for preloading and request a show
294
+ if (this.io) this.io.observe(wrapper);
407
295
 
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
- }
296
+ // If above fold, request immediately (no timeout)
297
+ const rect = wrapper.getBoundingClientRect();
298
+ if (rect.top < (window.innerHeight * 1.5)) {
299
+ this.requestShow(placeholderId);
300
+ }
301
+ },
419
302
 
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
- }
303
+ requestShow(placeholderId) {
304
+ if (!placeholderId) return;
431
305
 
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();
306
+ const last = this.lastShowAt.get(placeholderId) || 0;
307
+ const t = now();
308
+ if ((t - last) < this.minShowIntervalMs) return;
309
+ this.lastShowAt.set(placeholderId, t);
438
310
 
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;
311
+ // Ensure placeholder exists in DOM (pool or wrapper)
312
+ Pool.ensurePlaceholder(placeholderId);
444
313
 
445
- removeAllAds();
446
- }
314
+ // Use ez cmd queue if available
315
+ const doShow = () => {
316
+ if (!window.ezstandalone || !window.ezstandalone.showAds) return;
317
+ // showAds is wrapped; calling with one id is safest
318
+ window.ezstandalone.showAds(placeholderId);
319
+ };
447
320
 
448
- // ----------------------------
449
- // Bind to NodeBB 4.x ajaxify events
450
- // ----------------------------
451
- function bind() {
452
- if (!$) return;
321
+ if (window.ezstandalone && Array.isArray(window.ezstandalone.cmd)) {
322
+ ezCmd(doShow);
323
+ } else {
324
+ doShow();
325
+ }
326
+ },
327
+
328
+ // On navigation, return all placeholders to pool so they still exist
329
+ reclaimPlaceholders() {
330
+ const wrappers = document.querySelectorAll('.ezoic-ad-wrapper[data-ezoic-id]');
331
+ wrappers.forEach(w => {
332
+ const id = w.getAttribute('data-ezoic-id');
333
+ if (id) Pool.returnToPool(id);
334
+ });
335
+ // Let NodeBB wipe DOM; we do not aggressively remove nodes here.
336
+ this.insertedKeys.clear();
337
+ this.lastShowAt.clear();
338
+ }
339
+ };
453
340
 
454
- $(window).off('.ezoicInfinite');
341
+ // -------------------------
342
+ // NodeBB hooks
343
+ // -------------------------
344
+ function onAjaxifyStart() {
345
+ navToken++;
346
+ Ad.disconnectObservers();
347
+ Ad.reclaimPlaceholders();
348
+ }
455
349
 
456
- $(window).on('action:ajaxify.start.ezoicInfinite', () => cleanupForNav());
350
+ function onAjaxifyEnd() {
351
+ // Kick scan for new page
352
+ prefetchConfig();
353
+ Ad.scheduleScan();
354
+ }
457
355
 
458
- $(window).on('action:ajaxify.end.ezoicInfinite', () => {
459
- state.pageKey = getPageKey();
460
- ensureMO();
461
- schedule(runCore);
462
- });
356
+ // NodeBB exposes ajaxify events on document
357
+ on('ajaxify.start', onAjaxifyStart);
358
+ on('ajaxify.end', onAjaxifyEnd);
463
359
 
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
- }
360
+ // First load
361
+ once('DOMContentLoaded', () => {
362
+ installShowAdsHook();
363
+ prefetchConfig();
364
+ Ad.scheduleScan();
365
+ });
470
366
 
471
- // Boot
472
- cleanupForNav();
473
- bind();
474
- ensureMO();
475
- state.pageKey = getPageKey();
476
- schedule(runCore);
477
- })();
367
+ })();
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;}