nodebb-plugin-ezoic-infinite 1.7.89 → 1.7.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nodebb-plugin-ezoic-infinite",
3
- "version": "1.7.89",
3
+ "version": "1.7.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
@@ -77,7 +77,6 @@
77
77
  const A_CREATED = 'data-ezoic-created'; // timestamp création ms
78
78
  const A_SHOWN = 'data-ezoic-shown'; // timestamp dernier showAds ms
79
79
 
80
- const EMPTY_CHECK_MS = 20_000; // délai avant collapse d'un wrap vide post-show
81
80
  const MIN_PRUNE_AGE_MS = 8_000; // délai de grâce avant pruning (stabilisation DOM)
82
81
  const MAX_INSERTS_RUN = 6; // max insertions par appel runCore
83
82
  const MAX_INFLIGHT = 4; // max showAds() simultanés
@@ -127,11 +126,14 @@
127
126
  pending: [], // ids en attente de slot inflight
128
127
  pendingSet: new Set(),
129
128
  wrapByKey: new Map(), // anchorKey → wrap DOM node
129
+ fillObsById: new Map(), // id -> MutationObserver
130
130
  runQueued: false,
131
131
  burstActive: false,
132
132
  burstDeadline: 0,
133
133
  burstCount: 0,
134
134
  lastBurstTs: 0,
135
+ lastScrollY: 0,
136
+ scrollDir: 1, // 1=down, -1=up
135
137
  };
136
138
 
137
139
  let blockedUntil = 0;
@@ -142,6 +144,33 @@
142
144
  const normBool = v => v === true || v === 'true' || v === 1 || v === '1' || v === 'on';
143
145
  const isFilled = n => !!(n?.querySelector?.('iframe, ins, img, video, [data-google-container-id]'));
144
146
 
147
+
148
+ function unwatchPlaceholderFillById(id) {
149
+ const obs = S.fillObsById.get(id);
150
+ if (obs) { try { obs.disconnect(); } catch (_) {} S.fillObsById.delete(id); }
151
+ }
152
+
153
+ function uncollapseIfFilled(ph) {
154
+ try {
155
+ const wrap = ph?.closest?.(`.${WRAP_CLASS}`);
156
+ if (!wrap) return false;
157
+ if (!isFilled(ph)) return false;
158
+ wrap.classList.remove('is-empty');
159
+ return true;
160
+ } catch (_) { return false; }
161
+ }
162
+
163
+ function watchPlaceholderFill(id) {
164
+ const ph = document.getElementById(`${PH_PREFIX}${id}`);
165
+ if (!ph?.isConnected || S.fillObsById.has(id)) return;
166
+ try {
167
+ const obs = new MutationObserver(() => { try { uncollapseIfFilled(ph); } catch (_) {} });
168
+ obs.observe(ph, { childList: true, subtree: true, attributes: true });
169
+ S.fillObsById.set(id, obs);
170
+ uncollapseIfFilled(ph);
171
+ } catch (_) {}
172
+ }
173
+
145
174
  function mutate(fn) {
146
175
  S.mutGuard++;
147
176
  try { fn(); } finally { S.mutGuard--; }
@@ -282,26 +311,24 @@
282
311
  return (w?.isConnected) ? w : null;
283
312
  }
284
313
 
