nodebb-plugin-ezoic-infinite 1.6.62 → 1.6.64

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 +125 -38
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nodebb-plugin-ezoic-infinite",
3
- "version": "1.6.62",
3
+ "version": "1.6.64",
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
@@ -401,12 +401,23 @@
401
401
  }
402
402
 
403
403
  // ─── Insertion primitives ───────────────────────────────────────────────────
404
- function buildWrap(id, kindClass, afterPos, createPlaceholder) {
404
+
405
+ /** Extract the stable DOM id (tid/pid/cid) from an anchor element. */
406
+ function getAnchorStableId(el) {
407
+ if (!el) return '';
408
+ return el.getAttribute('data-tid') || el.getAttribute('data-pid') ||
409
+ el.getAttribute('data-cid') || el.getAttribute('data-index') || '';
410
+ }
411
+
412
+ function buildWrap(id, kindClass, afterPos, createPlaceholder, anchorStableId) {
405
413
  const wrap = document.createElement('div');
406
414
  wrap.className = WRAP_CLASS + ' ' + kindClass;
407
415
  wrap.setAttribute('data-ezoic-after', String(afterPos));
408
416
  wrap.setAttribute('data-ezoic-wrapid', String(id));
409
417
  wrap.setAttribute('data-created', String(now()));
418
+ // Store the anchor element's stable DOM id so we can re-anchor after
419
+ // NodeBB re-renders the list (which changes ordinal positions).
420
+ if (anchorStableId) wrap.setAttribute('data-ezoic-anchor', String(anchorStableId));
410
421
  if (afterPos === 1) wrap.setAttribute('data-ezoic-pin', '1');
411
422
  wrap.style.width = '100%';
412
423
  if (createPlaceholder) {
@@ -418,14 +429,27 @@
418
429
  return wrap;
419
430
  }
420
431
 
432
+ /**
433
+ * Find a wrap that is already anchored to a specific stable id (tid/pid/cid).
434
+ * Used to prevent double-injection when NodeBB re-renders and ordinals shift.
435
+ */
436
+ function findWrapByAnchor(kindClass, anchorStableId) {
437
+ if (!anchorStableId) return null;
438
+ return document.querySelector(
439
+ '.' + WRAP_CLASS + '.' + kindClass + '[data-ezoic-anchor="' + CSS.escape(String(anchorStableId)) + '"]'
440
+ );
441
+ }
442
+
421
443
  function insertAfter(target, id, kindClass, afterPos) {
422
444
  if (!target || !target.insertAdjacentElement) return null;
423
445
  if (findWrap(kindClass, afterPos)) return null;
446
+ const anchorId = getAnchorStableId(target);
447
+ if (anchorId && findWrapByAnchor(kindClass, anchorId)) return null; // already anchored
424
448
  if (insertingIds.has(id)) return null;
425
449
  const existingPh = document.getElementById(PLACEHOLDER_PREFIX + id);
426
450
  insertingIds.add(id);
427
451
  try {
428
- const wrap = buildWrap(id, kindClass, afterPos, !existingPh);
452
+ const wrap = buildWrap(id, kindClass, afterPos, !existingPh, anchorId);
429
453
  target.insertAdjacentElement('afterend', wrap);
430
454
  if (existingPh) {
431
455
  existingPh.setAttribute('data-ezoic-id', String(id));
@@ -469,23 +493,25 @@
469
493
  if (!items || !items.length) return 0;
470
494
  const itemSet = new Set(items);
471
495
  const isMessage = kindClass === 'ezoic-ad-message';
472
- // Between-ads and category-ads are NEVER fully released — only hidden/shown.
473
- // Releasing them frees their placeholder IDs back into the pool. When the
474
- // anchor topics return (scroll-up / NodeBB re-render), injectBetween would
475
- // re-insert those IDs, but DOM ordinals have shifted → all wraps pile at top.
476
- // Keeping the wrap node in DOM at its original position means findWrap() will
477
- // always find it and injectBetween() will never create a duplicate.
496
+ // Between/category ads are NEVER fully released — only hidden/shown.
478
497
  const allowRelease = isMessage;
479
498
  let removed = 0;
480
499
 
481
- const hasNearbyItem = (wrap) => {
482
- // If the wrap is inside a li.nodebb-ezoic-host, we must check the HOST's
483
- // siblings, not the wrap's own siblings (the wrap has no siblings inside host).
500
+ // Build a set of stable anchor ids currently in the DOM (tid/pid/cid).
501
+ const liveAnchorIds = new Set();
502
+ items.forEach((el) => {
503
+ const sid = getAnchorStableId(el);
504
+ if (sid) liveAnchorIds.add(sid);
505
+ });
506
+
507
+ const anchorIsLive = (wrap) => {
508
+ // Primary: check by stable id (survives NodeBB re-renders).
509
+ const sid = wrap.getAttribute('data-ezoic-anchor');
510
+ if (sid) return liveAnchorIds.has(sid);
511
+ // Fallback: DOM-proximity check (for wraps without stable id).
484
512
  const pivot = (wrap.parentElement && wrap.parentElement.classList &&
485
513
  wrap.parentElement.classList.contains(HOST_CLASS))
486
- ? wrap.parentElement
487
- : wrap;
488
-
514
+ ? wrap.parentElement : wrap;
489
515
  let prev = pivot.previousElementSibling;
490
516
  for (let i = 0; i < 14 && prev; i++) {
491
517
  if (itemSet.has(prev)) return true;
@@ -500,31 +526,24 @@
500
526
  };
501
527
 
502
528
  document.querySelectorAll('.' + WRAP_CLASS + '.' + kindClass).forEach((wrap) => {
503
- // Never touch pinned placements.
504
529
  if (wrap.getAttribute('data-ezoic-pin') === '1') return;
505
-
506
- // Never prune very fresh wraps (slow auction / CMP fills).
507
530
  const created = parseInt(wrap.getAttribute('data-created') || '0', 10);
508
531
  if (created && (now() - created) < keepEmptyWrapMs()) return;
509
532
 
510
- if (hasNearbyItem(wrap)) {
511
- // Anchor back in DOM → restore visibility.
533
+ if (anchorIsLive(wrap)) {
512
534
  try { wrap.classList.remove('ez-orphan-hidden'); wrap.style.display = ''; } catch (e) {}
513
535
  return;
514
536
  }
515
537
 
516
- // Anchor is gone (virtualized) → hide so ads don't stack visually.
538
+ // Anchor not in DOM → hide.
517
539
  try { wrap.classList.add('ez-orphan-hidden'); wrap.style.display = 'none'; } catch (e) {}
518
-
519
- // For between/category ads: stop here — never release the node.
520
- // The wrap stays in DOM (hidden) at its correct data-ezoic-after position.
521
540
  if (!allowRelease) return;
522
541
 
523
- // For message-ads only: fully release when far off-screen.
542
+ // Message-ads only: release when far off-screen.
524
543
  try {
525
544
  const r = wrap.getBoundingClientRect();
526
545
  const vh = Math.max(1, window.innerHeight || 1);
527
- if (r.bottom > -vh * 2 && r.top < vh * 4) return; // still near viewport
546
+ if (r.bottom > -vh * 2 && r.top < vh * 4) return;
528
547
  } catch (e) { return; }
529
548
 
530
549
  withInternalDomChange(() => releaseWrapNode(wrap));
@@ -533,6 +552,55 @@
533
552
  return removed;
534
553
  }
535
554
 
555
+ /**
556
+ * Re-anchor between/category wraps after a NodeBB list re-render.
557
+ *
558
+ * When NodeBB rebuilds the topic list (infinite scroll above, sort change…),
559
+ * it re-inserts topic <li> elements in a new DOM order. Our wraps are still
560
+ * in the DOM but they may now sit between wrong topics because their ordinals
561
+ * changed. We use the stable anchor id (data-tid / data-pid) recorded at
562
+ * insertion time to find each wrap's correct anchor topic and move it there.
563
+ */
564
+ function reanchorWraps(kindClass, items) {
565
+ // Build tid → element map from the current live items.
566
+ const tidMap = new Map();
567
+ items.forEach((el) => {
568
+ const sid = getAnchorStableId(el);
569
+ if (sid) tidMap.set(sid, el);
570
+ });
571
+
572
+ document.querySelectorAll('.' + WRAP_CLASS + '.' + kindClass).forEach((wrap) => {
573
+ try {
574
+ const sid = wrap.getAttribute('data-ezoic-anchor');
575
+ if (!sid) return;
576
+ const anchor = tidMap.get(sid);
577
+ if (!anchor || !anchor.isConnected) return;
578
+
579
+ // Check if the wrap is already correctly positioned (immediately after anchor).
580
+ // Account for li.nodebb-ezoic-host wrapper.
581
+ const wrapOrHost = (wrap.parentElement && wrap.parentElement.classList &&
582
+ wrap.parentElement.classList.contains(HOST_CLASS))
583
+ ? wrap.parentElement : wrap;
584
+
585
+ if (wrapOrHost.previousElementSibling === anchor) {
586
+ // Already in the right place — just un-hide if it was hidden.
587
+ try { wrap.classList.remove('ez-orphan-hidden'); wrap.style.display = ''; } catch (e) {}
588
+ return;
589
+ }
590
+
591
+ // Update data-ezoic-after to the anchor's current ordinal so findWrap still works.
592
+ const newOrdinal = getAnchorStableId(anchor)
593
+ ? (parseInt(anchor.getAttribute('data-index') || '-1', 10) + 1) || 0
594
+ : 0;
595
+ if (newOrdinal > 0) wrap.setAttribute('data-ezoic-after', String(newOrdinal));
596
+
597
+ // Move the wrap (or its host) to after the anchor.
598
+ anchor.insertAdjacentElement('afterend', wrapOrHost);
599
+ try { wrap.classList.remove('ez-orphan-hidden'); wrap.style.display = ''; } catch (e) {}
600
+ } catch (e) {}
601
+ });
602
+ }
603
+
536
604
  function decluster(kindClass) {
537
605
  const wraps = Array.from(document.querySelectorAll('.' + WRAP_CLASS + '.' + kindClass));
538
606
  if (wraps.length < 2) return 0;
@@ -965,30 +1033,49 @@
965
1033
  const kind = getKind();
966
1034
  let inserted = 0;
967
1035
 
1036
+ // When the user is scrolling UP, NodeBB loads content above the viewport.
1037
+ // Injecting new ad wraps at that moment targets those freshly-loaded top items
1038
+ // (low ordinals) and makes ads appear right at the top of the list.
1039
+ // While scrolling up we only restore previously-hidden wraps (pruneOrphanWraps
1040
+ // un-hides them as their anchor posts return) — no new injections.
1041
+ const canInject = scrollDir >= 0;
1042
+
968
1043
  if (kind === 'topic' && normalizeBool(cfg.enableMessageAds)) {
969
1044
  const items = getPostContainers();
970
1045
  pruneOrphanWraps('ezoic-ad-message', items);
971
- inserted += injectBetween('ezoic-ad-message', items,
972
- Math.max(1, parseInt(cfg.messageIntervalPosts, 10) || 3),
973
- normalizeBool(cfg.showFirstMessageAd), state.allPosts, 'curPosts');
974
- decluster('ezoic-ad-message');
1046
+ if (canInject) {
1047
+ inserted += injectBetween('ezoic-ad-message', items,
1048
+ Math.max(1, parseInt(cfg.messageIntervalPosts, 10) || 3),
1049
+ normalizeBool(cfg.showFirstMessageAd), state.allPosts, 'curPosts');
1050
+ decluster('ezoic-ad-message');
1051
+ }
975
1052
 
976
1053
  } else if (kind === 'categoryTopics' && normalizeBool(cfg.enableBetweenAds)) {
977
1054
  const items = getTopicItems();
1055
+ // Re-anchor FIRST: move existing wraps to their correct topic after any
1056
+ // NodeBB list re-render (ordinals may have shifted). This must happen before
1057
+ // pruneOrphanWraps (which uses live anchor ids) and before injectBetween
1058
+ // (which checks findWrap/findWrapByAnchor to avoid duplicates).
1059
+ reanchorWraps('ezoic-ad-between', items);
978
1060
  pruneOrphanWraps('ezoic-ad-between', items);
979
- inserted += injectBetween('ezoic-ad-between', items,
980
- Math.max(1, parseInt(cfg.intervalPosts, 10) || 6),
981
- normalizeBool(cfg.showFirstTopicAd), state.allTopics, 'curTopics');
982
- decluster('ezoic-ad-between');
983
- schedulePileFix();
1061
+ if (canInject) {
1062
+ inserted += injectBetween('ezoic-ad-between', items,
1063
+ Math.max(1, parseInt(cfg.intervalPosts, 10) || 6),
1064
+ normalizeBool(cfg.showFirstTopicAd), state.allTopics, 'curTopics');
1065
+ decluster('ezoic-ad-between');
1066
+ schedulePileFix();
1067
+ }
984
1068
 
985
1069
  } else if (kind === 'categories' && normalizeBool(cfg.enableCategoryAds)) {
986
1070
  const items = getCategoryItems();
1071
+ reanchorWraps('ezoic-ad-categories', items);
987
1072
  pruneOrphanWraps('ezoic-ad-categories', items);
988
- inserted += injectBetween('ezoic-ad-categories', items,
989
- Math.max(1, parseInt(cfg.intervalCategories, 10) || 4),
990
- normalizeBool(cfg.showFirstCategoryAd), state.allCategories, 'curCategories');
991
- decluster('ezoic-ad-categories');
1073
+ if (canInject) {
1074
+ inserted += injectBetween('ezoic-ad-categories', items,
1075
+ Math.max(1, parseInt(cfg.intervalCategories, 10) || 4),
1076
+ normalizeBool(cfg.showFirstCategoryAd), state.allCategories, 'curCategories');
1077
+ decluster('ezoic-ad-categories');
1078
+ }
992
1079
  }
993
1080
 
994
1081
  return inserted;