nodebb-plugin-ezoic-infinite 0.8.0 → 0.8.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nodebb-plugin-ezoic-infinite",
3
- "version": "0.8.0",
3
+ "version": "0.8.1",
4
4
  "description": "Ezoic ads with infinite scroll using a pool of placeholder IDs",
5
5
  "main": "library.js",
6
6
  "license": "MIT",
@@ -13,5 +13,8 @@
13
13
  ],
14
14
  "engines": {
15
15
  "node": ">=18"
16
+ },
17
+ "peerDependencies": {
18
+ "nodebb": ">=4.0.0"
16
19
  }
17
20
  }
package/plugin.json CHANGED
@@ -22,8 +22,5 @@
22
22
  "scripts": [
23
23
  "public/client.js"
24
24
  ],
25
- "templates": "public/templates",
26
- "css": [
27
- "public/style.css"
28
- ]
25
+ "templates": "public/templates"
29
26
  }
package/public/client.js CHANGED
@@ -1,6 +1,6 @@
1
1
  'use strict';
2
-
3
2
  /* globals ajaxify */
3
+
4
4
  window.ezoicInfiniteLoaded = true;
5
5
 
6
6
  let cachedConfig;
@@ -10,17 +10,17 @@ let debounceTimer;
10
10
  let inFlight = false;
11
11
  let rerunRequested = false;
12
12
 
13
- // Per-page state (keyed by tid/cid)
13
+ // Per-page state
14
14
  let pageKey = null;
15
15
 
16
- // Topic page state: anchor ads to absolute post number (not DOM index)
17
- let seenAfterPostNo = new Set(); // post numbers we've already inserted an ad after
18
- let usedIds = new Set(); // ids currently in DOM
19
- let fifo = []; // [{afterPostNo, id}]
16
+ // Topic page: anchor ads to absolute post number
17
+ let seenAfterPostNo = new Set(); // post numbers we've inserted after
18
+ let usedIds = new Set(); // ids currently present in DOM
19
+ let fifo = []; // [{afterPostNo, id}]
20
20
 
21
- // Category page state: anchor to absolute topic position if available
22
- let seenAfterTopicPos = new Set(); // topic positions we've inserted after
23
- let fifoCat = []; // [{afterPos, id}]
21
+ // Category topic list page: anchor ads to absolute position
22
+ let seenAfterTopicPos = new Set();
23
+ let fifoCat = []; // [{afterPos, id}]
24
24
 
