nodebb-plugin-ezoic-infinite 1.5.6 → 1.5.7

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 || (req.user && req.user.uid)), settings.excludedGroups);
110
+ const excluded = await isUserExcluded(req.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.6",
3
+ "version": "1.5.7",
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,386 +1,477 @@
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
- */
9
-
10
1
  (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);
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
+ }
45
+
46
+ function normalizeBool(v) {
47
+ return v === true || v === 'true' || v === 1 || v === '1' || v === 'on' || v === 'yes';
23
48
  }
24
49
 
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);
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);
66
58
  }
67
59
  }
68
- };
60
+ return out;
61
+ }
69
62
 
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);
63
+ function parsePool(raw) {
64
+ if (!raw) return [];
65
+ return uniqInts(String(raw).split(/\r?\n/).map(s => s.trim()).filter(Boolean));
79
66
  }
80
67
 
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
- };
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
+ }
103
79
 
104
- wrapped.__nodebbSafeWrapped = true;
105
- return wrapped;
80
+ function getTopicItems() {
81
+ return Array.from(document.querySelectorAll(SELECTORS.topicItem));
106
82
  }
107
83
 
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
- }
84
+ function getCategoryItems() {
85
+ return Array.from(document.querySelectorAll(SELECTORS.categoryItem));
86
+ }
113
87
 
114
- // Hook future assignment (no polling)
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
+ }
99
+
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;
110
+ try { fn(); } catch (e) {}
111
+ });
112
+ }
113
+
114
+ // ----------------------------
115
+ // Ezoic showAds patch (handles arrays, varargs, and filters absent placeholders)
116
+ // ----------------------------
117
+ function patchShowAds() {
115
118
  try {
116
- if (!window.ezstandalone) window.ezstandalone = {};
119
+ window.ezstandalone = window.ezstandalone || {};
117
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
+ };
118
156
 
119
- // If already has a setter installed, do nothing
120
- const desc = Object.getOwnPropertyDescriptor(ez, 'showAds');
121
- if (desc && (desc.set || desc.get)) return;
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
+ }
122
165
 
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); }
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) {}
129
175
  });
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
- }
176
+ } catch (e) {}
136
177
  }
137
178
 
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) {}
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
+ }
146
194
  });
147
195
  }
148
196
 
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
- });
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
+ }
168
212
 
169
- return cfgPromise;
213
+ const ph = document.createElement('div');
214
+ ph.id = `${PLACEHOLDER_PREFIX}${id}`;
215
+ wrap.appendChild(ph);
216
+ return wrap;
170
217
  }
171
218
 
172
- function parsePlaceholderIds(s) {
173
- return String(s || '')
174
- .split(',')
175
- .map(x => x.trim())
176
- .filter(Boolean);
219
+ function findWrap(kindClass, afterPos) {
220
+ return document.querySelector(`.${WRAP_CLASS}.${kindClass}[data-ezoic-after="${afterPos}"]`);
177
221
  }
178
222
 
179
- // -------------------------
180
- // Ad insertion logic
181
- // -------------------------
182
- const Ad = {
183
- // throttle showAds per placeholder id
184
- lastShowAt: new Map(),
185
- minShowIntervalMs: 1500,
223
+ function insertAfter(target, id, kindClass, afterPos) {
224
+ if (!target || !target.insertAdjacentElement) return null;
225
+ if (findWrap(kindClass, afterPos)) return null;
186
226
 
187
- // Observers
188
- io: null,
189
- mo: null,
227
+ // avoid duplicates if already exists
228
+ if (isPlaceholderPresent(id)) return null;
190
229
 
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
- }
230
+ const liLike = String(target.tagName).toUpperCase() === 'LI';
231
+ const wrap = buildWrap(id, kindClass, afterPos, liLike);
204
232
 
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 });
209
- }
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
- },
227
-
228
- async scanAndInject() {
229
- const c = await prefetchConfig();
230
- if (!c) return;
231
-
232
- if (c.excluded) return; // HARD stop: never inject for excluded users
233
-
234
- installShowAdsHook();
235
- this.initObservers();
236
-
237
- if (c.enableBetweenAds) this.injectBetweenTopics(c);
238
- // Extend: categories ads if you use them
239
- },
240
-
241
- // NodeBB topic list injection (between li rows)
242
- injectBetweenTopics(c) {
243
- // NodeBB 4.x (Harmony) uses: <ul component="category" class="topics-list ...">
244
- // Older themes may use .topic-list
245
- const container =
246
- document.querySelector('ul[component="category"].topics-list') ||
247
- document.querySelector('ul[component="category"].topic-list') ||
248
- document.querySelector('ul.topics-list[component="category"]') ||
249
- document.querySelector('ul.topics-list') ||
250
- document.querySelector('.category ul.topics-list') ||
251
- document.querySelector('.category .topic-list') ||
252
- document.querySelector('.topics .topic-list') ||
253
- document.querySelector('.topic-list');
254
-
255
- if (!container) return;
256
-
257
- let rows = Array.from(container.querySelectorAll(':scope > li')).filter(el => el && el.nodeType === 1);
258
- if (!rows.length) {
259
- // Fallback for older markups
260
- rows = Array.from(container.querySelectorAll('li, .topic-row, .topic-item')).filter(el => el && el.nodeType === 1);
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
+ }
237
+
238
+ target.insertAdjacentElement('afterend', wrap);
239
+ return wrap;
240
+ }
241
+
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) {}
261
256
  }
