nodebb-plugin-ezoic-infinite 1.5.89 → 1.5.91

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 +209 -12
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nodebb-plugin-ezoic-infinite",
3
- "version": "1.5.89",
3
+ "version": "1.5.91",
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,14 +1,54 @@
1
1
  (function () {
2
2
  'use strict';
3
3
 
4
- function purgeEzoicWrapsInContainer(containerEl) {
4
+ // ===== V6 anchor-map stability layer =====
5
+ var __ezoicAnchorMap = Object.create(null);
6
+
7
+ function ezoicGetAnchorKey(el, afterPos, kindClass) {
8
+ try {
9
+ if (!el) return kindClass + ':pos:' + String(afterPos || 0);
10
+ var pid = el.getAttribute && (el.getAttribute('data-pid') || el.getAttribute('data-id'));
11
+ if (pid) return kindClass + ':pid:' + String(pid);
12
+ var tid = el.getAttribute && (el.getAttribute('data-tid') || el.getAttribute('data-topic-id'));
13
+ if (tid) return kindClass + ':tid:' + String(tid);
14
+ var a = el.querySelector && el.querySelector('[data-pid],[data-id],[data-tid],[data-topic-id]');
15
+ if (a) {
16
+ var p = a.getAttribute('data-pid') || a.getAttribute('data-id');
17
+ if (p) return kindClass + ':pid:' + String(p);
18
+ var t = a.getAttribute('data-tid') || a.getAttribute('data-topic-id');
19
+ if (t) return kindClass + ':tid:' + String(t);
20
+ }
21
+ } catch (e) {}
22
+ return kindClass + ':pos:' + String(afterPos || 0);
23
+ }
24
+
25
+ function ezoicRegisterWrapAnchor(wrap, anchorKey) {
26
+ try {
27
+ if (!wrap || !anchorKey) return;
28
+ wrap.setAttribute('data-ezoic-anchor', anchorKey);
29
+ __ezoicAnchorMap[anchorKey] = wrap;
30
+ } catch (e) {}
31
+ }
32
+
33
+ function ezoicFindWrapByAnchor(anchorKey) {
34
+ try {
35
+ var w = __ezoicAnchorMap[anchorKey];
36
+ if (w && w.isConnected) return w;
37
+ } catch (e) {}
5
38
  try {
6
- if (!containerEl || !containerEl.querySelectorAll) return;
7
- containerEl.querySelectorAll('.nodebb-ezoic-wrap').forEach(function (n) {
8
- try { n.remove(); } catch (e) {}
39
+ return document.querySelector('.nodebb-ezoic-wrap[data-ezoic-anchor="' + String(anchorKey).replace(/"/g, '') + '"]');
40
+ } catch (e) { return null; }
41
+ }
42
+
43
+ function ezoicCleanupAnchorMap() {
44
+ try {
45
+ Object.keys(__ezoicAnchorMap).forEach(function(k){
46
+ var w = __ezoicAnchorMap[k];
47
+ if (!w || !w.isConnected) delete __ezoicAnchorMap[k];
9
48
  });
10
49
  } catch (e) {}
11
50
  }
51
+ // ===== /V6 =====
12
52
 
13
53
 
14
54
  // Track scroll direction to avoid aggressive recycling when the user scrolls upward.
@@ -650,6 +690,8 @@ function globalGapFixInit() {
650
690
  const wrap = document.createElement('div');
651
691
  wrap.className = `${WRAP_CLASS} ${kindClass}`;
652
692
  wrap.setAttribute('data-ezoic-after', String(afterPos));
693
+ var __anchorKey = ezoicGetAnchorKey(el, afterPos, kindClass);
694
+ ezoicRegisterWrapAnchor(wrap, __anchorKey);
653
695
  wrap.setAttribute('data-ezoic-wrapid', String(id));
654
696
  wrap.setAttribute('data-created', String(now()));
655
697
  // "Pinned" placements (after the first item) should remain stable.
@@ -714,13 +756,144 @@ function globalGapFixInit() {
714
756
  }
715
757
 
716
758
  function pruneOrphanWraps(kindClass, items) {
759
+ // Topic pages can be virtualized (posts removed from DOM as you scroll).
760
+ // When that happens, previously-inserted ad wraps may become "orphan" nodes with no
761
+ // nearby post containers, which leads to ads clustering together when scrolling back up.
762
+ // We prune only *true* orphans that are far offscreen to keep the UI stable.
763
+ if (!items || !items.length) return 0;
764
+ const itemSet = new Set(items);
765
+ const wraps = document.querySelectorAll(`.${WRAP_CLASS}.${kindClass}`);
766
+ let removed = 0;
767
+
768
+ const isFilled = (wrap) => {
769
+ return !!(wrap.querySelector('iframe, ins, img, video, [data-google-container-id]'));
770
+ };
771
+
772
+ const hasNearbyItem = (wrap) => {
773
+ // NodeBB/skins can inject separators/spacers; be tolerant.
774
+ let prev = wrap.previousElementSibling;
775
+ for (let i = 0; i < 14 && prev; i++) {
776
+ if (itemSet.has(prev)) return true;
777
+ prev = prev.previousElementSibling;
778
+ }
779
+ let next = wrap.nextElementSibling;
780
+ for (let i = 0; i < 14 && next; i++) {
781
+ if (itemSet.has(next)) return true;
782
+ next = next.nextElementSibling;
783
+ }
784
+ return false;
785
+ };
786
+
787
+ wraps.forEach((wrap) => {
788
+ // Never prune pinned placements.
789
+ try {
790
+ if (wrap.getAttribute('data-ezoic-pin') === '1') return;
791
+ } catch (e) {}
792
+
793
+ // For message/topic pages we may prune filled or empty orphans if they are far away,
794
+ // otherwise consecutive "stacks" can appear when posts are virtualized.
795
+ const isMessage = (kindClass === 'ezoic-ad-message');
796
+ if (!isMessage && isFilled(wrap)) return; // never prune filled ads for non-message lists
797
+
798
+ // Never prune a fresh wrap: it may fill late.
799
+ try {
800
+ const created = parseInt(wrap.getAttribute('data-created') || '0', 10);
801
+ if (created && (now() - created) < keepEmptyWrapMs()) return;
802
+ } catch (e) {}
803
+
804
+ if (hasNearbyItem(wrap)) {
805
+ try { wrap.classList && wrap.classList.remove('ez-orphan-hidden'); wrap.style && (wrap.style.display = ''); } catch (e) {}
717
806
  return;
718
807
  }
719
808
 
720
- function decluster(kindClass) {
721
- return;
809
+ // If the anchor item is no longer in the DOM (virtualized), hide the wrap so ads never "stack"
810
+ // back-to-back while scrolling. We'll recycle it when its anchor comes back.
811
+ try { wrap.classList && wrap.classList.add('ez-orphan-hidden'); wrap.style && (wrap.style.display = 'none'); } catch (e) {}
812
+
813
+ // For message ads: only release if far offscreen to avoid perceived "vanishing" during fast scroll.
814
+ if (isMessage) {
815
+ try {
816
+ const r = wrap.getBoundingClientRect();
817
+ const vh = Math.max(1, window.innerHeight || 1);
818
+ const farAbove = r.bottom < -vh * 2;
819
+ const farBelow = r.top > vh * 4;
820
+ if (!farAbove && !farBelow) return;
821
+ } catch (e) {
822
+ return;
823
+ }
722
824
  }
723
825
 
826
+ withInternalDomChange(() => releaseWrapNode(wrap));
827
+ removed++;
828
+ });
829
+
830
+ return removed;
831
+ }
832
+
833
+ function decluster(kindClass) {
834
+ // Remove "near-consecutive" wraps (keep the first). Be tolerant of spacer nodes.
835
+ const wraps = Array.from(document.querySelectorAll(`.${WRAP_CLASS}.${kindClass}`));
836
+ if (wraps.length < 2) return 0;
837
+
838
+ const isWrap = (el) => !!(el && el.classList && el.classList.contains(WRAP_CLASS));
839
+
840
+ const isFilled = (wrap) => {
841
+ return !!(wrap && wrap.querySelector && wrap.querySelector('iframe, ins, img, video, [data-google-container-id]'));
842
+ };
843
+
844
+ const isFresh = (wrap) => {
845
+ try {
846
+ const created = parseInt(wrap.getAttribute('data-created') || '0', 10);
847
+ return created && (now() - created) < keepEmptyWrapMs();
848
+ } catch (e) {
849
+ return false;
850
+ }
851
+ };
852
+
853
+ let removed = 0;
854
+ for (const w of wraps) {
855
+ // Never decluster pinned placements.
856
+ try {
857
+ if (w.getAttribute && w.getAttribute('data-ezoic-pin') === '1') continue;
858
+ } catch (e) {}
859
+
860
+ let prev = w.previousElementSibling;
861
+ for (let i = 0; i < 3 && prev; i++) {
862
+ if (isWrap(prev)) {
863
+ // If the previous wrap is pinned, keep this one (spacing is intentional).
864
+ try {
865
+ if (prev.getAttribute && prev.getAttribute('data-ezoic-pin') === '1') break;
866
+ } catch (e) {}
867
+
868
+ // Never remove a wrap that is already filled; otherwise it looks like
869
+ // ads "disappear" while scrolling. Only remove the empty neighbour.
870
+ const prevFilled = isFilled(prev);
871
+ const curFilled = isFilled(w);
872
+
873
+ if (curFilled) {
874
+ // If the previous one is empty (and not fresh), drop the previous instead.
875
+ if (!prevFilled && !isFresh(prev)) {
876
+ withInternalDomChange(() => releaseWrapNode(prev));
877
+ removed++;
878
+ }
879
+ break;
880
+ }
881
+
882
+ // Current is empty.
883
+ // Don't decluster two "fresh" empty wraps — it can reduce fill on slow auctions.
884
+ // Only decluster when previous is filled, or when current is stale.
885
+ if (prevFilled || !isFresh(w)) {
886
+ withInternalDomChange(() => releaseWrapNode(w));
887
+ removed++;
888
+ }
889
+ break;
890
+ }
891
+ prev = prev.previousElementSibling;
892
+ }
893
+ }
894
+ return removed;
895
+ }
896
+
724
897
  // ---------------- show (preload / fast fill) ----------------
725
898
 
726
899
  function ensurePreloadObserver() {
@@ -958,9 +1131,7 @@ function buildOrdinalMap(items) {
958
1131
  }
959
1132
 
960
1133
  function injectBetween(kindClass, items, interval, showFirst, allIds, cursorKey) {
961
- // V5: local stateless mode (container only)
962
- purgeEzoicWrapsInContainer(containerEl);
963
-
1134
+ ezoicCleanupAnchorMap();
964
1135
  if (!items.length) return 0;
965
1136
 
966
1137
  const { map: ordinalMap, max: maxOrdinal } = buildOrdinalMap(items);
@@ -984,9 +1155,10 @@ function buildOrdinalMap(items) {
984
1155
  // This avoids "Placeholder Id X has already been defined" and also ensures ads keep
985
1156
  // appearing on very long infinite scroll sessions.
986
1157
  if (!id) {
987
- // Only recycle while scrolling down. Recycling while scrolling up tends to
988
- // cause perceived instability (ads bunching/disappearing).
989
- recycledWrap = null;
1158
+ // Safe mode: disable recycling for topic message ads to prevent visual "jumping"
1159
+ // (ads seemingly moving back under the first post during virtualized scroll).
1160
+ const allowRecycle = kindClass !== 'ezoic-ad-message';
1161
+ recycledWrap = (allowRecycle && scrollDir > 0) ? pickRecyclableWrap(kindClass) : null;
990
1162
  if (recycledWrap) {
991
1163
  id = recycledWrap.getAttribute('data-ezoic-wrapid') || '';
992
1164
  }
@@ -1353,3 +1525,28 @@ function buildOrdinalMap(items) {
1353
1525
  insertHeroAdEarly().catch(() => {});
1354
1526
  requestBurst();
1355
1527
  })();
1528
+
1529
+
1530
+ // V6 guard: prevent unexpected regrouping under first topic
1531
+ try {
1532
+ if (typeof MutationObserver !== 'undefined' && !window.__ezoicWrapGuardInstalled) {
1533
+ window.__ezoicWrapGuardInstalled = true;
1534
+ var guardObserver = new MutationObserver(function() {
1535
+ try {
1536
+ Object.keys(__ezoicAnchorMap).forEach(function(k){
1537
+ var w = __ezoicAnchorMap[k];
1538
+ if (!w || !w.isConnected) return;
1539
+ var anchor = w.getAttribute('data-ezoic-anchor') || '';
1540
+ var m = anchor.match(/:(pid|tid):(.+)$/);
1541
+ if (!m) return;
1542
+ var val = m[2];
1543
+ var anchorEl = document.querySelector('[data-pid="' + val + '"], [data-id="' + val + '"], [data-tid="' + val + '"], [data-topic-id="' + val + '"]');
1544
+ if (anchorEl && w.previousElementSibling !== anchorEl) {
1545
+ if (anchorEl.parentNode) anchorEl.parentNode.insertBefore(w, anchorEl.nextSibling);
1546
+ }
1547
+ });
1548
+ } catch (e) {}
1549
+ });
1550
+ guardObserver.observe(document.body || document.documentElement, { childList: true, subtree: true });
1551
+ }
1552
+ } catch (e) {}