25
25
  function resetState() {
26
26
  seenAfterPostNo = new Set();
@@ -50,7 +50,7 @@ function parsePool(raw) {
50
50
  }
51
51
 
52
52
  async function fetchConfig() {
53
- if (cachedConfig && Date.now() - lastFetch < 10000) return cachedConfig;
53
+ if (cachedConfig && Date.now() - lastFetch < 8000) return cachedConfig;
54
54
  const res = await fetch('/api/plugins/ezoic-infinite/config', { credentials: 'same-origin' });
55
55
  cachedConfig = await res.json();
56
56
  lastFetch = Date.now();
@@ -58,7 +58,10 @@ async function fetchConfig() {
58
58
  }
59
59
 
60
60
  function isTopicPage() {
61
- return $('[component="post/content"]').length > 0 || $('[component="post"][data-pid]').length > 0;
61
+ try {
62
+ if (ajaxify && ajaxify.data && ajaxify.data.tid) return true;
63
+ } catch (e) {}
64
+ return /^\/topic\//.test(window.location.pathname);
62
65
  }
63
66
 
64
67
  function isCategoryTopicListPage() {
@@ -69,7 +72,7 @@ function getTopicPosts() {
69
72
  const $primary = $('[component="post"][data-pid]');
70
73
  if ($primary.length) return $primary;
71
74
 
72
- // fallback: top-level with post/content
75
+ // Fallback: top-level nodes with post/content (avoid nested)
73
76
  return $('[data-pid]').filter(function () {
74
77
  const $el = $(this);
75
78
  const hasContent = $el.find('[component="post/content"]').length > 0;
@@ -82,7 +85,7 @@ function getCategoryTopicItems() {
82
85
  return $('li[component="category/topic"]');
83
86
  }
84
87
 
85
- // If target's parent is UL/OL, wrapper MUST be LI (otherwise browser may move it to top)
88
+ // If target's parent is UL/OL, wrapper must be LI
86
89
  function wrapperTagFor($target) {
87
90
  if (!$target || !$target.length) return 'div';
88
91
  const parentTag = ($target.parent().prop('tagName') || '').toUpperCase();
@@ -102,7 +105,7 @@ function makeWrapperLike($target, classes, innerHtml, attrs) {
102
105
  }
103
106
 
104
107
  function cleanupOnNav() {
105
- $('.ezoic-ad-post, .ezoic-ad-topic, .ezoic-ad-between').remove();
108
+ $('.ezoic-ad-post, .ezoic-ad-topic').remove();
106
109
  }
107
110
 
108
111
  function pickNextId(pool) {
@@ -112,53 +115,119 @@ function pickNextId(pool) {
112
115
  return null;
113
116
  }
114
117
 
115
- function removeOldestTopicAd() {
118
+ function destroyEzoicId(id) {
119
+ try {
120
+ window.ezstandalone = window.ezstandalone || {};
121
+ window.ezstandalone.cmd = window.ezstandalone.cmd || [];
122
+ const fn = function () {
123
+ try {
124
+ if (typeof window.ezstandalone.destroyPlaceholders === 'function') {
125
+ window.ezstandalone.destroyPlaceholders(id);
126
+ }
127
+ } catch (e) {}
128
+ };
129
+ if (typeof window.ezstandalone.destroyPlaceholders === 'function') fn();
130
+ else window.ezstandalone.cmd.push(fn);
131
+ } catch (e) {}
132
+ }
133
+
134
+ function recycleOldestTopicId($posts) {
135
+ if (!fifo.length) return null;
116
136
  fifo.sort((a, b) => a.afterPostNo - b.afterPostNo);
117
- const old = fifo.shift();
118
- if (!old) return false;
119
137
 
120
- const sel = '.ezoic-ad-post[data-ezoic-after="' + old.afterPostNo + '"][data-ezoic-id="' + old.id + '"]';
121
- const $el = $(sel);
122
- if ($el.length) $el.remove();
138
+ while (fifo.length) {
139
+ const old = fifo.shift();
140
+ const sel = '.ezoic-ad-post[data-ezoic-after="' + old.afterPostNo + '"][data-ezoic-id="' + old.id + '"]';
141
+ const $el = $(sel);
142
+ if (!$el.length) continue;
143
+
144
+ // Never recycle the ad that is right after the last real post (sentinel)
145
+ try {
146
+ const $last = $posts.last();
147
+ if ($last.length && $el.prev().is($last)) {
148
+ fifo.push(old);
149
+ return null;
150
+ }
151
+ } catch (e) {}
123
152
 
124
- usedIds.delete(old.id);
125
- // DO NOT delete seenAfterPostNo to prevent re-insertion in the top area
126
- return true;
153
+ $el.remove();
154
+ usedIds.delete(old.id);
155
+ destroyEzoicId(old.id);
156
+ return old.id;
157
+ }
158
+ return null;
127
159
  }
128
160
 
129
- function removeOldestCategoryAd() {
161
+ function recycleOldestCategoryId($items) {
162
+ if (!fifoCat.length) return null;
130
163
  fifoCat.sort((a, b) => a.afterPos - b.afterPos);
131
- const old = fifoCat.shift();
132
- if (!old) return false;
133
164
 
134
- const sel = '.ezoic-ad-topic[data-ezoic-after="' + old.afterPos + '"][data-ezoic-id="' + old.id + '"]';
135
- const $el = $(sel);
136
- if ($el.length) $el.remove();
165
+ while (fifoCat.length) {
166
+ const old = fifoCat.shift();
167
+ const sel = '.ezoic-ad-topic[data-ezoic-after="' + old.afterPos + '"][data-ezoic-id="' + old.id + '"]';
168
+ const $el = $(sel);
169
+ if (!$el.length) continue;
170
+
171
+ try {
172
+ const $last = $items.last();
173
+ if ($last.length && $el.prev().is($last)) {
174
+ fifoCat.push(old);
175
+ return null;
176
+ }
177
+ } catch (e) {}
178
+
179
+ $el.remove();
180
+ usedIds.delete(old.id);
181
+ destroyEzoicId(old.id);
182
+ return old.id;
183
+ }
184
+ return null;
185
+ }
186
+
187
+ function setupAdAutoHeight() {
188
+ if (window.__ezoicAutoHeightAttached) return;
189
+ window.__ezoicAutoHeightAttached = true;
190
+
191
+ try {
192
+ const ro = new ResizeObserver(function () {
193
+ document.querySelectorAll('.ezoic-ad').forEach(function (wrap) {
194
+ wrap.style.minHeight = '0px';
195
+ wrap.style.height = 'auto';
196
+ });
197
+ });
198
+
199
+ function attach() {
200
+ document.querySelectorAll('.ezoic-ad').forEach(function (wrap) {
201
+ if (wrap.__ezoicRO) return;
202
+ wrap.__ezoicRO = true;
203
+ ro.observe(wrap);
204
+ });
205
+ }
137
206
 
138
- usedIds.delete(old.id);
139
- return true;
207
+ attach();
208
+ setTimeout(attach, 600);
209
+ setTimeout(attach, 1600);
210
+ } catch (e) {}
140
211
  }
141
212
 
142
213
  function callEzoic(ids) {
143
- // ids optional; if omitted, we will scan DOM for unrendered placeholders
144
214
  window.ezstandalone = window.ezstandalone || {};
145
215
  window.ezstandalone.cmd = window.ezstandalone.cmd || [];
146
216
 
217
+ // Collect unrendered wrappers (prevents duplicates even if events fire multiple times)
147
218
  const collect = function () {
148
219
  const list = [];
149
- document.querySelectorAll('.ezoic-ad [id^="ezoic-pub-ad-placeholder-"]').forEach(function (ph) {
220
+ document.querySelectorAll('.ezoic-ad').forEach(function (wrap) {
221
+ if (wrap.getAttribute('data-ezoic-rendered') === '1') return;
222
+ const ph = wrap.querySelector('[id^="ezoic-pub-ad-placeholder-"]');
223
+ if (!ph) return;
150
224
  const idStr = ph.id.replace('ezoic-pub-ad-placeholder-', '');
151
225
  const id = parseInt(idStr, 10);
152
226
  if (!Number.isFinite(id) || id <= 0) return;
153
227
 
154
- const wrap = ph.closest('.ezoic-ad');
155
- if (!wrap) return;
156
- if (wrap.getAttribute('data-ezoic-rendered') === '1') return;
157
-
158
- list.push(id);
159
228
  wrap.setAttribute('data-ezoic-rendered', '1');
229
+ list.push(id);
160
230
  });
161
- // de-dupe
162
231
  return Array.from(new Set(list));
163
232
  };
164
233
 
@@ -178,16 +247,13 @@ function callEzoic(ids) {
178
247
  setupAdAutoHeight();
179
248
  window.ezstandalone.cmd.push(function () { run(); });
180
249
 
181
- // Retry only if showAds isn't available yet
250
+ // Retry if Ezoic loads late
182
251
  let tries = 0;
183
252
  const maxTries = 6;
184
253
  const retry = function () {
185
254
  tries++;
186
255
  const ok = run();
187
256
  if (ok) return;
188
- try {
189
- if (typeof window.ezstandalone.showAds === 'function') return;
190
- } catch (e) {}
191
257
  if (tries < maxTries) setTimeout(retry, 800);
192
258
  };
193
259
 
@@ -201,13 +267,15 @@ function callEzoic(ids) {
201
267
  }
202
268
 
203
269
  function getPostNumber($post) {
204
- const di = parseInt($post.attr('data-index'), 10);
205
- if (Number.isFinite(di) && di > 0) return di;
206
-
270
+ // Harmony: <a class="post-index">#72</a>
207
271
  const txt = ($post.find('a.post-index').first().text() || '').trim();
208
272
  const m = txt.match(/#\s*(\d+)/);
209
273
  if (m) return parseInt(m[1], 10);
210
274
 
275
+ // fallback data-index (often 0/1 based)
276
+ const di = parseInt($post.attr('data-index'), 10);
277
+ if (Number.isFinite(di) && di >= 0) return di + 1;
278
+
211
279
  return NaN;
212
280
  }
213
281
 
@@ -224,8 +292,9 @@ function injectTopicMessageAds($posts, pool, interval) {
224
292
 
225
293
  $posts.each(function () {
226
294
  const $p = $(this);
227
- // Never insert after the last real post: it can break NodeBB infinite scroll
295
+ // Never insert after the last post (sentinel)
228
296
  if ($p.is($posts.last())) return;
297
+
229
298
  const postNo = getPostNumber($p);
230
299
  if (!Number.isFinite(postNo) || postNo <= 0) return;
231
300
 
@@ -233,7 +302,10 @@ function injectTopicMessageAds($posts, pool, interval) {
233
302
  if (seenAfterPostNo.has(postNo)) return;
234
303
 
235
304
  let id = pickNextId(pool);
236
- if (!id) { return; }
305
+ if (!id) {
306
+ id = recycleOldestTopicId($posts);
307
+ if (!id) return;
308
+ }
237
309
 
238
310
  const inner = '<div class="ezoic-ad-message-inner"><div id="ezoic-pub-ad-placeholder-' + id + '"></div></div>';
239
311
  const html = makeWrapperLike(
@@ -259,8 +331,9 @@ function injectCategoryBetweenAds($items, pool, interval) {
259
331
 
260
332
  $items.each(function () {
261
333
  const $it = $(this);
262
- // Never insert after the last real topic item (keeps NodeBB infinite scroll working)
334
+ // Never insert after the last topic item (sentinel)
263
335
  if ($it.is($items.last())) return;
336
+
264
337
  const pos = getTopicPos($it);
265
338
  if (!Number.isFinite(pos) || pos <= 0) return;
266
339
 
@@ -268,7 +341,10 @@ function injectCategoryBetweenAds($items, pool, interval) {
268
341
  if (seenAfterTopicPos.has(pos)) return;
269
342
 
270
343
  let id = pickNextId(pool);
271
- if (!id) { return; }
344
+ if (!id) {
345
+ id = recycleOldestCategoryId($items);
346
+ if (!id) return;
347
+ }
272
348
 
273
349
  const placeholder = '<div id="ezoic-pub-ad-placeholder-' + id + '"></div>';
274
350
  const html = makeWrapperLike(
@@ -310,17 +386,20 @@ async function refreshAds() {
310
386
  const messagePool = parsePool(cfg.messagePlaceholderIds);
311
387
  const messageInterval = Math.max(1, parseInt(cfg.messageIntervalPosts, 10) || 3);
312
388
 
389
+ const hasTopicList = isCategoryTopicListPage();
313
390
  const onTopic = isTopicPage();
314
- const onCategory = !onTopic && isCategoryTopicListPage();
391
+ const onCategory = hasTopicList && !onTopic;
315
392
 
316
393
  const newIds = [];
317
394
 
395
+ // Rule: topic list => between only ; topic page => message only
318
396
  if (onCategory) {
319
397
  const $items = getCategoryTopicItems();
320
398
  if (cfg.enableBetweenAds && betweenPool.length && $items.length) {
321
399
  newIds.push(...injectCategoryBetweenAds($items, betweenPool, betweenInterval));
322
400
  }
323
401
  callEzoic(newIds);
402
+ // also ensure any unrendered placeholders are rendered
324
403
  callEzoic();
325
404
  return;
326
405
  }
@@ -347,28 +426,29 @@ function debounceRefresh() {
347
426
  debounceTimer = setTimeout(refreshAds, 220);
348
427
  }
349
428
 
350
- $(document).ready(debounceRefresh);
429
+ $(document).ready(function () { setupAdAutoHeight(); debounceRefresh(); });
430
+
351
431
  $(window).on('action:ajaxify.end action:posts.loaded action:topic.loaded action:topics.loaded action:category.loaded', debounceRefresh);
352
- setTimeout(debounceRefresh, 2200);
353
432
 
354
- // Fallback: some themes/devices don't emit the expected events for infinite scroll.
355
- // Observe DOM additions and trigger a refresh when new posts/topics are appended/prepended.
433
+ setTimeout(function () { setupAdAutoHeight(); debounceRefresh(); }, 2200);
434
+
435
+ // Observer + poller: ensure we rerun when NodeBB injects new items
356
436
  (function setupEzoicObserver() {
357
437
  if (window.__ezoicInfiniteObserver) return;
358
438
  try {
439
+ const trigger = function () { debounceRefresh(); };
440
+
359
441
  const obs = new MutationObserver(function (mutations) {
360
442
  for (const m of mutations) {
361
443
  if (!m.addedNodes || !m.addedNodes.length) continue;
362
444
  for (const n of m.addedNodes) {
363
445
  if (!n || n.nodeType !== 1) continue;
364
- // direct match
365
446
  if (n.matches && (n.matches('[component="post"][data-pid]') || n.matches('li[component="category/topic"]'))) {
366
- debounceRefresh();
447
+ trigger();
367
448
  return;
368
449
  }
369
- // descendant match
370
450
  if (n.querySelector && (n.querySelector('[component="post"][data-pid]') || n.querySelector('li[component="category/topic"]'))) {
371
- debounceRefresh();
451
+ trigger();
372
452
  return;
373
453
  }
374
454
  }
@@ -376,5 +456,19 @@ setTimeout(debounceRefresh, 2200);
376
456
  });
377
457
  obs.observe(document.body, { childList: true, subtree: true });
378
458
  window.__ezoicInfiniteObserver = obs;
459
+
460
+ let lastPostCount = 0;
461
+ let lastTopicCount = 0;
462
+ setInterval(function () {
463
+ try {
464
+ const posts = document.querySelectorAll('[component="post"][data-pid]').length;
465
+ const topics = document.querySelectorAll('li[component="category/topic"]').length;
466
+ if (posts !== lastPostCount || topics !== lastTopicCount) {
467
+ lastPostCount = posts;
468
+ lastTopicCount = topics;
469
+ trigger();
470
+ }
471
+ } catch (e) {}
472
+ }, 1500);
379
473
  } catch (e) {}
380
474
  })();
package/public/style.css CHANGED
@@ -1,2 +1,3 @@
1
- .ezoic-ad-post{margin:0.75rem 0;}
2
- .ezoic-ad-message-inner{padding:0.75rem 0;}
1
+ .ezoic-ad{min-height:0!important;height:auto!important;padding:0!important;}
2
+ .ezoic-ad-post,.ezoic-ad-topic{margin:0.5rem 0;}
3
+ .ezoic-ad-message-inner{padding:0;margin:0;}