262
- if (!rows.length) return;
257
+ }, { root: null, rootMargin: '1200px 0px', threshold: 0 });
258
+ }
263
259
 
264
- const ids = parsePlaceholderIds(c.placeholderIds);
265
- if (!ids.length) return;
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
+ }
266
267
 
267
- const interval = clamp(parseInt(c.intervalPosts, 10) || 6, 1, 50);
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
+ }
268
273
 
269
- // HERO: early, above-the-fold (first eligible insertion point)
270
- if (c.showFirstTopicAd) {
271
- this.insertAdAfterRow(rows[0], ids[0], 'hero-topic-0');
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;
272
292
  }
293
+ })();
273
294
 
274
- // Between rows
275
- let idIndex = 0;
276
- for (let i = interval; i < rows.length; i += interval) {
277
- idIndex = (idIndex + 1) % ids.length;
278
- this.insertAdAfterRow(rows[i - 1], ids[idIndex], `between-topic-${i}`);
279
- }
280
- },
295
+ return state.cfgPromise;
296
+ }
281
297
 
282
- insertAdAfterRow(rowEl, placeholderId, key) {
283
- if (!rowEl || !placeholderId || !key) return;
284
- if (this.insertedKeys.has(key)) return;
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
+ }
285
310
 
286
- // If already present nearby, skip
287
- if (rowEl.nextElementSibling && rowEl.nextElementSibling.classList && rowEl.nextElementSibling.classList.contains('ezoic-ad-wrapper')) {
288
- this.insertedKeys.add(key);
289
- return;
290
- }
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
+ }
291
317
 
292
- const isLi = rowEl.tagName && rowEl.tagName.toLowerCase() === 'li';
293
- const wrapper = document.createElement(isLi ? 'li' : 'div');
294
- wrapper.className = 'ezoic-ad-wrapper';
295
- wrapper.setAttribute('role', 'presentation');
296
- wrapper.setAttribute('data-ezoic-id', placeholderId);
318
+ function isExcludedClientSide(cfg) {
319
+ // Prefer server decision if present
320
+ if (cfg && cfg.excluded === true) return true;
297
321
 
298
- // Theme compatibility: if list uses list-group-item, keep it to avoid layout/JS breaks
299
- if (isLi && rowEl.classList.contains('list-group-item')) {
300
- wrapper.classList.add('list-group-item');
301
- }
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;
302
327
 
303
- // Ensure placeholder exists (in pool) then move it into wrapper
304
- const ph = Pool.ensurePlaceholder(placeholderId);
305
- // If placeholder is currently rendered elsewhere, move it (Ezoic uses id lookup)
306
- try { wrapper.appendChild(ph); } catch (e) {}
328
+ const set = new Set(userGroups);
329
+ return excludedGroups.some(g => set.has(g));
330
+ }
331
+
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
+ }
307
342
 
