nodebb-plugin-ezoic-infinite 1.5.79 → 1.5.81

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 +135 -6
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nodebb-plugin-ezoic-infinite",
3
- "version": "1.5.79",
3
+ "version": "1.5.81",
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
 
@@ -83,6 +103,22 @@
83
103
  } catch (e) {}
84
104
  }
85
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
+
86
122
 
87
123
 
88
124
  function isFilledNode(node) {
@@ -668,9 +704,10 @@ function globalGapFixInit() {
668
704
  }
669
705
 
670
706
  function pruneOrphanWraps(kindClass, items) {
671
- // Message ads should remain stable (desktop + mobile). Pruning them can make it look like
672
- // ads vanish on scroll and can reduce fill on long topics.
673
- if (kindClass === 'ezoic-ad-message') return 0;
707
+ // Topic pages can be virtualized (posts removed from DOM as you scroll).
708
+ // When that happens, previously-inserted ad wraps may become "orphan" nodes with no
709
+ // nearby post containers, which leads to ads clustering together when scrolling back up.
710
+ // We prune only *true* orphans that are far offscreen to keep the UI stable.
674
711
  if (!items || !items.length) return 0;
675
712
  const itemSet = new Set(items);
676
713
  const wraps = document.querySelectorAll(`.${WRAP_CLASS}.${kindClass}`);
@@ -701,7 +738,10 @@ function globalGapFixInit() {
701
738
  if (wrap.getAttribute('data-ezoic-pin') === '1') return;
702
739
  } catch (e) {}
703
740
 
704
- if (isFilled(wrap)) return; // never prune filled ads
741
+ // For message/topic pages we may prune filled or empty orphans if they are far away,
742
+ // otherwise consecutive "stacks" can appear when posts are virtualized.
743
+ const isMessage = (kindClass === 'ezoic-ad-message');
744
+ if (!isMessage && isFilled(wrap)) return; // never prune filled ads for non-message lists
705
745
 
706
746
  // Never prune a fresh wrap: it may fill late.
707
747
  try {
@@ -711,6 +751,20 @@ function globalGapFixInit() {
711
751
 
712
752
  if (hasNearbyItem(wrap)) return;
713
753
 
754
+ // For message ads: only prune if far offscreen to avoid perceived "vanishing".
755
+ if (isMessage) {
756
+ try {
757
+ const r = wrap.getBoundingClientRect();
758
+ const vh = Math.max(1, window.innerHeight || 1);
759
+ const farAbove = r.bottom < -vh * 2;
760
+ const farBelow = r.top > vh * 4;
761
+ if (!farAbove && !farBelow) return;
762
+ } catch (e) {
763
+ // If we can't measure, be conservative.
764
+ return;
765
+ }
766
+ }
767
+
714
768
  withInternalDomChange(() => releaseWrapNode(wrap));
715
769
  removed++;
716
770
  });
@@ -1003,12 +1057,31 @@ function globalGapFixInit() {
1003
1057
  if (isAdjacentAd(el)) continue;
1004
1058
  if (findWrap(kindClass, afterPos)) continue;
1005
1059
 
1006
- const id = pickIdFromAll(allIds, cursorKey);
1060
+ let id = pickIdFromAll(allIds, cursorKey);
1061
+ let recycledWrap = null;
1062
+
1063
+ // If the pool is exhausted (all placeholder ids already mounted), recycle a wrap that is far
1064
+ // above the viewport by moving it to the new target instead of creating a new placeholder.
1065
+ // This avoids "Placeholder Id X has already been defined" and also ensures ads keep
1066
+ // appearing on very long infinite scroll sessions.
1067
+ if (!id) {
1068
+ // Only recycle while scrolling down. Recycling while scrolling up tends to
1069
+ // cause perceived instability (ads bunching/disappearing).
1070
+ recycledWrap = scrollDir > 0 ? pickRecyclableWrap(kindClass) : null;
1071
+ if (recycledWrap) {
1072
+ id = recycledWrap.getAttribute('data-ezoic-wrapid') || '';
1073
+ }
1074
+ }
1075
+
1007
1076
  if (!id) break;
1008
1077
 
1009
- const wrap = insertAfter(el, id, kindClass, afterPos);
1078
+ const wrap = recycledWrap
1079
+ ? moveWrapAfter(el, recycledWrap, kindClass, afterPos)
1080
+ : insertAfter(el, id, kindClass, afterPos);
1010
1081
  if (!wrap) continue;
1011
1082
 
1083
+ // observePlaceholder is safe for existing ids (it is idempotent) and helps with "late fill"
1084
+ // after ajaxify/infinite scroll mutations.
1012
1085
  observePlaceholder(id);
1013
1086
  inserted++;
1014
1087
  }
@@ -1016,6 +1089,60 @@ function globalGapFixInit() {
1016
1089
  return inserted;
1017
1090
  }
1018
1091
 
1092
+ function pickRecyclableWrap(kindClass) {
1093
+ // Only recycle wrappers that are well above the viewport to avoid visible "disappearing".
1094
+ // With very small id pools (e.g. 6 ids), recycling is the only way to keep ads appearing
1095
+ // on long topics without redefining placeholders.
1096
+ const wraps = document.querySelectorAll('.' + kindClass + '[data-ezoic-wrapid]');
1097
+ if (!wraps || !wraps.length) return null;
1098
+
1099
+ const vh = Math.max(300, window.innerHeight || 800);
1100
+ // Recycle only when the wrapper is far above the viewport.
1101
+ // This keeps ads on-screen longer (especially on mobile) and reduces "blink".
1102
+ const threshold = -Math.min(9000, Math.round(vh * 6));
1103
+
1104
+ let best = null;
1105
+ let bestBottom = Infinity;
1106
+ for (const w of wraps) {
1107
+ if (!w || !w.isConnected) continue;
1108
+ const rect = w.getBoundingClientRect();
1109
+ if (rect.bottom < threshold) {
1110
+ if (rect.bottom < bestBottom) {
1111
+ bestBottom = rect.bottom;
1112
+ best = w;
1113
+ }
1114
+ }
1115
+ }
1116
+ return best;
1117
+ }
1118
+
1119
+ function moveWrapAfter(anchorEl, wrap, kindClass, afterPos) {
1120
+ try {
1121
+ if (!anchorEl || !wrap || !wrap.isConnected) return null;
1122
+
1123
+ wrap.setAttribute('data-ezoic-after', String(afterPos));
1124
+ anchorEl.insertAdjacentElement('afterend', wrap);
1125
+
1126
+ // Ensure minimal layout impact.
1127
+ try { wrap.style.contain = 'layout style paint'; } catch (e) {}
1128
+ try { tightenStickyIn(wrap); } catch (e) {}
1129
+
1130
+ const id = wrap.getAttribute('data-ezoic-wrapid');
1131
+ if (id) {
1132
+ setTimeout(() => {
1133
+ try {
1134
+ if (window.ezstandalone && typeof window.ezstandalone.showAds === 'function') {
1135
+ window.ezstandalone.showAds(id);
1136
+ }
1137
+ } catch (e) {}
1138
+ }, 0);
1139
+ }
1140
+ return wrap;
1141
+ } catch (e) {
1142
+ return null;
1143
+ }
1144
+ }
1145
+
1019
1146
  async function runCore() {
1020
1147
  if (isBlocked()) return 0;
1021
1148
 
@@ -1268,6 +1395,7 @@ function globalGapFixInit() {
1268
1395
  blockedUntil = 0;
1269
1396
 
1270
1397
  muteNoisyConsole();
1398
+ ensureTcfApiLocator();
1271
1399
  warmUpNetwork();
1272
1400
  patchShowAds();
1273
1401
  globalGapFixInit();
@@ -1339,6 +1467,7 @@ function globalGapFixInit() {
1339
1467
 
1340
1468
  state.pageKey = getPageKey();
1341
1469
  muteNoisyConsole();
1470
+ ensureTcfApiLocator();
1342
1471
  warmUpNetwork();
1343
1472
  patchShowAds();
1344
1473
  ensurePreloadObserver();