nodebb-plugin-ezoic-infinite 1.5.32 → 1.5.34

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": "1.5.32",
3
+ "version": "1.5.34",
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
@@ -14,19 +14,79 @@
14
14
  const PRELOAD_MARGIN_DESKTOP = '2600px 0px 2600px 0px';
15
15
  const PRELOAD_MARGIN_MOBILE = '1500px 0px 1500px 0px';
16
16
 
17
+ // When the user scrolls very fast, temporarily preload more aggressively.
18
+ // This helps ensure ads are already in-flight before the user reaches them.
19
+ const PRELOAD_MARGIN_DESKTOP_BOOST = '4200px 0px 4200px 0px';
20
+ const PRELOAD_MARGIN_MOBILE_BOOST = '2500px 0px 2500px 0px';
21
+ const BOOST_DURATION_MS = 2500;
22
+ const BOOST_SPEED_PX_PER_MS = 2.2; // ~2200px/s
23
+
17
24
  const MAX_INFLIGHT_DESKTOP = 4;
18
25
  const MAX_INFLIGHT_MOBILE = 3;
19
26
 
27
+
28
+ // Adaptive performance profile (device/network aware)
29
+ const PERF_DEFAULTS = Object.freeze({
30
+ maxInflightDesktop: MAX_INFLIGHT_DESKTOP,
31
+ maxInflightMobile: MAX_INFLIGHT_MOBILE,
32
+ maxInsertsPerRun: MAX_INSERTS_PER_RUN,
33
+ });
34
+
35
+ function getPerfProfile() {
36
+ // Cache result for this pageview; recomputed on navigation via cleanup()
37
+ if (state.perfProfile) return state.perfProfile;
38
+ const p = {
39
+ maxInflightDesktop: PERF_DEFAULTS.maxInflightDesktop,
40
+ maxInflightMobile: PERF_DEFAULTS.maxInflightMobile,
41
+ maxInsertsPerRun: PERF_DEFAULTS.maxInsertsPerRun,
42
+ };
43
+
44
+ try {
45
+ const mem = typeof navigator !== 'undefined' ? navigator.deviceMemory : undefined; // GB
46
+ const cores = typeof navigator !== 'undefined' ? navigator.hardwareConcurrency : undefined;
47
+
48
+ // Conservative tuning for low-end devices
49
+ if (typeof mem === 'number' && mem > 0 && mem <= 2) {
50
+ p.maxInflightDesktop = Math.max(2, p.maxInflightDesktop - 1);
51
+ p.maxInflightMobile = Math.max(1, p.maxInflightMobile - 1);
52
+ p.maxInsertsPerRun = Math.max(1, p.maxInsertsPerRun - 1);
53
+ }
54
+ if (typeof cores === 'number' && cores > 0 && cores <= 4) {
55
+ p.maxInflightDesktop = Math.max(2, p.maxInflightDesktop - 1);
56
+ p.maxInflightMobile = Math.max(1, p.maxInflightMobile - 1);
57
+ p.maxInsertsPerRun = Math.max(1, p.maxInsertsPerRun - 1);
58
+ }
59
+
60
+ const conn = typeof navigator !== 'undefined' ? navigator.connection : undefined;
61
+ const eff = conn && conn.effectiveType ? String(conn.effectiveType).toLowerCase() : '';
62
+ // Slow connections: don't ramp concurrency too high (keeps page responsive).
63
+ if (eff.includes('2g')) {
64
+ p.maxInflightDesktop = Math.max(2, p.maxInflightDesktop - 1);
65
+ p.maxInflightMobile = Math.max(1, p.maxInflightMobile - 1);
66
+ }
67
+ } catch (e) {}
68
+
69
+ state.perfProfile = p;
70
+ return p;
71
+ }
72
+
73
+ function isBoosted() {
74
+ try { return Date.now() < (state.scrollBoostUntil || 0); } catch (e) { return false; }
75
+ }
76
+
20
77
  function isMobile() {
21
78
  try { return window && window.innerWidth && window.innerWidth < 768; } catch (e) { return false; }
22
79
  }
23
80
 
24
81
  function getPreloadRootMargin() {
25
- return isMobile() ? PRELOAD_MARGIN_MOBILE : PRELOAD_MARGIN_DESKTOP;
82
+ if (isMobile()) return isBoosted() ? PRELOAD_MARGIN_MOBILE_BOOST : PRELOAD_MARGIN_MOBILE;
83
+ return isBoosted() ? PRELOAD_MARGIN_DESKTOP_BOOST : PRELOAD_MARGIN_DESKTOP;
26
84
  }
27
85
 
