nodebb-plugin-ezoic-infinite 1.6.97 → 1.6.99

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/library.js CHANGED
@@ -7,60 +7,124 @@ const db = require.main.require('./src/database');
7
7
  const SETTINGS_KEY = 'ezoic-infinite';
8
8
  const plugin = {};
9
9
 
10
- async function getSettings() {
11
- return await meta.settings.get(SETTINGS_KEY);
10
+ function normalizeExcludedGroups(value) {
11
+ if (!value) return [];
12
+ if (Array.isArray(value)) return value;
13
+ return String(value).split(',').map(s => s.trim()).filter(Boolean);
12
14
  }
13
15
 
14
- async function isUserExcluded(uid, excludedGroups) {
15
- if (!uid || uid <= 0) return false;
16
- if (!excludedGroups || !excludedGroups.length) return false;
17
- const userGroups = await groups.getUserGroupsNames([uid]);
18
- return excludedGroups.some(g => userGroups[0].includes(g));
16
+ function parseBool(v, def = false) {
17
+ if (v === undefined || v === null || v === '') return def;
18
+ if (typeof v === 'boolean') return v;
19
+ const s = String(v).toLowerCase();
20
+ return s === '1' || s === 'true' || s === 'on' || s === 'yes';
19
21
  }
20
22
 
21
23
  async function getAllGroups() {
22
- let names = await db.getSortedSetRange('groups:createtime', 0, -1);
23
- const data = await groups.getGroupsData(names);
24
- return data.filter(g => g && g.name).map(g => ({ name: g.name }));
24
+ let names = await db.getSortedSetRange('groups:createtime', 0, -1);
25
+ if (!names || !names.length) {
26
+ names = await db.getSortedSetRange('groups:visible:createtime', 0, -1);
27
+ }
28
+ const filtered = names.filter(name => !groups.isPrivilegeGroup(name));
29
+ const data = await groups.getGroupsData(filtered);
30
+ // Filter out nulls (groups deleted between the sorted-set read and getGroupsData)
31
+ const valid = data.filter(g => g && g.name);
32
+ valid.sort((a, b) => String(a.name).localeCompare(String(b.name), undefined, { sensitivity: 'base' }));
33
+ return valid;
25
34
  }
35
+ let _settingsCache = null;
36
+ let _settingsCacheAt = 0;
37
+ const SETTINGS_TTL = 30000; // 30s
26
38
 
27
- plugin.init = async ({ router, middleware }) => {
28
- async function render(req, res) {
29
- const settings = await getSettings();
30
- const allGroups = await getAllGroups();
31
- res.render('admin/plugins/ezoic-infinite', {
32
- ...settings,
33
- allGroups,
34
- });
35
- }
36
-
37
- router.get('/admin/plugins/ezoic-infinite', middleware.admin.buildHeader, render);
38
- router.get('/api/admin/plugins/ezoic-infinite', render);
39
-
40
- router.get('/api/plugins/ezoic-infinite/config', async (req, res) => {
41
- const settings = await getSettings();
42
- const excluded = await isUserExcluded(req.uid, settings.excludedGroups || []);
43
- res.json({
44
- excluded,
45
- enableCategoryAds: settings.enableCategoryAds === 'on',
46
- showFirstCategoryAd: settings.showFirstCategoryAd === 'on',
47
- categoryPlaceholderIds: settings.categoryPlaceholderIds,
48
- intervalCategories: parseInt(settings.intervalCategories, 10) || 5,
49
- enableBetweenAds: settings.enableBetweenAds === 'on',
50
- showFirstTopicAd: settings.showFirstTopicAd === 'on',
51
- placeholderIds: settings.placeholderIds,
52
- intervalPosts: parseInt(settings.intervalPosts, 10) || 10,
53
- enableMessageAds: settings.enableMessageAds === 'on',
54
- showFirstMessageAd: settings.showFirstMessageAd === 'on',
55
- messagePlaceholderIds: settings.messagePlaceholderIds,
56
- messageIntervalPosts: parseInt(settings.messageIntervalPosts, 10) || 10,
57
- });
58
- });
39
+ async function getSettings() {
40
+ const now = Date.now();
41
+ if (_settingsCache && (now - _settingsCacheAt) < SETTINGS_TTL) return _settingsCache;
42
+ const s = await meta.settings.get(SETTINGS_KEY);
43
+ _settingsCacheAt = Date.now();
44
+ _settingsCache = {
45
+ // Between-post ads (simple blocks) in category topic list
46
+ enableBetweenAds: parseBool(s.enableBetweenAds, true),
47
+ showFirstTopicAd: parseBool(s.showFirstTopicAd, false),
48
+ placeholderIds: (s.placeholderIds || '').trim(),
49
+ intervalPosts: Math.max(1, parseInt(s.intervalPosts, 10) || 6),
50
+
51
+ // Home/categories list ads (between categories on / or /categories)
52
+ enableCategoryAds: parseBool(s.enableCategoryAds, false),
53
+ showFirstCategoryAd: parseBool(s.showFirstCategoryAd, false),
54
+ categoryPlaceholderIds: (s.categoryPlaceholderIds || '').trim(),
55
+ intervalCategories: Math.max(1, parseInt(s.intervalCategories, 10) || 4),
56
+
57
+ // "Ad message" between replies (looks like a post)
58
+ enableMessageAds: parseBool(s.enableMessageAds, false),
59
+ showFirstMessageAd: parseBool(s.showFirstMessageAd, false),
60
+ messagePlaceholderIds: (s.messagePlaceholderIds || '').trim(),
61
+ messageIntervalPosts: Math.max(1, parseInt(s.messageIntervalPosts, 10) || 3),
62
+
63
+ excludedGroups: normalizeExcludedGroups(s.excludedGroups),
64
+ };
65
+ return _settingsCache;
66
+ }
67
+
68
+ async function isUserExcluded(uid, excludedGroups) {
69
+ if (!uid || !excludedGroups.length) return false;
70
+ const userGroups = await groups.getUserGroups([uid]);
71
+ return (userGroups[0] || []).some(g => excludedGroups.includes(g.name));
72
+ }
73
+
74
+ plugin.onSettingsSet = function (data) {
75
+ // Invalider le cache dès que les settings de ce plugin sont sauvegardés via l'ACP
76
+ if (data && data.hash === SETTINGS_KEY) {
77
+ _settingsCache = null;
78
+ }
59
79
  };
60
80
 
61
81
  plugin.addAdminNavigation = async (header) => {
62
- header.plugins.push({ route: '/plugins/ezoic-infinite', icon: 'fa-ad', name: 'Ezoic Infinite' });
63
- return header;
82
+ header.plugins = header.plugins || [];
83
+ header.plugins.push({
84
+ route: '/plugins/ezoic-infinite',
85
+ icon: 'fa-ad',
86
+ name: 'Ezoic Infinite Ads'
87
+ });
88
+ return header;
89
+ };
90
+
91
+ plugin.init = async ({ router, middleware }) => {
92
+ async function render(req, res) {
93
+ const settings = await getSettings();
94
+ const allGroups = await getAllGroups();
95
+
96
+ res.render('admin/plugins/ezoic-infinite', {
97
+ title: 'Ezoic Infinite Ads',
98
+ ...settings,
99
+ enableBetweenAds_checked: settings.enableBetweenAds ? 'checked' : '',
100
+ enableMessageAds_checked: settings.enableMessageAds ? 'checked' : '',
101
+ allGroups,
102
+ });
103
+ }
104
+
105
+ router.get('/admin/plugins/ezoic-infinite', middleware.admin.buildHeader, render);
106
+ router.get('/api/admin/plugins/ezoic-infinite', render);
107
+
108
+ router.get('/api/plugins/ezoic-infinite/config', async (req, res) => {
109
+ const settings = await getSettings();
110
+ const excluded = await isUserExcluded(req.uid, settings.excludedGroups);
111
+
112
+ res.json({
113
+ excluded,
114
+ enableBetweenAds: settings.enableBetweenAds,
115
+ showFirstTopicAd: settings.showFirstTopicAd,
116
+ placeholderIds: settings.placeholderIds,
117
+ intervalPosts: settings.intervalPosts,
118
+ enableCategoryAds: settings.enableCategoryAds,
119
+ showFirstCategoryAd: settings.showFirstCategoryAd,
120
+ categoryPlaceholderIds: settings.categoryPlaceholderIds,
121
+ intervalCategories: settings.intervalCategories,
122
+ enableMessageAds: settings.enableMessageAds,
123
+ showFirstMessageAd: settings.showFirstMessageAd,
124
+ messagePlaceholderIds: settings.messagePlaceholderIds,
125
+ messageIntervalPosts: settings.messageIntervalPosts,
126
+ });
127
+ });
64
128
  };
65
129
 
66
- module.exports = plugin;
130
+ module.exports = plugin;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nodebb-plugin-ezoic-infinite",
3
- "version": "1.6.97",
3
+ "version": "1.6.99",
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,135 +1,823 @@
1
+ /**
2
+ * NodeBB Ezoic Infinite Ads — client.js (v18)
3
+ *
4
+ * Corrections majeures vs v17 :
5
+ * 1. ANCRAGE PAR pid/tid (data-pid, data-index) au lieu d'ordinalMap fragile.
6
+ * → Le même post garde toujours le même ID de wrap, quelle que soit la virtualisation.
7
+ * 2. CLEANUP COMPLET à chaque navigation ajaxify (wraps + curseurs + état pools).
8
+ * → Supprime les fantômes qui se réaffichaient après scroll up/down.
9
+ * 3. DEDUPLICATION par ancrage : on vérifie `data-ezoic-anchor` avant d'insérer.
10
+ * → Empêche la création de doublons lors de multiples passes DOM.
11
+ * 4. PAS de recyclage de wraps (moveWrapAfter supprimé).
12
+ * → La cause n°1 des pubs "qui sautent n'importe où".
13
+ * 5. pruneOrphanWraps simplifié : suppression réelle (remove) au lieu de hide.
14
+ * → Un wrap orphelin = mort, pas caché. Libère l'id dans le curseur.
15
+ * 6. Déduplication de l'id Ezoic par wrap : un id ne peut être monté qu'une fois.
16
+ * 7. V17 pile-fix SUPPRIMÉ (was conflicting with main logic).
17
+ * 8. Factorisation : helpers partagés, pas de duplication de logique entre kinds.
18
+ */
1
19
  (function () {
2
20
  'use strict';
3
21
 
4
- if (window.ezInfiniteInjected) return;
5
- window.ezInfiniteInjected = true;
6
-
7
- const WRAP_CLASS = 'nodebb-ezoic-wrap';
8
- const POOL_ID = 'nodebb-ezoic-placeholder-pool';
9
- let config = null;
10
- let isInternalChange = false;
11
- let activeIds = new Set();
12
-
13
- function getPool() {
14
- let p = document.getElementById(POOL_ID);
15
- if (!p) {
16
- p = document.createElement('div');
17
- p.id = POOL_ID;
18
- p.style.display = 'none';
19
- document.body.appendChild(p);
22
+ // ─── Constants ────────────────────────────────────────────────────────────
23
+ const WRAP_CLASS = 'nodebb-ezoic-wrap';
24
+ const PLACEHOLDER_PREFIX = 'ezoic-pub-ad-placeholder-';
25
+ const ANCHOR_ATTR = 'data-ezoic-anchor'; // unique key = kind:anchorId
26
+ const WRAPID_ATTR = 'data-ezoic-wrapid';
27
+ const CREATED_ATTR = 'data-ezoic-created';
28
+
29
+ const MAX_INSERTS_PER_RUN = 6;
30
+ const EMPTY_WRAP_TTL_MS = 90_000; // 90 s avant de colapser un wrap vide
31
+ const FILL_WATCH_MS = 7_000;
32
+
33
+ const PRELOAD_MARGIN = {
34
+ desktop: '2000px 0px 2000px 0px',
35
+ mobile: '3000px 0px 3000px 0px',
36
+ desktopBoosted:'4500px 0px 4500px 0px',
37
+ mobileBoosted: '4500px 0px 4500px 0px',
38
+ };
39
+ const BOOST_DURATION_MS = 2500;
40
+ const BOOST_SPEED_PX_PER_MS = 2.2;
41
+ const MAX_INFLIGHT_DESKTOP = 4;
42
+ const MAX_INFLIGHT_MOBILE = 3;
43
+ const SHOW_THROTTLE_MS = 900;
44
+
45
+ const SELECTORS = {
46
+ topicItem: 'li[component="category/topic"]',
47
+ postItem: '[component="post"][data-pid]',
48
+ categoryItem: 'li[component="categories/category"]',
49
+ };
50
+
51
+ // ─── Helpers ──────────────────────────────────────────────────────────────
52
+ const now = () => Date.now();
53
+ const isMobile = () => { try { return window.innerWidth < 768; } catch (_) { return false; } };
54
+ const normBool = (v) => v === true || v === 'true' || v === 1 || v === '1' || v === 'on';
55
+
56
+ function uniqInts(raw) {
57
+ const out = [], seen = new Set();
58
+ for (const v of String(raw).split(/\r?\n/).map(s => s.trim()).filter(Boolean)) {
59
+ const n = parseInt(v, 10);
60
+ if (Number.isFinite(n) && n > 0 && !seen.has(n)) { seen.add(n); out.push(n); }
61
+ }
62
+ return out;
63
+ }
64
+
65
+ function isFilledNode(node) {
66
+ return !!(node?.querySelector?.('iframe, ins, img, video, [data-google-container-id]'));
67
+ }
68
+
69
+ // ─── State ────────────────────────────────────────────────────────────────
70
+ const state = {
71
+ pageKey: null,
72
+ cfg: null,
73
+
74
+ // ID pools + curseurs rotatifs
75
+ pools: { topics: [], posts: [], categories: [] },
76
+ cursors: { topics: 0, posts: 0, categories: 0 },
77
+
78
+ // Suivi des IDs Ezoic actuellement montés dans le DOM (évite les doublons)
79
+ mountedIds: new Set(),
80
+
81
+ // Throttle par id
82
+ lastShowById: new Map(),
83
+
84
+ // Observers
85
+ domObs: null,
86
+ io: null,
87
+ ioMargin: null,
88
+
89
+ // Guard contre nos propres mutations
90
+ internalMutation: 0,
91
+
92
+ // File de show
93
+ inflight: 0,
94
+ pending: [],
95
+ pendingSet: new Set(),
96
+
97
+ // Scroll boost
98
+ scrollBoostUntil: 0,
99
+ lastScrollY: 0,
100
+ lastScrollTs: 0,
101
+
102
+ // Scheduler
103
+ runQueued: false,
104
+ burstActive: false,
105
+ burstDeadline: 0,
106
+ burstCount: 0,
107
+ lastBurstReqTs: 0,
108
+ };
109
+
110
+ let blockedUntil = 0;
111
+ const isBlocked = () => now() < blockedUntil;
112
+ const isBoosted = () => now() < state.scrollBoostUntil;
113
+
114
+ function withInternalMutation(fn) {
115
+ state.internalMutation++;
116
+ try { fn(); } finally { state.internalMutation--; }
117
+ }
118
+
119
+ // ─── Config ───────────────────────────────────────────────────────────────
120
+ async function fetchConfig() {
121
+ if (state.cfg) return state.cfg;
122
+ try {
123
+ const res = await fetch('/api/plugins/ezoic-infinite/config', { credentials: 'same-origin' });
124
+ if (!res.ok) return null;
125
+ state.cfg = await res.json();
126
+ } catch (_) { state.cfg = null; }
127
+ return state.cfg;
128
+ }
129
+
130
+ function initPools(cfg) {
131
+ if (!cfg) return;
132
+ state.pools.topics = uniqInts(cfg.placeholderIds || '');
133
+ state.pools.posts = uniqInts(cfg.messagePlaceholderIds || '');
134
+ state.pools.categories = uniqInts(cfg.categoryPlaceholderIds || '');
135
+ // Ne pas réinitialiser les curseurs ici (ils sont remis à 0 dans cleanup).
136
+ }
137
+
138
+ // ─── Page / Kind ──────────────────────────────────────────────────────────
139
+ function getPageKey() {
140
+ try {
141
+ const ax = window.ajaxify?.data;
142
+ if (ax?.tid) return `topic:${ax.tid}`;
143
+ if (ax?.cid) return `cid:${ax.cid}:${location.pathname}`;
144
+ } catch (_) {}
145
+ return location.pathname;
146
+ }
147
+
148
+ function getKind() {
149
+ const p = location.pathname;
150
+ if (/^\/topic\//.test(p)) return 'topic';
151
+ if (/^\/category\//.test(p)) return 'categoryTopics';
152
+ if (p === '/' || /^\/categories/.test(p)) return 'categories';
153
+ if (document.querySelector(SELECTORS.categoryItem)) return 'categories';
154
+ if (document.querySelector(SELECTORS.postItem)) return 'topic';
155
+ if (document.querySelector(SELECTORS.topicItem)) return 'categoryTopics';
156
+ return 'other';
157
+ }
158
+
159
+ // ─── DOM helpers ──────────────────────────────────────────────────────────
160
+ function getPostContainers() {
161
+ return Array.from(document.querySelectorAll(SELECTORS.postItem)).filter(el => {
162
+ if (!el.isConnected) return false;
163
+ if (!el.querySelector('[component="post/content"]')) return false;
164
+ const parentPost = el.parentElement?.closest('[component="post"][data-pid]');
165
+ if (parentPost && parentPost !== el) return false;
166
+ if (el.getAttribute('component') === 'post/parent') return false;
167
+ return true;
168
+ });
169
+ }
170
+
171
+ function getTopicItems() { return Array.from(document.querySelectorAll(SELECTORS.topicItem)); }
172
+ function getCategoryItems() { return Array.from(document.querySelectorAll(SELECTORS.categoryItem)); }
173
+
174
+ /**
175
+ * Calcule l'ancre stable d'un élément : on préfère data-pid (posts) puis
176
+ * data-index, sinon on retombe sur l'index dans le tableau.
177
+ * La clé est préfixée par kindClass pour éviter les collisions.
178
+ */
179
+ function getAnchorKey(kindClass, el, fallbackIndex) {
180
+ const pid = el.getAttribute('data-pid');
181
+ const index = el.getAttribute('data-index') ?? el.getAttribute('data-idx');
182
+ const id = pid ?? index ?? String(fallbackIndex);
183
+ return `${kindClass}:${id}`;
184
+ }
185
+
186
+ function findWrapByAnchor(anchorKey) {
187
+ return document.querySelector(`.${WRAP_CLASS}[${ANCHOR_ATTR}="${CSS.escape(anchorKey)}"]`);
188
+ }
189
+
190
+ function hasAdjacentWrap(el) {
191
+ const next = el.nextElementSibling;
192
+ if (next?.classList?.contains(WRAP_CLASS)) return true;
193
+ const prev = el.previousElementSibling;
194
+ if (prev?.classList?.contains(WRAP_CLASS)) return true;
195
+ return false;
196
+ }
197
+
198
+ // ─── ID pool / rotation ───────────────────────────────────────────────────
199
+ /**
200
+ * Retourne le prochain id disponible du pool (non déjà monté dans le DOM),
201
+ * en avançant le curseur rotatif.
202
+ */
203
+ function pickId(poolKey) {
204
+ const pool = state.pools[poolKey];
205
+ const n = pool.length;
206
+ if (!n) return null;
207
+
208
+ for (let tries = 0; tries < n; tries++) {
209
+ const idx = state.cursors[poolKey] % n;
210
+ state.cursors[poolKey] = (state.cursors[poolKey] + 1) % n;
211
+ const id = pool[idx];
212
+ if (!state.mountedIds.has(id)) return id;
20
213
  }
21
- return p;
214
+ return null; // Tous les IDs déjà montés
22
215
  }
23
216
 
24
- function callEzoic(id) {
25
- if (typeof window.ezstandalone === 'undefined') return;
26
- const pid = parseInt(id, 10);
27
- if (isNaN(pid)) return;
217
+ // ─── Wrap construction ────────────────────────────────────────────────────
218
+ function buildWrap(id, kindClass, anchorKey) {
219
+ const wrap = document.createElement('div');
220
+ wrap.className = `${WRAP_CLASS} ${kindClass}`;
221
+ wrap.setAttribute(ANCHOR_ATTR, anchorKey);
222
+ wrap.setAttribute(WRAPID_ATTR, String(id));
223
+ wrap.setAttribute(CREATED_ATTR, String(now()));
224
+ wrap.style.cssText = 'width:100%;display:block;';
28
225
 
29
- window.ezstandalone.cmd.push(function() {
30
- window.ezstandalone.define(pid);
31
- if (!window.ezstandalone.enabled) {
32
- window.ezstandalone.enable();
33
- window.ezstandalone.showAds();
226
+ const ph = document.createElement('div');
227
+ ph.id = `${PLACEHOLDER_PREFIX}${id}`;
228
+ ph.setAttribute('data-ezoic-id', String(id));
229
+ wrap.appendChild(ph);
230
+
231
+ return wrap;
232
+ }
233
+
234
+ function insertWrapAfter(el, id, kindClass, anchorKey) {
235
+ if (!el?.insertAdjacentElement) return null;
236
+ if (findWrapByAnchor(anchorKey)) return null; // déjà présent
237
+ if (state.mountedIds.has(id)) return null; // id déjà monté
238
+
239
+ const existingPh = document.getElementById(`${PLACEHOLDER_PREFIX}${id}`);
240
+ if (existingPh?.isConnected) {
241
+ // Cet id a déjà un placeholder dans le DOM → on ne peut pas le dupliquer
242
+ return null;
243
+ }
244
+
245
+ const wrap = buildWrap(id, kindClass, anchorKey);
246
+ withInternalMutation(() => el.insertAdjacentElement('afterend', wrap));
247
+ state.mountedIds.add(id);
248
+ return wrap;
249
+ }
250
+
251
+ function removeWrap(wrap) {
252
+ try {
253
+ const id = parseInt(wrap.getAttribute(WRAPID_ATTR), 10);
254
+ if (Number.isFinite(id)) state.mountedIds.delete(id);
255
+ wrap.remove();
256
+ } catch (_) {}
257
+ }
258
+
259
+ // ─── Prune & Decluster ────────────────────────────────────────────────────
260
+ /**
261
+ * Supprime les wraps dont l'élément-ancre n'est plus dans le DOM.
262
+ * On supprime proprement (remove) plutôt que de cacher → libère l'id.
263
+ */
264
+ function pruneOrphans(kindClass) {
265
+ const wraps = document.querySelectorAll(`.${WRAP_CLASS}.${kindClass}`);
266
+ let removed = 0;
267
+
268
+ wraps.forEach(wrap => {
269
+ // Ne jamais pruner un wrap tout juste créé (fill lent côté Ezoic)
270
+ const created = parseInt(wrap.getAttribute(CREATED_ATTR) || '0', 10);
271
+ if (created && (now() - created) < EMPTY_WRAP_TTL_MS) return;
272
+
273
+ const anchorKey = wrap.getAttribute(ANCHOR_ATTR);
274
+ if (!anchorKey) {
275
+ withInternalMutation(() => removeWrap(wrap));
276
+ removed++;
277
+ return;
278
+ }
279
+
280
+ // L'ancre existe-t-elle encore dans le DOM ?
281
+ const [, anchorId] = anchorKey.split(':');
282
+ const isPost = kindClass === 'ezoic-ad-message';
283
+ let anchorEl = null;
284
+
285
+ if (isPost) {
286
+ anchorEl = document.querySelector(`[component="post"][data-pid="${CSS.escape(anchorId)}"]`);
34
287
  } else {
35
- window.ezstandalone.showAds(pid);
288
+ anchorEl = document.querySelector(`${SELECTORS.topicItem}[data-index="${CSS.escape(anchorId)}"]`)
289
+ ?? document.querySelector(`${SELECTORS.categoryItem}[data-index="${CSS.escape(anchorId)}"]`);
290
+ }
291
+
292
+ if (!anchorEl || !anchorEl.isConnected) {
293
+ // Ancre disparue → si rempli on garde (scrolling back), si vide on supprime
294
+ if (!isFilledNode(wrap)) {
295
+ withInternalMutation(() => removeWrap(wrap));
296
+ removed++;
297
+ }
36
298
  }
37
- activeIds.add(pid);
38
299
  });
300
+
301
+ return removed;
39
302
  }
40
303
 
41
- function redistribute() {
42
- if (!config || config.excluded) return;
304
+ /**
305
+ * Si deux wraps se retrouvent consécutifs (sans item entre eux),
306
+ * on supprime le plus récent des deux s'il est vide.
307
+ */
308
+ function decluster(kindClass) {
309
+ const wraps = Array.from(document.querySelectorAll(`.${WRAP_CLASS}.${kindClass}`));
310
+ let removed = 0;
43
311
 
44
- // 1. Accueil / Catégories
45
- const categoryItems = document.querySelectorAll('.category-item, [component="categories/category"]');
46
- if (config.enableCategoryAds) process(Array.from(categoryItems), 'home', config.intervalCategories, config.showFirstCategoryAd, 'div');
312
+ for (const wrap of wraps) {
313
+ let prev = wrap.previousElementSibling;
314
+ let steps = 0;
315
+ while (prev && steps < 3) {
316
+ if (prev.classList?.contains(WRAP_CLASS)) {
317
+ // Deux wraps consécutifs : supprimer le plus vide/récent
318
+ const wCreated = parseInt(wrap.getAttribute(CREATED_ATTR) || '0', 10);
319
+ const pCreated = parseInt(prev.getAttribute(CREATED_ATTR) || '0', 10);
320
+ const wFilled = isFilledNode(wrap);
321
+ const pFilled = isFilledNode(prev);
47
322
 
48
- // 2. Liste des Topics (Harmony utilise des <li>)
49
- const topicItems = document.querySelectorAll('li[component="category/topic"]');
50
- if (config.enableBetweenAds) process(Array.from(topicItems), 'topic-list', config.intervalPosts, config.showFirstTopicAd, 'li');
323
+ if (!wFilled) {
324
+ withInternalMutation(() => removeWrap(wrap));
325
+ removed++;
326
+ } else if (!pFilled) {
327
+ withInternalMutation(() => removeWrap(prev));
328
+ removed++;
329
+ }
330
+ // Si les deux sont remplis, laisser en place
331
+ break;
332
+ }
333
+ prev = prev.previousElementSibling;
334
+ steps++;
335
+ }
336
+ }
337
+
338
+ return removed;
339
+ }
340
+
341
+ // ─── Injection ────────────────────────────────────────────────────────────
342
+ function computeTargetIndices(count, interval, showFirst) {
343
+ const targets = new Set();
344
+ if (showFirst && count > 0) targets.add(0);
345
+ for (let i = interval - 1; i < count; i += interval) targets.add(i);
346
+ return [...targets].sort((a, b) => a - b);
347
+ }
348
+
349
+ function injectBetween(kindClass, items, interval, showFirst, poolKey) {
350
+ if (!items.length) return 0;
351
+
352
+ const targets = computeTargetIndices(items.length, interval, showFirst);
353
+ const maxIns = MAX_INSERTS_PER_RUN + (isBoosted() ? 2 : 0);
354
+ let inserted = 0;
355
+
356
+ for (const idx of targets) {
357
+ if (inserted >= maxIns) break;
358
+
359
+ const el = items[idx];
360
+ if (!el?.isConnected) continue;
361
+ if (hasAdjacentWrap(el)) continue;
362
+
363
+ const anchorKey = getAnchorKey(kindClass, el, idx);
364
+ if (findWrapByAnchor(anchorKey)) continue; // déjà là
51
365
 
52
- // 3. Messages (Posts)
53
- const postItems = document.querySelectorAll('[component="post"]');
54
- if (config.enableMessageAds) process(Array.from(postItems), 'message', config.messageIntervalPosts, config.showFirstMessageAd, 'div');
366
+ const id = pickId(poolKey);
367
+ if (!id) break; // pool épuisé pour cette passe
368
+
369
+ const wrap = insertWrapAfter(el, id, kindClass, anchorKey);
370
+ if (!wrap) continue;
371
+
372
+ observePlaceholder(id);
373
+ inserted++;
374
+ }
375
+
376
+ return inserted;
377
+ }
378
+
379
+ // ─── Preload / Show ───────────────────────────────────────────────────────
380
+ function getPreloadMargin() {
381
+ const m = isMobile() ? 'mobile' : 'desktop';
382
+ return PRELOAD_MARGIN[m + (isBoosted() ? 'Boosted' : '')];
55
383
  }
56
384
 
57
- function process(items, kind, interval, showFirst, wrapperTag) {
58
- const int = parseInt(interval, 10) || 10;
59
- const pool = getPool();
385
+ function getMaxInflight() {
386
+ return (isMobile() ? MAX_INFLIGHT_MOBILE : MAX_INFLIGHT_DESKTOP) + (isBoosted() ? 1 : 0);
387
+ }
60
388
 
61
- items.forEach((item, index) => {
62
- const pos = index + 1;
63
- const shouldHaveAd = (pos === 1 && showFirst) || (pos % int === 0);
64
- const next = item.nextElementSibling;
389
+ function ensurePreloadObserver() {
390
+ const margin = getPreloadMargin();
391
+ if (state.io && state.ioMargin === margin) return state.io;
65
392
 
66
- if (shouldHaveAd && !(next && next.classList.contains(WRAP_CLASS))) {
67
- const available = pool.querySelector(`[data-kind="${kind}"]`);
68
- if (available) {
69
- isInternalChange = true;
70
-
71
- // Création d'un conteneur qui respecte le type de parent (li ou div)
72
- const wrapper = document.createElement(wrapperTag);
73
- wrapper.className = WRAP_CLASS + ' ezoic-added-content';
74
- wrapper.setAttribute('data-kind', kind);
75
- wrapper.setAttribute('data-placeholder-id', available.getAttribute('data-placeholder-id'));
76
- wrapper.innerHTML = available.innerHTML;
393
+ state.io?.disconnect();
394
+ state.io = null;
77
395
 
78
- item.parentNode.insertBefore(wrapper, item.nextSibling);
79
- callEzoic(available.getAttribute('data-placeholder-id'));
80
-
81
- setTimeout(() => { isInternalChange = false; }, 50);
396
+ try {
397
+ state.io = new IntersectionObserver(entries => {
398
+ for (const ent of entries) {
399
+ if (!ent.isIntersecting) continue;
400
+ state.io?.unobserve(ent.target);
401
+ const id = parseInt(ent.target.getAttribute('data-ezoic-id'), 10);
402
+ if (Number.isFinite(id) && id > 0) enqueueShow(id);
82
403
  }
404
+ }, { root: null, rootMargin: margin, threshold: 0 });
405
+ state.ioMargin = margin;
406
+ } catch (_) { state.io = null; state.ioMargin = null; }
407
+
408
+ // Ré-observer les placeholders déjà dans le DOM
409
+ try {
410
+ document.querySelectorAll(`[id^="${PLACEHOLDER_PREFIX}"]`).forEach(n => {
411
+ try { state.io?.observe(n); } catch (_) {}
412
+ });
413
+ } catch (_) {}
414
+
415
+ return state.io;
416
+ }
417
+
418
+ function observePlaceholder(id) {
419
+ const ph = document.getElementById(`${PLACEHOLDER_PREFIX}${id}`);
420
+ if (!ph?.isConnected) return;
421
+ try { state.io?.observe(ph); } catch (_) {}
422
+
423
+ // Si déjà proche du viewport → show immédiat
424
+ try {
425
+ const r = ph.getBoundingClientRect();
426
+ const screens = isBoosted() ? 6 : (isMobile() ? 4 : 2.5);
427
+ if (r.top < window.innerHeight * screens && r.bottom > -window.innerHeight * 2) {
428
+ enqueueShow(id);
83
429
  }
430
+ } catch (_) {}
431
+ }
432
+
433
+ function enqueueShow(id) {
434
+ if (!id || isBlocked()) return;
435
+ const t = now();
436
+ if (t - (state.lastShowById.get(id) ?? 0) < SHOW_THROTTLE_MS) return;
437
+
438
+ if (state.inflight >= getMaxInflight()) {
439
+ if (!state.pendingSet.has(id)) {
440
+ state.pending.push(id);
441
+ state.pendingSet.add(id);
442
+ }
443
+ return;
444
+ }
445
+ startShow(id);
446
+ }
447
+
448
+ function drainQueue() {
449
+ if (isBlocked()) return;
450
+ while (state.inflight < getMaxInflight() && state.pending.length) {
451
+ const id = state.pending.shift();
452
+ state.pendingSet.delete(id);
453
+ startShow(id);
454
+ }
455
+ }
456
+
457
+ function startShow(id) {
458
+ if (!id || isBlocked()) return;
459
+ state.inflight++;
460
+ let done = false;
461
+
462
+ const release = () => {
463
+ if (done) return;
464
+ done = true;
465
+ state.inflight = Math.max(0, state.inflight - 1);
466
+ drainQueue();
467
+ };
468
+
469
+ const timeout = setTimeout(release, 6500);
470
+
471
+ requestAnimationFrame(() => {
472
+ try {
473
+ if (isBlocked()) return release();
474
+ const ph = document.getElementById(`${PLACEHOLDER_PREFIX}${id}`);
475
+ if (!ph?.isConnected) return release();
476
+ if (isFilledNode(ph)) { clearTimeout(timeout); return release(); }
477
+
478
+ const t = now();
479
+ if (t - (state.lastShowById.get(id) ?? 0) < SHOW_THROTTLE_MS) return release();
480
+ state.lastShowById.set(id, t);
481
+
482
+ window.ezstandalone = window.ezstandalone || {};
483
+ const ez = window.ezstandalone;
484
+
485
+ const doShow = () => {
486
+ try { ez.showAds(id); } catch (_) {}
487
+ scheduleEmptyCheck(id);
488
+ setTimeout(release, 650);
489
+ };
490
+
491
+ if (Array.isArray(ez.cmd)) ez.cmd.push(doShow);
492
+ else doShow();
493
+ } finally { /* timeout covers us */ }
84
494
  });
85
495
  }
86
496
 
87
- function init() {
88
- fetch('/api/plugins/ezoic-infinite/config')
89
- .then(r => r.json())
90
- .then(data => {
91
- config = data;
92
- const pool = getPool();
93
- const setup = (raw, kind) => {
94
- if (!raw) return;
95
- raw.split(/[\s,]+/).filter(Boolean).forEach(id => {
96
- const d = document.createElement('div');
97
- d.setAttribute('data-kind', kind);
98
- d.setAttribute('data-placeholder-id', id);
99
- d.innerHTML = `<div id="ezoic-pub-ad-placeholder-${id}"></div>`;
100
- pool.appendChild(d);
101
- });
497
+ function scheduleEmptyCheck(id) {
498
+ setTimeout(() => {
499
+ try {
500
+ const ph = document.getElementById(`${PLACEHOLDER_PREFIX}${id}`);
501
+ if (!ph?.isConnected) return;
502
+ const wrap = ph.closest(`.${WRAP_CLASS}`);
503
+ if (!wrap) return;
504
+
505
+ const created = parseInt(wrap.getAttribute(CREATED_ATTR) || '0', 10);
506
+ if (created && (now() - created) < EMPTY_WRAP_TTL_MS) return;
507
+
508
+ if (!isFilledNode(ph)) wrap.classList.add('is-empty');
509
+ else wrap.classList.remove('is-empty');
510
+ } catch (_) {}
511
+ }, 15_000);
512
+ }
513
+
514
+ // ─── Patch Ezoic showAds ──────────────────────────────────────────────────
515
+ function patchShowAds() {
516
+ const apply = () => {
517
+ try {
518
+ window.ezstandalone = window.ezstandalone || {};
519
+ const ez = window.ezstandalone;
520
+ if (window.__nodebbEzoicPatched || typeof ez.showAds !== 'function') return;
521
+
522
+ window.__nodebbEzoicPatched = true;
523
+ const orig = ez.showAds.bind(ez);
524
+
525
+ ez.showAds = function (...args) {
526
+ if (isBlocked()) return;
527
+ const ids = (args.length === 1 && Array.isArray(args[0])) ? args[0] : args;
528
+ const seen = new Set();
529
+ for (const v of ids) {
530
+ const id = parseInt(v, 10);
531
+ if (!Number.isFinite(id) || id <= 0 || seen.has(id)) continue;
532
+ const ph = document.getElementById(`${PLACEHOLDER_PREFIX}${id}`);
533
+ if (!ph?.isConnected) continue;
534
+ seen.add(id);
535
+ try { orig(id); } catch (_) {}
536
+ }
102
537
  };
103
- setup(config.categoryPlaceholderIds, 'home');
104
- setup(config.placeholderIds, 'topic-list');
105
- setup(config.messagePlaceholderIds, 'message');
106
-
107
- redistribute();
108
-
109
- const observer = new MutationObserver(() => {
110
- if (!isInternalChange) redistribute();
111
- });
112
- observer.observe(document.body, { childList: true, subtree: true });
538
+ } catch (_) {}
539
+ };
540
+
541
+ apply();
542
+ if (!window.__nodebbEzoicPatched) {
543
+ window.ezstandalone = window.ezstandalone || {};
544
+ window.ezstandalone.cmd = window.ezstandalone.cmd || [];
545
+ window.ezstandalone.cmd.push(apply);
546
+ }
547
+ }
548
+
549
+ // ─── Core run ─────────────────────────────────────────────────────────────
550
+ async function runCore() {
551
+ if (isBlocked()) return 0;
552
+ patchShowAds();
553
+
554
+ const cfg = await fetchConfig();
555
+ if (!cfg || cfg.excluded) return 0;
556
+ initPools(cfg);
557
+
558
+ const kind = getKind();
559
+ let inserted = 0;
560
+
561
+ const run = (kindClass, getItems, cfgEnable, cfgInterval, cfgShowFirst, poolKey) => {
562
+ if (!normBool(cfgEnable)) return 0;
563
+ const items = getItems();
564
+ pruneOrphans(kindClass);
565
+ const n = injectBetween(kindClass, items, Math.max(1, parseInt(cfgInterval, 10) || 3), normBool(cfgShowFirst), poolKey);
566
+ if (n) decluster(kindClass);
567
+ return n;
568
+ };
569
+
570
+ if (kind === 'topic') {
571
+ inserted += run('ezoic-ad-message', getPostContainers,
572
+ cfg.enableMessageAds, cfg.messageIntervalPosts, cfg.showFirstMessageAd, 'posts');
573
+ } else if (kind === 'categoryTopics') {
574
+ inserted += run('ezoic-ad-between', getTopicItems,
575
+ cfg.enableBetweenAds, cfg.intervalPosts, cfg.showFirstTopicAd, 'topics');
576
+ } else if (kind === 'categories') {
577
+ inserted += run('ezoic-ad-categories', getCategoryItems,
578
+ cfg.enableCategoryAds, cfg.intervalCategories, cfg.showFirstCategoryAd, 'categories');
579
+ }
580
+
581
+ return inserted;
582
+ }
583
+
584
+ // ─── Scheduler / Burst ────────────────────────────────────────────────────
585
+ function scheduleRun(delayMs = 0, cb) {
586
+ if (state.runQueued) return;
587
+ state.runQueued = true;
588
+
589
+ const run = async () => {
590
+ state.runQueued = false;
591
+ if (state.pageKey && getPageKey() !== state.pageKey) return;
592
+ let n = 0;
593
+ try { n = await runCore(); } catch (_) {}
594
+ try { cb?.(n); } catch (_) {}
595
+ };
596
+
597
+ const doRun = () => requestAnimationFrame(run);
598
+ if (delayMs > 0) setTimeout(doRun, delayMs);
599
+ else doRun();
600
+ }
601
+
602
+ function requestBurst() {
603
+ if (isBlocked()) return;
604
+ const t = now();
605
+ if (t - state.lastBurstReqTs < 100) return;
606
+ state.lastBurstReqTs = t;
607
+
608
+ const pk = getPageKey();
609
+ state.pageKey = pk;
610
+ state.burstDeadline = t + 1800;
611
+
612
+ if (state.burstActive) return;
613
+ state.burstActive = true;
614
+ state.burstCount = 0;
615
+
616
+ const step = () => {
617
+ if (getPageKey() !== pk) { state.burstActive = false; return; }
618
+ if (isBlocked()) { state.burstActive = false; return; }
619
+ if (now() > state.burstDeadline) { state.burstActive = false; return; }
620
+ if (state.burstCount >= 8) { state.burstActive = false; return; }
621
+
622
+ state.burstCount++;
623
+ scheduleRun(0, (n) => {
624
+ if (!n && !state.pending.length) { state.burstActive = false; return; }
625
+ setTimeout(step, n > 0 ? 120 : 250);
113
626
  });
627
+ };
628
+
629
+ step();
630
+ }
631
+
632
+ // ─── Cleanup (ajaxify navigation) ─────────────────────────────────────────
633
+ function cleanup() {
634
+ // Bloquer toute injection pendant la transition
635
+ blockedUntil = now() + 1500;
636
+
637
+ // Supprimer tous les wraps injectés → libère les IDs
638
+ withInternalMutation(() => {
639
+ document.querySelectorAll(`.${WRAP_CLASS}`).forEach(removeWrap);
640
+ });
641
+
642
+ // Réinitialiser l'état complet
643
+ state.cfg = null;
644
+ state.pools = { topics: [], posts: [], categories: [] };
645
+ state.cursors = { topics: 0, posts: 0, categories: 0 };
646
+ state.mountedIds.clear();
647
+ state.lastShowById.clear();
648
+ state.inflight = 0;
649
+ state.pending = [];
650
+ state.pendingSet.clear();
651
+
652
+ state.burstActive = false;
653
+ state.runQueued = false;
654
+ }
655
+
656
+ // ─── DOM Observer ─────────────────────────────────────────────────────────
657
+ function shouldReact(mutations) {
658
+ for (const m of mutations) {
659
+ if (!m.addedNodes?.length) continue;
660
+ for (const n of m.addedNodes) {
661
+ if (n.nodeType !== 1) continue;
662
+ if (
663
+ n.matches?.(SELECTORS.postItem) || n.querySelector?.(SELECTORS.postItem) ||
664
+ n.matches?.(SELECTORS.topicItem) || n.querySelector?.(SELECTORS.topicItem) ||
665
+ n.matches?.(SELECTORS.categoryItem) || n.querySelector?.(SELECTORS.categoryItem)
666
+ ) return true;
667
+ }
668
+ }
669
+ return false;
670
+ }
671
+
672
+ function ensureDomObserver() {
673
+ if (state.domObs) return;
674
+ state.domObs = new MutationObserver(mutations => {
675
+ if (state.internalMutation > 0) return;
676
+ if (isBlocked()) return;
677
+ if (!shouldReact(mutations)) return;
678
+ requestBurst();
679
+ });
680
+ try { state.domObs.observe(document.body, { childList: true, subtree: true }); } catch (_) {}
114
681
  }
115
682
 
116
- // GESTION NAVIGATION SANS RECHARGE (SPA)
117
- window.addEventListener('action:ajaxify.start', function() {
118
- if (typeof window.ezstandalone !== 'undefined' && activeIds.size > 0) {
119
- window.ezstandalone.cmd.push(function() {
120
- window.ezstandalone.destroyAll();
121
- activeIds.clear();
683
+ // ─── Utilities: console mute + TCF + network warm ─────────────────────────
684
+ function muteNoisyConsole() {
685
+ if (window.__nodebbEzoicConsoleMuted) return;
686
+ window.__nodebbEzoicConsoleMuted = true;
687
+ const MUTED = [
688
+ '[EzoicAds JS]: Placeholder Id',
689
+ 'Debugger iframe already exists',
690
+ 'HTML element with id ezoic-pub-ad-placeholder-',
691
+ ];
692
+ ['log', 'info', 'warn', 'error'].forEach(m => {
693
+ const orig = console[m];
694
+ if (typeof orig !== 'function') return;
695
+ console[m] = function (...args) {
696
+ const s = typeof args[0] === 'string' ? args[0] : '';
697
+ if (MUTED.some(p => s.includes(p))) return;
698
+ orig.apply(console, args);
699
+ };
700
+ });
701
+ }
702
+
703
+ function ensureTcfLocator() {
704
+ try {
705
+ if (!window.__tcfapi && !window.__cmp) return;
706
+ if (document.getElementById('__tcfapiLocator')) return;
707
+ const f = Object.assign(document.createElement('iframe'), {
708
+ style: 'display:none', id: '__tcfapiLocator', name: '__tcfapiLocator',
122
709
  });
710
+ (document.body || document.documentElement).appendChild(f);
711
+ } catch (_) {}
712
+ }
713
+
714
+ const _warmedLinks = new Set();
715
+ function warmNetwork() {
716
+ const head = document.head;
717
+ if (!head) return;
718
+ const links = [
719
+ ['preconnect', 'https://g.ezoic.net', true],
720
+ ['preconnect', 'https://go.ezoic.net', true],
721
+ ['preconnect', 'https://securepubads.g.doubleclick.net', true],
722
+ ['preconnect', 'https://pagead2.googlesyndication.com', true],
723
+ ['dns-prefetch', 'https://g.ezoic.net', false],
724
+ ['dns-prefetch', 'https://securepubads.g.doubleclick.net', false],
725
+ ];
726
+ for (const [rel, href, cors] of links) {
727
+ const key = `${rel}|${href}`;
728
+ if (_warmedLinks.has(key)) continue;
729
+ _warmedLinks.add(key);
730
+ const link = document.createElement('link');
731
+ link.rel = rel; link.href = href;
732
+ if (cors) link.crossOrigin = 'anonymous';
733
+ head.appendChild(link);
123
734
  }
124
- });
735
+ }
736
+
737
+ // ─── NodeBB + Scroll bindings ─────────────────────────────────────────────
738
+ function bindNodeBB() {
739
+ const $ = window.jQuery;
740
+ if (!$) return;
741
+
742
+ $(window).off('.ezoicInfinite');
743
+
744
+ $(window).on('action:ajaxify.start.ezoicInfinite', () => {
745
+ cleanup();
746
+ });
125
747
 
126
- window.addEventListener('action:ajaxify.end', function() {
127
- setTimeout(redistribute, 600);
128
- });
748
+ $(window).on('action:ajaxify.end.ezoicInfinite', () => {
749
+ state.pageKey = getPageKey();
750
+ blockedUntil = 0;
751
+ muteNoisyConsole();
752
+ ensureTcfLocator();
753
+ warmNetwork();
754
+ patchShowAds();
755
+ ensurePreloadObserver();
756
+ ensureDomObserver();
757
+ requestBurst();
758
+ });
759
+
760
+ const burstEvents = [
761
+ 'action:ajaxify.contentLoaded',
762
+ 'action:posts.loaded',
763
+ 'action:topics.loaded',
764
+ 'action:categories.loaded',
765
+ 'action:category.loaded',
766
+ 'action:topic.loaded',
767
+ ].map(e => `${e}.ezoicInfinite`).join(' ');
768
+
769
+ $(window).on(burstEvents, () => { if (!isBlocked()) requestBurst(); });
770
+
771
+ // Hooks AMD (NodeBB 4.x)
772
+ try {
773
+ require(['hooks'], hooks => {
774
+ if (typeof hooks?.on !== 'function') return;
775
+ [
776
+ 'action:ajaxify.end', 'action:ajaxify.contentLoaded',
777
+ 'action:posts.loaded', 'action:topics.loaded',
778
+ 'action:categories.loaded', 'action:category.loaded', 'action:topic.loaded',
779
+ ].forEach(ev => {
780
+ try { hooks.on(ev, () => { if (!isBlocked()) requestBurst(); }); } catch (_) {}
781
+ });
782
+ });
783
+ } catch (_) {}
784
+ }
129
785
 
130
- if (document.readyState === 'loading') {
131
- document.addEventListener('DOMContentLoaded', init);
132
- } else {
133
- init();
786
+ function bindScroll() {
787
+ let ticking = false;
788
+ window.addEventListener('scroll', () => {
789
+ // Scroll boost
790
+ try {
791
+ const t = now();
792
+ const y = window.scrollY || window.pageYOffset || 0;
793
+ if (state.lastScrollTs) {
794
+ const speed = Math.abs(y - state.lastScrollY) / Math.max(1, t - state.lastScrollTs);
795
+ if (speed >= BOOST_SPEED_PX_PER_MS) {
796
+ const wasBoosted = isBoosted();
797
+ state.scrollBoostUntil = Math.max(state.scrollBoostUntil, t + BOOST_DURATION_MS);
798
+ if (!wasBoosted) ensurePreloadObserver();
799
+ }
800
+ }
801
+ state.lastScrollY = y;
802
+ state.lastScrollTs = t;
803
+ } catch (_) {}
804
+
805
+ if (ticking) return;
806
+ ticking = true;
807
+ requestAnimationFrame(() => { ticking = false; requestBurst(); });
808
+ }, { passive: true });
134
809
  }
135
- })();
810
+
811
+ // ─── Boot ─────────────────────────────────────────────────────────────────
812
+ state.pageKey = getPageKey();
813
+ muteNoisyConsole();
814
+ ensureTcfLocator();
815
+ warmNetwork();
816
+ patchShowAds();
817
+ ensurePreloadObserver();
818
+ ensureDomObserver();
819
+ bindNodeBB();
820
+ bindScroll();
821
+ blockedUntil = 0;
822
+ requestBurst();
823
+ })();
package/public/style.css CHANGED
@@ -1,37 +1,89 @@
1
- /* Isolation et Taille */
1
+ /*
2
+ NodeBB Ezoic Infinite Ads — style.css (v18)
3
+ Corrections :
4
+ - Suppression de line-height:0/font-size:0 global (cassait le texte adjacent)
5
+ - Ciblage précis pour éviter les effets de bord sur les composants NodeBB
6
+ - Règles is-empty revues pour ne pas interférer avec les slots en cours de fill
7
+ */
8
+
9
+ /* ── Wrapper principal ───────────────────────────────────────────────────── */
2
10
  .nodebb-ezoic-wrap {
3
- display: block !important;
4
- width: 100% !important;
5
- min-height: 250px !important;
6
- margin: 30px 0 !important;
7
- clear: both !important;
8
- text-align: center;
9
- list-style: none !important; /* Pour les injections en <li> */
11
+ display: block;
12
+ width: 100%;
13
+ margin: 0 !important;
14
+ padding: 0 !important;
15
+ overflow: hidden;
16
+ contain: layout style; /* isolation des reflows */
17
+ }
18
+
19
+ /* Placeholder : measurable pour l'IntersectionObserver, invisible si vide */
20
+ .nodebb-ezoic-wrap > [id^="ezoic-pub-ad-placeholder-"] {
21
+ margin: 0 !important;
22
+ padding: 0 !important;
23
+ min-height: 1px;
24
+ display: block;
10
25
  }
11
26
 
12
- /* Fix pour l'accueil Harmony */
13
- li.nodebb-ezoic-wrap {
14
- float: none !important;
15
- padding: 20px 0;
16
- border-bottom: 1px solid rgba(0,0,0,0.05);
27
+ /* ── Ciblage précis des noeuds Ezoic à l'intérieur de nos wraps ─────────── */
28
+
29
+ /* Killer du gap "baseline" sous les iframes */
30
+ .nodebb-ezoic-wrap iframe,
31
+ .nodebb-ezoic-wrap div[id$="__container__"] iframe {
32
+ display: block !important;
33
+ vertical-align: top !important;
34
+ line-height: 0 !important;
35
+ font-size: 0 !important;
17
36
  }
18
37
 
19
- /* Éviter que les pubs ne se collent aux messages */
20
- [component="post"] + .nodebb-ezoic-wrap {
21
- background: rgba(0,0,0,0.02);
22
- padding: 20px;
23
- border-radius: 10px;
38
+ .nodebb-ezoic-wrap div[id$="__container__"] {
39
+ display: block !important;
40
+ line-height: 0 !important;
41
+ font-size: 0 !important;
24
42
  }
25
43
 
26
- /* Anti-remontée (Pile-up) */
27
- .nodebb-ezoic-wrap::before, .nodebb-ezoic-wrap::after {
28
- content: "";
29
- display: table;
30
- clear: both;
44
+ /* Kill de la réserve 400px qu'Ezoic injecte en inline style */
45
+ .nodebb-ezoic-wrap .ezoic-ad,
46
+ .nodebb-ezoic-wrap span.ezoic-ad {
47
+ margin: 0 !important;
48
+ padding: 0 !important;
49
+ min-height: 1px !important; /* écrase le 400px Ezoic */
50
+ height: auto !important;
31
51
  }
32
52
 
33
- /* Centrage forcé des iframes Ezoic */
53
+ /* Reportline en absolu pour ne pas impacter le layout */
54
+ .nodebb-ezoic-wrap .reportline {
55
+ position: absolute !important;
56
+ }
57
+
58
+ /* Neutralise sticky à l'intérieur de nos wraps (évite l'effet "gliding") */
59
+ .nodebb-ezoic-wrap .ezads-sticky-intradiv {
60
+ position: static !important;
61
+ top: auto !important;
62
+ }
63
+
64
+ /* ── État "vide" ─────────────────────────────────────────────────────────── */
65
+ /*
66
+ is-empty est ajouté 15s après le show si aucun fill détecté.
67
+ On collapse à 1px (et non 0) pour rester visible à l'IO (évite le déclenchement
68
+ trop tard si le fill arrive après le collapse).
69
+ */
70
+ .nodebb-ezoic-wrap.is-empty {
71
+ display: block !important;
72
+ height: 1px !important;
73
+ min-height: 1px !important;
74
+ max-height: 1px !important;
75
+ margin: 0 !important;
76
+ padding: 0 !important;
77
+ overflow: hidden !important;
78
+ }
79
+
80
+ /* ── Ezoic global (hors de nos wraps) ───────────────────────────────────── */
34
81
  .ezoic-ad {
35
- margin: 0 auto !important;
36
- display: block !important;
37
- }
82
+ margin: 0 !important;
83
+ padding: 0 !important;
84
+ }
85
+
86
+ /* ── Orphan hidden (compatibilité transitoire) ───────────────────────────── */
87
+ .ez-orphan-hidden {
88
+ display: none !important;
89
+ }