nodebb-plugin-ezoic-infinite 1.5.78 → 1.5.80

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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/public/client.js +142 -10
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nodebb-plugin-ezoic-infinite",
3
- "version": "1.5.78",
3
+ "version": "1.5.80",
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,6 +1,26 @@
1
1
  (function () {
2
2
  'use strict';
3
3
 
4
+ // Track scroll direction to avoid aggressive recycling when the user scrolls upward.
5
+ // Recycling while scrolling up is a common cause of ads "bunching" and a "disappearing too fast" feeling.
6
+ let lastScrollY = 0;
7
+ let scrollDir = 1; // 1 = down, -1 = up
8
+ try {
9
+ lastScrollY = window.scrollY || 0;
10
+ window.addEventListener(
11
+ 'scroll',
12
+ () => {
13
+ const y = window.scrollY || 0;
14
+ const d = y - lastScrollY;
15
+ if (Math.abs(d) > 4) {
16
+ scrollDir = d > 0 ? 1 : -1;
17
+ lastScrollY = y;
18
+ }
19
+ },
20
+ { passive: true }
21
+ );
22
+ } catch (e) {}
23
+
4
24
  // NodeBB client context
5
25
  const $ = (typeof window.jQuery === 'function') ? window.jQuery : null;
6
26
 
@@ -22,9 +42,11 @@
22
42
 
23
43
  // Preload margins
24
44
  const PRELOAD_MARGIN_DESKTOP = '2600px 0px 2600px 0px';
25
- const PRELOAD_MARGIN_MOBILE = '1500px 0px 1500px 0px';
26
- const PRELOAD_MARGIN_DESKTOP_BOOST = '4200px 0px 4200px 0px';
27
- const PRELOAD_MARGIN_MOBILE_BOOST = '2500px 0px 2500px 0px';
45
+ // Mobile: larger preload window so ad fill requests start earlier and
46
+ // users don't scroll past empty placeholders.
47
+ const PRELOAD_MARGIN_MOBILE = '3200px 0px 3200px 0px';
48
+ const PRELOAD_MARGIN_DESKTOP_BOOST = '5200px 0px 5200px 0px';
49
+ const PRELOAD_MARGIN_MOBILE_BOOST = '5200px 0px 5200px 0px';
28
50
 
29
51
  const BOOST_DURATION_MS = 2500;
30
52
  const BOOST_SPEED_PX_PER_MS = 2.2; // ~2200px/s
@@ -81,6 +103,22 @@
81
103
  } catch (e) {}
82
104
  }
83
105
 
106
+ // Some CMP/TCF stubs rely on a hidden iframe named `__tcfapiLocator` to route postMessage calls.
107
+ // In SPA/Ajaxify navigations, that iframe can be removed/replaced unexpectedly, causing noisy
108
+ // "postMessage" / "addtlConsent" null errors. Ensuring it's present makes the environment stable.
109
+ function ensureTcfApiLocator() {
110
+ try {
111
+ // If a CMP is not present, do nothing.
112
+ if (typeof window.__tcfapi !== 'function' && typeof window.__cmp !== 'function') return;
113
+ if (document.getElementById('__tcfapiLocator')) return;
114
+ const f = document.createElement('iframe');
115
+ f.style.display = 'none';
116
+ f.id = '__tcfapiLocator';
117
+ f.name = '__tcfapiLocator';
118
+ (document.body || document.documentElement).appendChild(f);
119
+ } catch (e) {}
120
+ }
121
+
84
122
 
85
123
 