28
86
  function getMaxInflight() {
29
- return isMobile() ? MAX_INFLIGHT_MOBILE : MAX_INFLIGHT_DESKTOP;
87
+ const perf = getPerfProfile();
88
+ const base = isMobile() ? perf.maxInflightMobile : perf.maxInflightDesktop;
89
+ return base + (isBoosted() ? 1 : 0);
30
90
  }
31
91
 
32
92
  const SELECTORS = {
@@ -35,6 +95,31 @@
35
95
  categoryItem: 'li[component="categories/category"]',
36
96
  };
37
97
 
98
+ const RELEVANT_MATCHERS = [
99
+ SELECTORS.postItem,
100
+ SELECTORS.topicItem,
101
+ SELECTORS.categoryItem,
102
+ ];
103
+
104
+ function mutationHasRelevantAddedNodes(mutations) {
105
+ try {
106
+ for (const m of mutations) {
107
+ if (!m || !m.addedNodes || !m.addedNodes.length) continue;
108
+ for (const n of m.addedNodes) {
109
+ if (!n || n.nodeType !== 1) continue;
110
+ const el = /** @type {Element} */ (n);
111
+ for (const sel of RELEVANT_MATCHERS) {
112
+ if (el.matches && el.matches(sel)) return true;
113
+ if (el.querySelector && el.querySelector(sel)) return true;
114
+ }
115
+ }
116
+ }
117
+ } catch (e) {}
118
+ return false;
119
+ }
120
+
121
+
122
+
38
123
  // Soft block during navigation / heavy DOM churn to avoid “placeholder does not exist” spam
39
124
  let blockedUntil = 0;
40
125
  function isBlocked() {
@@ -71,35 +156,17 @@
71
156
  pending: [],
72
157
  pendingSet: new Set(),
73
158
 
159
+ // fast scroll boosting
160
+ scrollBoostUntil: 0,
161
+ lastScrollY: 0,
162
+ lastScrollTs: 0,
163
+ ioMargin: null,
164
+
74
165
  // hero)
75
166
  heroDoneForPage: false,
76
167
  };
77
168
 
78
169
  const insertingIds = new Set();
79
-
80
- }
81
-
82
- function markEmptyWrapper(id) {
83
- try {
84
- const ph = document.getElementById(`${PLACEHOLDER_PREFIX}${id}`);
85
- if (!ph || !ph.isConnected) return;
86
- const wrap = ph.closest ? ph.closest(`.${WRAP_CLASS}`) : null;
87
- if (!wrap) return;
88
- // If still empty after a delay, collapse it.
89
- setTimeout(() => {
90
- try {
91
- const ph2 = document.getElementById(`${PLACEHOLDER_PREFIX}${id}`);
92
- if (!ph2 || !ph2.isConnected) return;
93
- const w2 = ph2.closest ? ph2.closest(`.${WRAP_CLASS}`) : null;
94
- if (!w2) return;
95
- // consider empty if only whitespace and no iframes/ins/img
96
- const hasAd = !!(ph2.querySelector && ph2.querySelector('iframe, ins, img, .ez-ad, .ezoic-ad'));
97
- if (!hasAd) w2.classList.add('is-empty');
98
- } catch (e) {}
99
- }, 3500);
100
- } catch (e) {}
101
- }
102
-
103
170
  // Production build: debug disabled
104
171
  function dbg() {}
105
172
 
@@ -346,22 +413,6 @@ function withInternalDomChange(fn) {
346
413
  if (removed) dbg('prune-orphan', kindClass, { removed });
347
414
  return removed;
348
415
  }
