nodebb-plugin-ezoic-infinite 1.5.88 → 1.5.90

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 +165 -6
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nodebb-plugin-ezoic-infinite",
3
- "version": "1.5.88",
3
+ "version": "1.5.90",
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,7 +1,32 @@
1
1
  (function () {
2
2
  'use strict';
3
3
 
4
- var EZOIC_SAFE_MODE = true;
4
+ // ===== V5.1 multi-ads stable anchors =====
5
+ function getStableAnchorKey(el, fallbackPos) {
6
+ try {
7
+ if (!el) return 'pos:' + String(fallbackPos || 0);
8
+ var pid = el.getAttribute && (el.getAttribute('data-pid') || el.getAttribute('data-id'));
9
+ if (pid) return 'pid:' + String(pid);
10
+ var tid = el.getAttribute && (el.getAttribute('data-tid') || el.getAttribute('data-topic-id'));
11
+ if (tid) return 'tid:' + String(tid);
12
+ var topic = el.querySelector && el.querySelector('[data-tid],[data-topic-id]');
13
+ if (topic) {
14
+ var t = topic.getAttribute('data-tid') || topic.getAttribute('data-topic-id');
15
+ if (t) return 'tid:' + String(t);
16
+ }
17
+ } catch (e) {}
18
+ return 'pos:' + String(fallbackPos || 0);
19
+ }
20
+
21
+ function findWrapByAnchor(kindClass, anchorKey) {
22
+ try {
23
+ var sel = '.nodebb-ezoic-wrap.' + kindClass + '[data-ezoic-anchor="' + anchorKey.replace(/"/g, '') + '"]';
24
+ return document.querySelector(sel);
25
+ } catch (e) {
26
+ return null;
27
+ }
28
+ }
29
+ // ===== /V5.1 =====
5
30
 
6
31
 
7
32
  // Track scroll direction to avoid aggressive recycling when the user scrolls upward.
@@ -643,6 +668,7 @@ function globalGapFixInit() {
643
668
  const wrap = document.createElement('div');
644
669
  wrap.className = `${WRAP_CLASS} ${kindClass}`;
645
670
  wrap.setAttribute('data-ezoic-after', String(afterPos));
671
+ try { wrap.setAttribute('data-ezoic-anchor', getStableAnchorKey(el, afterPos)); } catch (e) {}
646
672
  wrap.setAttribute('data-ezoic-wrapid', String(id));
647
673
  wrap.setAttribute('data-created', String(now()));
648
674
  // "Pinned" placements (after the first item) should remain stable.
@@ -707,14 +733,144 @@ function globalGapFixInit() {
707
733
  }
708
734
 
709
735
  function pruneOrphanWraps(kindClass, items) {
710
- // V4.1 safe mode: no DOM removals here to avoid breaking infinite-scroll internals.
736
+ // Topic pages can be virtualized (posts removed from DOM as you scroll).
737
+ // When that happens, previously-inserted ad wraps may become "orphan" nodes with no
738
+ // nearby post containers, which leads to ads clustering together when scrolling back up.
739
+ // We prune only *true* orphans that are far offscreen to keep the UI stable.
740
+ if (!items || !items.length) return 0;
741
+ const itemSet = new Set(items);
742
+ const wraps = document.querySelectorAll(`.${WRAP_CLASS}.${kindClass}`);
743
+ let removed = 0;
744
+
745
+ const isFilled = (wrap) => {
746
+ return !!(wrap.querySelector('iframe, ins, img, video, [data-google-container-id]'));
747
+ };
748
+
749
+ const hasNearbyItem = (wrap) => {
750
+ // NodeBB/skins can inject separators/spacers; be tolerant.
751
+ let prev = wrap.previousElementSibling;
752
+ for (let i = 0; i < 14 && prev; i++) {
753
+ if (itemSet.has(prev)) return true;
754
+ prev = prev.previousElementSibling;
755
+ }
756
+ let next = wrap.nextElementSibling;
757
+ for (let i = 0; i < 14 && next; i++) {
758
+ if (itemSet.has(next)) return true;
759
+ next = next.nextElementSibling;
760
+ }
761
+ return false;
762
+ };
763
+
764
+ wraps.forEach((wrap) => {
765
+ // Never prune pinned placements.
766
+ try {
767
+ if (wrap.getAttribute('data-ezoic-pin') === '1') return;
768
+ } catch (e) {}
769
+
770
+ // For message/topic pages we may prune filled or empty orphans if they are far away,
771
+ // otherwise consecutive "stacks" can appear when posts are virtualized.
772
+ const isMessage = (kindClass === 'ezoic-ad-message');
773
+ if (!isMessage && isFilled(wrap)) return; // never prune filled ads for non-message lists
774
+
775
+ // Never prune a fresh wrap: it may fill late.
776
+ try {
777
+ const created = parseInt(wrap.getAttribute('data-created') || '0', 10);
778
+ if (created && (now() - created) < keepEmptyWrapMs()) return;
779
+ } catch (e) {}
780
+
781
+ if (hasNearbyItem(wrap)) {
782
+ try { wrap.classList && wrap.classList.remove('ez-orphan-hidden'); wrap.style && (wrap.style.display = ''); } catch (e) {}
711
783
  return;
712
784
  }
713
785
 
714
- function decluster(kindClass) {
715
- return;
786
+ // If the anchor item is no longer in the DOM (virtualized), hide the wrap so ads never "stack"
787
+ // back-to-back while scrolling. We'll recycle it when its anchor comes back.
788
+ try { wrap.classList && wrap.classList.add('ez-orphan-hidden'); wrap.style && (wrap.style.display = 'none'); } catch (e) {}
789
+
790
+ // For message ads: only release if far offscreen to avoid perceived "vanishing" during fast scroll.
791
+ if (isMessage) {
792
+ try {
793
+ const r = wrap.getBoundingClientRect();
794
+ const vh = Math.max(1, window.innerHeight || 1);
795
+ const farAbove = r.bottom < -vh * 2;
796
+ const farBelow = r.top > vh * 4;
797
+ if (!farAbove && !farBelow) return;
798
+ } catch (e) {
799
+ // keep original behavior in base build
800
+ }
716
801
  }
717
802
 
803
+ withInternalDomChange(() => releaseWrapNode(wrap));
804
+ removed++;
805
+ });
806
+
807
+ return removed;
808
+ }
809
+
810
+ function decluster(kindClass) {
811
+ // Remove "near-consecutive" wraps (keep the first). Be tolerant of spacer nodes.
812
+ const wraps = Array.from(document.querySelectorAll(`.${WRAP_CLASS}.${kindClass}`));
813
+ if (wraps.length < 2) return 0;
814
+
815
+ const isWrap = (el) => !!(el && el.classList && el.classList.contains(WRAP_CLASS));
816
+
817
+ const isFilled = (wrap) => {
818
+ return !!(wrap && wrap.querySelector && wrap.querySelector('iframe, ins, img, video, [data-google-container-id]'));
819
+ };
820
+
821
+ const isFresh = (wrap) => {
822
+ try {
823
+ const created = parseInt(wrap.getAttribute('data-created') || '0', 10);
824
+ return created && (now() - created) < keepEmptyWrapMs();
825
+ } catch (e) {
826
+ return false;
827
+ }
828
+ };
829
+
830
+ let removed = 0;
831
+ for (const w of wraps) {
832
+ // Never decluster pinned placements.
833
+ try {
834
+ if (w.getAttribute && w.getAttribute('data-ezoic-pin') === '1') continue;
835
+ } catch (e) {}
836
+
837
+ let prev = w.previousElementSibling;
838
+ for (let i = 0; i < 3 && prev; i++) {
839
+ if (isWrap(prev)) {
840
+ // If the previous wrap is pinned, keep this one (spacing is intentional).
841
+ try {
842
+ if (prev.getAttribute && prev.getAttribute('data-ezoic-pin') === '1') break;
843
+ } catch (e) {}
844
+
845
+ // Never remove a wrap that is already filled; otherwise it looks like
846
+ // ads "disappear" while scrolling. Only remove the empty neighbour.
847
+ const prevFilled = isFilled(prev);
848
+ const curFilled = isFilled(w);
849
+
850
+ if (curFilled) {
851
+ // If the previous one is empty (and not fresh), drop the previous instead.
852
+ if (!prevFilled && !isFresh(prev)) {
853
+ withInternalDomChange(() => releaseWrapNode(prev));
854
+ removed++;
855
+ }
856
+ break;
857
+ }
858
+
859
+ // Current is empty.
860
+ // Don't decluster two "fresh" empty wraps — it can reduce fill on slow auctions.
861
+ // Only decluster when previous is filled, or when current is stale.
862
+ if (prevFilled || !isFresh(w)) {
863
+ withInternalDomChange(() => releaseWrapNode(w));
864
+ removed++;
865
+ }
866
+ break;
867
+ }
868
+ prev = prev.previousElementSibling;
869
+ }
870
+ }
871
+ return removed;
872
+ }
873
+
718
874
  // ---------------- show (preload / fast fill) ----------------
719
875
 
720
876
  function ensurePreloadObserver() {
@@ -952,6 +1108,9 @@ function buildOrdinalMap(items) {
952
1108
  }
953
1109
 
954
1110
  function injectBetween(kindClass, items, interval, showFirst, allIds, cursorKey) {
1111
+
1112
+ // V5.1: stable anchor dedupe (prevents regrouping while preserving multiple ads)
1113
+
955
1114
  if (!items.length) return 0;
956
1115
 
957
1116
  const { map: ordinalMap, max: maxOrdinal } = buildOrdinalMap(items);
@@ -977,7 +1136,7 @@ function buildOrdinalMap(items) {
977
1136
  if (!id) {
978
1137
  // Only recycle while scrolling down. Recycling while scrolling up tends to
979
1138
  // cause perceived instability (ads bunching/disappearing).
980
- recycledWrap = null;
1139
+ recycledWrap = scrollDir > 0 ? pickRecyclableWrap(kindClass) : null;
981
1140
  if (recycledWrap) {
982
1141
  id = recycledWrap.getAttribute('data-ezoic-wrapid') || '';
983
1142
  }
@@ -1004,7 +1163,7 @@ function buildOrdinalMap(items) {
1004
1163
  }
1005
1164
 
1006
1165
  function moveWrapAfter(anchorEl, wrap, kindClass, afterPos) {
1007
- return;
1166
+ // keep original behavior in base build
1008
1167
  }
1009
1168
 
1010
1169
  async function runCore() {