86
124
  function isFilledNode(node) {
@@ -751,11 +789,24 @@ function globalGapFixInit() {
751
789
  if (prev.getAttribute && prev.getAttribute('data-ezoic-pin') === '1') break;
752
790
  } catch (e) {}
753
791
 
754
- // Don't decluster two "fresh" empty wraps it can reduce fill on slow auctions.
755
- // Only decluster when at least one is filled, or when the newer one is stale.
792
+ // Never remove a wrap that is already filled; otherwise it looks like
793
+ // ads "disappear" while scrolling. Only remove the empty neighbour.
756
794
  const prevFilled = isFilled(prev);
757
795
  const curFilled = isFilled(w);
758
- if (prevFilled || curFilled || !isFresh(w)) {
796
+
797
+ if (curFilled) {
798
+ // If the previous one is empty (and not fresh), drop the previous instead.
799
+ if (!prevFilled && !isFresh(prev)) {
800
+ withInternalDomChange(() => releaseWrapNode(prev));
801
+ removed++;
802
+ }
803
+ break;
804
+ }
805
+
806
+ // Current is empty.
807
+ // Don't decluster two "fresh" empty wraps — it can reduce fill on slow auctions.
808
+ // Only decluster when previous is filled, or when current is stale.
809
+ if (prevFilled || !isFresh(w)) {
759
810
  withInternalDomChange(() => releaseWrapNode(w));
760
811
  removed++;
761
812
  }
@@ -817,10 +868,16 @@ function globalGapFixInit() {
817
868
  try { io && io.observe(ph); } catch (e) {}
818
869
 
819
870
  // If already near viewport, fire immediately.
871
+ // Mobile tends to scroll faster + has slower auctions, so we fire earlier.
820
872
  try {
821
873
  const r = ph.getBoundingClientRect();
822
- const screens = isBoosted() ? 5.0 : 3.0;
823
- const minBottom = isBoosted() ? -1500 : -800;
874
+ const mobile = isMobile();
875
+ const screens = isBoosted()
876
+ ? (mobile ? 9.0 : 5.0)
877
+ : (mobile ? 6.0 : 3.0);
878
+ const minBottom = isBoosted()
879
+ ? (mobile ? -2600 : -1500)
880
+ : (mobile ? -1400 : -800);
824
881
  if (r.top < window.innerHeight * screens && r.bottom > minBottom) enqueueShow(id);
825
882
  } catch (e) {}
826
883
  }
@@ -982,12 +1039,31 @@ function globalGapFixInit() {
982
1039
  if (isAdjacentAd(el)) continue;
983
1040
  if (findWrap(kindClass, afterPos)) continue;
984
1041
 
985
- const id = pickIdFromAll(allIds, cursorKey);
1042
+ let id = pickIdFromAll(allIds, cursorKey);
1043
+ let recycledWrap = null;
1044
+
1045
+ // If the pool is exhausted (all placeholder ids already mounted), recycle a wrap that is far
1046
+ // above the viewport by moving it to the new target instead of creating a new placeholder.
1047
+ // This avoids "Placeholder Id X has already been defined" and also ensures ads keep
1048
+ // appearing on very long infinite scroll sessions.
1049
+ if (!id) {
1050
+ // Only recycle while scrolling down. Recycling while scrolling up tends to
1051
+ // cause perceived instability (ads bunching/disappearing).
1052
+ recycledWrap = scrollDir > 0 ? pickRecyclableWrap(kindClass) : null;
1053
+ if (recycledWrap) {
1054
+ id = recycledWrap.getAttribute('data-ezoic-wrapid') || '';
1055
+ }
1056
+ }
1057
+
986
1058
  if (!id) break;
987
1059
 
988
- const wrap = insertAfter(el, id, kindClass, afterPos);
1060
+ const wrap = recycledWrap
1061
+ ? moveWrapAfter(el, recycledWrap, kindClass, afterPos)
1062
+ : insertAfter(el, id, kindClass, afterPos);
989
1063
  if (!wrap) continue;
990
1064
 
1065
+ // observePlaceholder is safe for existing ids (it is idempotent) and helps with "late fill"
1066
+ // after ajaxify/infinite scroll mutations.
991
1067
  observePlaceholder(id);
992
1068
  inserted++;
993
1069
  }
@@ -995,6 +1071,60 @@ function globalGapFixInit() {
995
1071
  return inserted;
996
1072
  }
997
1073
 
1074
+ function pickRecyclableWrap(kindClass) {
1075
+ // Only recycle wrappers that are well above the viewport to avoid visible "disappearing".
1076
+ // With very small id pools (e.g. 6 ids), recycling is the only way to keep ads appearing
1077
+ // on long topics without redefining placeholders.
1078
+ const wraps = document.querySelectorAll('.' + kindClass + '[data-ezoic-wrapid]');
1079
+ if (!wraps || !wraps.length) return null;
1080
+
1081
+ const vh = Math.max(300, window.innerHeight || 800);
1082
+ // Recycle only when the wrapper is far above the viewport.
1083
+ // This keeps ads on-screen longer (especially on mobile) and reduces "blink".
1084
+ const threshold = -Math.min(9000, Math.round(vh * 6));
1085
+
1086
+ let best = null;
1087
+ let bestBottom = Infinity;
1088
+ for (const w of wraps) {
1089
+ if (!w || !w.isConnected) continue;
1090
+ const rect = w.getBoundingClientRect();
1091
+ if (rect.bottom < threshold) {
1092
+ if (rect.bottom < bestBottom) {
1093
+ bestBottom = rect.bottom;
1094
+ best = w;
1095
+ }
1096
+ }
1097
+ }
1098
+ return best;
1099
+ }
1100
+
1101
+ function moveWrapAfter(anchorEl, wrap, kindClass, afterPos) {
1102
+ try {
1103
+ if (!anchorEl || !wrap || !wrap.isConnected) return null;
1104
+
1105
+ wrap.setAttribute('data-ezoic-after', String(afterPos));
1106
+ anchorEl.insertAdjacentElement('afterend', wrap);
1107
+
1108
+ // Ensure minimal layout impact.
1109
+ try { wrap.style.contain = 'layout style paint'; } catch (e) {}
1110
+ try { tightenStickyIn(wrap); } catch (e) {}
1111
+
1112
+ const id = wrap.getAttribute('data-ezoic-wrapid');
1113
+ if (id) {
1114
+ setTimeout(() => {
1115
+ try {
1116
+ if (window.ezstandalone && typeof window.ezstandalone.showAds === 'function') {
1117
+ window.ezstandalone.showAds(id);
1118
+ }
1119
+ } catch (e) {}
1120
+ }, 0);
1121
+ }
1122
+ return wrap;
1123
+ } catch (e) {
1124
+ return null;
1125
+ }
1126
+ }
1127
+
998
1128
  async function runCore() {
999
1129
  if (isBlocked()) return 0;
1000
1130
 
@@ -1247,6 +1377,7 @@ function globalGapFixInit() {
1247
1377
  blockedUntil = 0;
1248
1378
 
1249
1379
  muteNoisyConsole();
1380
+ ensureTcfApiLocator();
1250
1381
  warmUpNetwork();
1251
1382
  patchShowAds();
1252
1383
  globalGapFixInit();
@@ -1318,6 +1449,7 @@ function globalGapFixInit() {
1318
1449
 
1319
1450
  state.pageKey = getPageKey();
1320
1451
  muteNoisyConsole();
1452
+ ensureTcfApiLocator();
1321
1453
  warmUpNetwork();
1322
1454
  patchShowAds();
1323
1455
  ensurePreloadObserver();