285
- /**
286
- * Libère les ids de wraps supprimés du DOM (virtualisation NodeBB / rerender).
287
- * Sans ce sweep, mountedIds peut conserver des ids fantômes → pool épuisé
288
- * après long scroll alors qu'aucun wrap recyclable n'existe encore en DOM.
289
- */
290
314
  function sweepDeadWraps() {
291
- for (const [key, w] of S.wrapByKey.entries()) {
292
- if (w?.isConnected) continue;
293
- const id = parseInt(w?.getAttribute?.(A_WRAPID), 10);
294
- if (Number.isFinite(id)) {
315
+ let freed = 0;
316
+ for (const [key, wrap] of Array.from(S.wrapByKey.entries())) {
317
+ if (wrap?.isConnected) continue;
318
+ const id = parseInt(wrap?.getAttribute?.(A_WRAPID) || '0', 10);
319
+ if (Number.isFinite(id) && id > 0) {
295
320
  S.mountedIds.delete(id);
321
+ unwatchPlaceholderFillById(id);
296
322
  S.lastShow.delete(id);
297
323
  S.pendingSet.delete(id);
298
324
  }
299
325
  S.wrapByKey.delete(key);
326
+ freed++;
300
327
  }
301
- if (S.pending.length) {
302
- S.pending = S.pending.filter(id => !S.pendingSet.has(id) || document.getElementById(`${PH_PREFIX}${id}`)?.isConnected);
303
- S.pendingSet = new Set(S.pending);
328
+ if (freed && S.pending.length) {
329
+ S.pending = S.pending.filter(id => !S.mountedIds.has(id));
304
330
  }
331
+ return freed;
305
332
  }
306
333
 
307
334
  // ── Pool ───────────────────────────────────────────────────────────────────
@@ -335,26 +362,50 @@
335
362
  typeof ez?.define !== 'function' ||
336
363
  typeof ez?.displayMore !== 'function') return null;
337
364
 
338
- const vh = window.innerHeight || 800;
339
- // Seuil : -1vh (hors viewport visible). On appelle unobserve(ph) juste
340
- // après pour neutraliser l'IO — plus de showAds parasite possible.
341
- const threshold = -vh;
342
- let bestEmpty = null, bestEmptyBottom = Infinity;
343
- let bestFilled = null, bestFilledBottom = Infinity;
365
+ const vh = window.innerHeight || 800;
366
+ const targetRect = targetEl?.getBoundingClientRect?.() || { top: vh, bottom: vh };
367
+
368
+ // Recyclage bidirectionnel :
369
+ // - scroll vers le bas -> recycle préférentiellement des wraps loin au-dessus
370
+ // - scroll vers le haut -> recycle préférentiellement des wraps loin en-dessous
371
+ // Fallback sur l'autre côté si aucun candidat.
372
+ const aboveThreshold = -vh; // wrap entièrement/suffisamment au-dessus
373
+ const belowThreshold = vh * 2; // wrap loin sous le viewport
374
+
375
+ let aboveEmpty = null, aboveEmptyBottom = Infinity;
376
+ let aboveFilled = null, aboveFilledBottom = Infinity;
377
+ let belowEmpty = null, belowEmptyTop = -Infinity;
378
+ let belowFilled = null, belowFilledTop = -Infinity;
344
379
 
345
- document.querySelectorAll(`.${WRAP_CLASS}.${klass}`).forEach(wrap => {
380
+ for (const wrap of S.wrapByKey.values()) {
381
+ if (!wrap?.classList?.contains?.(klass)) continue;
346
382
  try {
383
+ if (!wrap?.isConnected) continue;
347
384
  const rect = wrap.getBoundingClientRect();
348
- if (rect.bottom > threshold) return;
349
- if (!isFilled(wrap)) {
350
- if (rect.bottom < bestEmptyBottom) { bestEmptyBottom = rect.bottom; bestEmpty = wrap; }
351
- } else {
352
- if (rect.bottom < bestFilledBottom) { bestFilledBottom = rect.bottom; bestFilled = wrap; }
385
+
386
+ if (rect.bottom < aboveThreshold) {
387
+ if (!isFilled(wrap)) {
388
+ if (rect.bottom < aboveEmptyBottom) { aboveEmptyBottom = rect.bottom; aboveEmpty = wrap; }
389
+ } else {
390
+ if (rect.bottom < aboveFilledBottom) { aboveFilledBottom = rect.bottom; aboveFilled = wrap; }
391
+ }
392
+ continue;
393
+ }
394
+
395
+ if (rect.top > belowThreshold) {
396
+ if (!isFilled(wrap)) {
397
+ if (rect.top > belowEmptyTop) { belowEmptyTop = rect.top; belowEmpty = wrap; }
398
+ } else {
399
+ if (rect.top > belowFilledTop) { belowFilledTop = rect.top; belowFilled = wrap; }
400
+ }
353
401
  }
354
402
  } catch (_) {}
355
- });
403
+ }
356
404
 
357
- const best = bestEmpty ?? bestFilled;
405
+ const preferBelow = (S.scrollDir < 0) || (targetRect.top < vh * 0.5);
406
+ const pickAbove = () => aboveEmpty ?? aboveFilled;
407
+ const pickBelow = () => belowEmpty ?? belowFilled;
408
+ const best = preferBelow ? (pickBelow() ?? pickAbove()) : (pickAbove() ?? pickBelow());
358
409
  if (!best) return null;
359
410
  const id = parseInt(best.getAttribute(A_WRAPID), 10);
360
411
  if (!Number.isFinite(id)) return null;
@@ -410,6 +461,7 @@
410
461
  mutate(() => el.insertAdjacentElement('afterend', w));
411
462
  S.mountedIds.add(id);
412
463
  S.wrapByKey.set(key, w);
464
+ watchPlaceholderFill(id);
413
465
  return w;
414
466
  }
415
467
 
@@ -418,7 +470,7 @@
418
470
  const ph = w.querySelector(`[id^="${PH_PREFIX}"]`);
419
471
  if (ph instanceof Element) S.io?.unobserve(ph);
420
472
  const id = parseInt(w.getAttribute(A_WRAPID), 10);
421
- if (Number.isFinite(id)) S.mountedIds.delete(id);
473
+ if (Number.isFinite(id)) { S.mountedIds.delete(id); unwatchPlaceholderFillById(id); S.lastShow.delete(id); S.pendingSet.delete(id); }
422
474
  const key = w.getAttribute(A_ANCHOR);
423
475
  if (key && S.wrapByKey.get(key) === w) S.wrapByKey.delete(key);
424
476
  w.remove();
@@ -494,12 +546,7 @@
494
546
  if (findWrap(key)) continue;
495
547
 
496
548
  let id = pickId(poolKey);
497
- if (!id) {
498
- // Réessaie après sweep : des wraps ont pu être retirés du DOM (virtualisation)
499
- // sans passer par dropWrap, laissant des ids fantômes dans mountedIds.
500
- sweepDeadWraps();
501
- id = pickId(poolKey);
502
- }
549
+ if (!id) { sweepDeadWraps(); id = pickId(poolKey); }
503
550
  if (id) {
504
551
  const w = insertAfter(el, id, klass, key);
505
552
  if (w) { observePh(id); inserted++; }
@@ -531,7 +578,9 @@
531
578
 
532
579
  function observePh(id) {
533
580
  const ph = document.getElementById(`${PH_PREFIX}${id}`);
534
- if (ph?.isConnected) try { getIO()?.observe(ph); } catch (_) {}
581
+ if (!ph?.isConnected) return;
582
+ watchPlaceholderFill(id);
583
+ try { getIO()?.observe(ph); } catch (_) {}
535
584
  }
536
585
 
537
586
  function enqueueShow(id) {
@@ -571,6 +620,8 @@
571
620
  const ph = document.getElementById(`${PH_PREFIX}${id}`);
572
621
  if (!ph?.isConnected || isFilled(ph)) { clearTimeout(timer); return release(); }
573
622
 
623
+ try { ph.closest?.(`.${WRAP_CLASS}`)?.classList.remove('is-empty'); } catch (_) {}
624
+
574
625
  const t = ts();
575
626
  if (t - (S.lastShow.get(id) ?? 0) < SHOW_THROTTLE_MS) { clearTimeout(timer); return release(); }
576
627
  S.lastShow.set(id, t);
@@ -581,7 +632,6 @@
581
632
  const ez = window.ezstandalone;
582
633
  const doShow = () => {
583
634
  try { ez.showAds(id); } catch (_) {}
584
- scheduleEmptyCheck(id, t);
585
635
  setTimeout(() => { clearTimeout(timer); release(); }, 700);
586
636
  };
587
637
  Array.isArray(ez.cmd) ? ez.cmd.push(doShow) : doShow();
@@ -589,17 +639,6 @@
589
639
  });
590
640
  }
591
641
 
592
- function scheduleEmptyCheck(id, showTs) {
593
- setTimeout(() => {
594
- try {
595
- const ph = document.getElementById(`${PH_PREFIX}${id}`);
596
- const wrap = ph?.closest?.(`.${WRAP_CLASS}`);
597
- if (!wrap || !ph?.isConnected) return;
598
- if (parseInt(wrap.getAttribute(A_SHOWN) || '0', 10) > showTs) return;
599
- wrap.classList.toggle('is-empty', !isFilled(ph));
600
- } catch (_) {}
601
- }, EMPTY_CHECK_MS);
602
- }
603
642
 
604
643
  // ── Patch Ezoic showAds ────────────────────────────────────────────────────
605
644
  //
@@ -726,6 +765,8 @@
726
765
  S.mountedIds.clear();
727
766
  S.lastShow.clear();
728
767
  S.wrapByKey.clear();
768
+ for (const obs of S.fillObsById.values()) { try { obs.disconnect(); } catch (_) {} }
769
+ S.fillObsById.clear();
729
770
  S.inflight = 0;
730
771
  S.pending = [];
731
772
  S.pendingSet.clear();
@@ -740,26 +781,25 @@
740
781
  const allSel = [SEL.post, SEL.topic, SEL.category];
741
782
  S.domObs = new MutationObserver(muts => {
742
783
  if (S.mutGuard > 0 || isBlocked()) return;
743
- let sawRelevantAdd = false;
744
- let sawWrapRemoval = false;
745
784
  for (const m of muts) {
746
- for (const n of m.removedNodes) {
785
+ let sawWrapRemoval = false;
786
+ for (const n of m.removedNodes || []) {
747
787
  if (n.nodeType !== 1) continue;
748
- if ((n.matches && n.matches(`.${WRAP_CLASS}`)) || n.querySelector?.(`.${WRAP_CLASS}`)) {
749
- sawWrapRemoval = true;
750
- }
788
+ try {
789
+ if (n.matches?.(`.${WRAP_CLASS}`) || n.querySelector?.(`.${WRAP_CLASS}`)) { sawWrapRemoval = true; }
790
+ } catch (_) {}
751
791
  }
792
+ if (sawWrapRemoval) sweepDeadWraps();
793
+
752
794
  for (const n of m.addedNodes) {
753
795
  if (n.nodeType !== 1) continue;
754
796
  // matches() d'abord (O(1)), querySelector() seulement si nécessaire
755
797
  if (allSel.some(s => { try { return n.matches(s); } catch(_){return false;} }) ||
756
798
  allSel.some(s => { try { return !!n.querySelector(s); } catch(_){return false;} })) {
757
- sawRelevantAdd = true;
799
+ requestBurst(); return;
758
800
  }
759
801
  }
760
802
  }
761
- if (sawWrapRemoval) sweepDeadWraps();
762
- if (sawRelevantAdd) requestBurst();
763
803
  });
764
804
  try { S.domObs.observe(document.body, { childList: true, subtree: true }); } catch (_) {}
765
805
  }
@@ -861,6 +901,12 @@
861
901
  function bindScroll() {
862
902
  let ticking = false;
863
903
  window.addEventListener('scroll', () => {
904
+ try {
905
+ const y = window.scrollY || window.pageYOffset || 0;
906
+ const dy = y - (S.lastScrollY || 0);
907
+ if (Math.abs(dy) > 2) S.scrollDir = dy > 0 ? 1 : -1;
908
+ S.lastScrollY = y;
909
+ } catch (_) {}
864
910
  if (ticking) return;
865
911
  ticking = true;
866
912
  requestAnimationFrame(() => { ticking = false; requestBurst(); });
@@ -870,6 +916,7 @@
870
916
  // ── Boot ───────────────────────────────────────────────────────────────────
871
917
 
872
918
  S.pageKey = pageKey();
919
+ try { S.lastScrollY = window.scrollY || window.pageYOffset || 0; } catch (_) { S.lastScrollY = 0; }
873
920
  muteConsole();
874
921
  ensureTcfLocator();
875
922
  warmNetwork();
package/public/style.css CHANGED
@@ -56,20 +56,6 @@
56
56
  top: auto !important;
57
57
  }
58
58
 
59
- /* ── État vide ────────────────────────────────────────────────────────────── */
60
- /*
61
- Ajouté 20s après showAds si aucun fill détecté.
62
- Collapse à 1px (pas 0) : reste observable par l'IO si le fill arrive tard.
63
- */
64
- .nodebb-ezoic-wrap.is-empty {
65
- display: block !important;
66
- height: 1px !important;
67
- min-height: 1px !important;
68
- max-height: 1px !important;
69
- margin: 0 !important;
70
- padding: 0 !important;
71
- overflow: hidden !important;
72
- }
73
59
 
74
60
  /* ── Ezoic global (hors de nos wraps) ────────────────────────────────────── */
75
61
  .ezoic-ad {