349
-
350
- function refreshEmptyState(id) {
351
- // After Ezoic has had a moment to fill the placeholder, toggle the CSS class.
352
- window.setTimeout(() => {
353
- try {
354
- const ph = document.getElementById(`${PLACEHOLDER_PREFIX}${id}`);
355
- if (!ph || !ph.isConnected) return;
356
- const wrap = ph.closest(`.${WRAP_CLASS}`);
357
- if (!wrap) return;
358
- const hasContent = ph.childElementCount > 0 || ph.offsetHeight > 0;
359
- if (hasContent) wrap.classList.remove('is-empty');
360
- else wrap.classList.add('is-empty');
361
- } catch (e) {}
362
- }, 3500);
363
- }
364
-
365
416
  function buildWrap(id, kindClass, afterPos) {
366
417
  const wrap = document.createElement('div');
367
418
  wrap.className = `${WRAP_CLASS} ${kindClass}`;
@@ -432,13 +483,20 @@ function buildWrap(id, kindClass, afterPos) {
432
483
  // Otherwise remove the earliest one in the document
433
484
  if (!victim) victim = wraps[0];
434
485
 
435
- // Unobserve placeholder if still observed
436
- try {
437
- const ph = victim.querySelector && victim.querySelector(`[id^="${PLACEHOLDER_PREFIX}"]`);
438
- if (ph && state.io) state.io.unobserve(ph);
439
- } catch (e) {}
486
+ const id = getWrapIdFromWrap(victim);
487
+
488
+ withInternalDomChange(() => {
489
+ // Unobserve placeholder if still observed
490
+ try {
491
+ const ph = victim.querySelector && victim.querySelector(`[id^="${PLACEHOLDER_PREFIX}"]`);
492
+ if (ph && state.io) state.io.unobserve(ph);
493
+ } catch (e) {}
494
+
495
+ try { if (id) safeDestroyById(id); } catch (e) {}
496
+
497
+ try { victim.remove(); } catch (e) {}
498
+ });
440
499
 
441
- victim.remove();
442
500
  return true;
443
501
  } catch (e) {
444
502
  return false;
@@ -505,14 +563,13 @@ function startShow(id) {
505
563
 
506
564
  const doShow = () => {
507
565
  try {
508
- if (state.usedOnce && state.usedOnce.has(id) && typeof ez.destroyPlaceholders === 'function') {
509
- try { ez.destroyPlaceholders(id); } catch (e) {}
566
+ if (state.usedOnce && state.usedOnce.has(id)) {
567
+ safeDestroyById(id);
510
568
  }
511
569
  } catch (e) {}
512
570
 
513
571
  try { ez.showAds(id); } catch (e) {}
514
572
  try { state.usedOnce && state.usedOnce.add(id); } catch (e) {}
515
- try { markEmptyWrapper(id); } catch (e) {}
516
573
 
517
574
  setTimeout(() => { clearTimeout(hardTimer); release(); }, 650);
518
575
  };
@@ -532,7 +589,14 @@ function startShow(id) {
532
589
  // ---------- preload / above-the-fold ----------
533
590
 
534
591
  function ensurePreloadObserver() {
535
- if (state.io) return state.io;
592
+ const desiredMargin = getPreloadRootMargin();
593
+ if (state.io && state.ioMargin === desiredMargin) return state.io;
594
+
595
+ // Rebuild IO if margin changed (e.g., scroll boost toggled)
596
+ if (state.io) {
597
+ try { state.io.disconnect(); } catch (e) {}
598
+ state.io = null;
599
+ }
536
600
  try {
537
601
  state.io = new IntersectionObserver((entries) => {
538
602
  for (const ent of entries) {
@@ -544,10 +608,20 @@ function startShow(id) {
544
608
  const id = parseInt(idAttr, 10);
545
609
  if (Number.isFinite(id) && id > 0) enqueueShow(id);
546
610
  }
547
- }, { root: null, rootMargin: getPreloadRootMargin(), threshold: 0 });
611
+ }, { root: null, rootMargin: desiredMargin, threshold: 0 });
612
+ state.ioMargin = desiredMargin;
548
613
  } catch (e) {
549
614
  state.io = null;
615
+ state.ioMargin = null;
550
616
  }
617
+
618
+ // If we rebuilt the observer, re-observe existing placeholders so we don't miss them.
619
+ try {
620
+ if (state.io) {
621
+ const nodes = document.querySelectorAll(`[id^="${PLACEHOLDER_PREFIX}"]`);
622
+ nodes.forEach((n) => { try { state.io.observe(n); } catch (e) {} });
623
+ }
624
+ } catch (e) {}
551
625
  return state.io;
552
626
  }
553
627
 
@@ -560,7 +634,9 @@ function startShow(id) {
560
634
  // If already above fold, fire immediately
561
635
  try {
562
636
  const r = ph.getBoundingClientRect();
563
- if (r.top < window.innerHeight * 3.0 && r.bottom > -800) enqueueShow(id);
637
+ const screens = isBoosted() ? 5.0 : 3.0;
638
+ const minBottom = isBoosted() ? -1500 : -800;
639
+ if (r.top < window.innerHeight * screens && r.bottom > minBottom) enqueueShow(id);
564
640
  } catch (e) {}
565
641
  }
566
642
 
@@ -581,9 +657,11 @@ function startShow(id) {
581
657
 
582
658
  const targets = computeTargets(items.length, interval, showFirst);
583
659
  let inserted = 0;
660
+ const perf = getPerfProfile();
661
+ const maxInserts = perf.maxInsertsPerRun + (isBoosted() ? 1 : 0);
584
662
 
585
663
  for (const afterPos of targets) {
586
- if (inserted >= MAX_INSERTS_PER_RUN) break;
664
+ if (inserted >= maxInserts) break;
587
665
 
588
666
  const el = items[afterPos - 1];
589
667
  if (!el || !el.isConnected) continue;
@@ -749,11 +827,16 @@ function startShow(id) {
749
827
 
750
828
  // remove all wrappers
751
829
  try {
752
- document.querySelectorAll(`.${WRAP_CLASS}`).forEach((el) => {
753
- try { el.remove(); } catch (e) {}
830
+ withInternalDomChange(() => {
831
+ document.querySelectorAll(`.${WRAP_CLASS}`).forEach((el) => {
832
+ try { el.remove(); } catch (e) {}
833
+ });
754
834
  });
755
835
  } catch (e) {}
756
836
 
837
+ // reset perf profile cache
838
+ state.perfProfile = null;
839
+
757
840
  // reset state
758
841
  state.cfg = null;
759
842
  state.allTopics = [];
@@ -774,9 +857,11 @@ function startShow(id) {
774
857
 
775
858
  function ensureDomObserver() {
776
859
  if (state.domObs) return;
777
- state.domObs = new MutationObserver(() => {
860
+ state.domObs = new MutationObserver((mutations) => {
778
861
  if (state.internalDomChange > 0) return;
779
- if (!isBlocked()) scheduleRun();
862
+ if (isBlocked()) return;
863
+ // Only rescan when NodeBB actually added posts/topics/categories.
864
+ if (mutationHasRelevantAddedNodes(mutations)) scheduleRun();
780
865
  });
781
866
  try {
782
867
  state.domObs.observe(document.body, { childList: true, subtree: true });
@@ -818,6 +903,29 @@ function startShow(id) {
818
903
  function bindScroll() {
819
904
  let ticking = false;
820
905
  window.addEventListener('scroll', () => {
906
+ // Detect very fast scrolling and temporarily boost preload/parallelism.
907
+ try {
908
+ const now = Date.now();
909
+ const y = window.scrollY || window.pageYOffset || 0;
910
+ if (state.lastScrollTs) {
911
+ const dt = now - state.lastScrollTs;
912
+ const dy = Math.abs(y - (state.lastScrollY || 0));
913
+ if (dt > 0) {
914
+ const speed = dy / dt; // px/ms
915
+ if (speed >= BOOST_SPEED_PX_PER_MS) {
916
+ const wasBoosted = isBoosted();
917
+ state.scrollBoostUntil = Math.max(state.scrollBoostUntil || 0, now + BOOST_DURATION_MS);
918
+ if (!wasBoosted) {
919
+ // margin changed -> rebuild IO so existing placeholders get earlier preload
920
+ ensurePreloadObserver();
921
+ }
922
+ }
923
+ }
924
+ }
925
+ state.lastScrollY = y;
926
+ state.lastScrollTs = now;
927
+ } catch (e) {}
928
+
821
929
  if (ticking) return;
822
930
  ticking = true;
823
931
  window.requestAnimationFrame(() => {
package/public/style.css CHANGED
@@ -1,40 +1,9 @@
1
- /* Keep Ezoic wrappers “CLS-safe” and remove the extra vertical spacing Ezoic often injects */
1
+ /* Minimal styling for injected Ezoic wrappers.
2
+ Spacing (margins/padding) is intentionally NOT forced here, because it can be
3
+ configured inside Ezoic and may vary by placement/device.
4
+ */
5
+
2
6
  .ezoic-ad {
3
7
  display: block;
4
8
  width: 100%;
5
- margin: 0 !important;
6
- padding: 0 !important;
7
- overflow: hidden;
8
- }
9
-
10
- .ezoic-ad > [id^="ezoic-pub-ad-placeholder-"] {
11
- margin: 0 !important;
12
- padding: 0 !important;
13
- min-height: 1px; /* keeps placeholder measurable for IO */
14
- }
15
-
16
- /* Ezoic sometimes wraps in extra spans/divs with margins */
17
- .ezoic-ad span.ezoic-ad,
18
- .ezoic-ad .ezoic-ad {
19
- margin: 0 !important;
20
- padding: 0 !important;
21
- }
22
-
23
-
24
- /* Collapse empty ad blocks (prevents "holes" when an ad doesn't fill or gets destroyed) */
25
- .ezoic-ad.is-empty {
26
- display: block !important;
27
- margin: 0 !important;
28
- padding: 0 !important;
29
- height: 0 !important;
30
- min-height: 0 !important;
31
- overflow: hidden !important;
32
- }
33
-
34
- .ezoic-ad {
35
- min-height: 0 !important;
36
- }
37
-
38
- .ezoic-ad > [id^="ezoic-pub-ad-placeholder-"] {
39
- min-height: 0 !important;
40
9
  }