308
- // Insert into DOM
309
- rowEl.insertAdjacentElement('afterend', wrapper);
310
- this.insertedKeys.add(key);
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;
360
+ }
311
361
 
312
- // Observe for preloading and request a show
313
- if (this.io) this.io.observe(wrapper);
362
+ insertedIds.push(id);
314
363
 
315
- // If above fold, request immediately (no timeout)
316
- const rect = wrapper.getBoundingClientRect();
317
- if (rect.top < (window.innerHeight * 1.5)) {
318
- this.requestShow(placeholderId);
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);
319
371
  }
320
- },
372
+ }
373
+ return insertedIds;
374
+ }
321
375
 
322
- requestShow(placeholderId) {
323
- if (!placeholderId) return;
376
+ function removeAllAds() {
377
+ try { document.querySelectorAll(`.${WRAP_CLASS}`).forEach(n => n.remove()); } catch (e) {}
378
+ }
324
379
 
325
- const last = this.lastShowAt.get(placeholderId) || 0;
326
- const t = now();
327
- if ((t - last) < this.minShowIntervalMs) return;
328
- this.lastShowAt.set(placeholderId, t);
380
+ async function runCore() {
381
+ state.pageKey = getPageKey();
329
382
 
330
- // Ensure placeholder exists in DOM (pool or wrapper)
331
- Pool.ensurePlaceholder(placeholderId);
383
+ patchShowAds();
332
384
 
333
- // Use ez cmd queue if available
334
- const doShow = () => {
335
- if (!window.ezstandalone || !window.ezstandalone.showAds) return;
336
- // showAds is wrapped; calling with one id is safest
337
- window.ezstandalone.showAds(placeholderId);
338
- };
385
+ const cfg = await fetchConfig();
386
+ if (!cfg) return;
339
387
 
340
- if (window.ezstandalone && Array.isArray(window.ezstandalone.cmd)) {
341
- ezCmd(doShow);
342
- } else {
343
- doShow();
344
- }
345
- },
346
-
347
- // On navigation, return all placeholders to pool so they still exist
348
- reclaimPlaceholders() {
349
- const wrappers = document.querySelectorAll('.ezoic-ad-wrapper[data-ezoic-id]');
350
- wrappers.forEach(w => {
351
- const id = w.getAttribute('data-ezoic-id');
352
- if (id) Pool.returnToPool(id);
353
- });
354
- // Let NodeBB wipe DOM; we do not aggressively remove nodes here.
355
- this.insertedKeys.clear();
356
- this.lastShowAt.clear();
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
+ }
407
+
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;
357
418
  }
358
- };
359
419
 
360
- // -------------------------
361
- // NodeBB hooks
362
- // -------------------------
363
- function onAjaxifyStart() {
364
- navToken++;
365
- Ad.disconnectObservers();
366
- Ad.reclaimPlaceholders();
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
+ }
367
430
  }
368
431
 
369
- function onAjaxifyEnd() {
370
- // Kick scan for new page
371
- prefetchConfig();
372
- Ad.scheduleScan();
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();
373
446
  }
374
447
 
375
- // NodeBB exposes ajaxify events on document
376
- on('ajaxify.start', onAjaxifyStart);
377
- on('ajaxify.end', onAjaxifyEnd);
448
+ // ----------------------------
449
+ // Bind to NodeBB 4.x ajaxify events
450
+ // ----------------------------
451
+ function bind() {
452
+ if (!$) return;
378
453
 
379
- // First load
380
- once('DOMContentLoaded', () => {
381
- installShowAdsHook();
382
- prefetchConfig();
383
- Ad.scheduleScan();
384
- });
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
+ }
385
470
 
386
- })();
471
+ // Boot
472
+ cleanupForNav();
473
+ bind();
474
+ ensureMO();
475
+ state.pageKey = getPageKey();
476
+ schedule(runCore);
477
+ })();
package/public/style.css CHANGED
@@ -4,6 +4,4 @@ span.ezoic-ad,
4
4
  span[class*="ezoic"] {
5
5
  min-height: 0 !important;
6
6
  min-width: 0 !important;
7
- }
8
-
9
- .ezoic-ad-wrapper{margin:0;padding:0;list-style:none;}
7
+ }