nodebb-plugin-ezoic-infinite 1.4.99 → 1.5.1

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