JVC chat

// ==UserScript== // @name JVChat DEBUG // @description JVChat avec debug intégré // @author m7r-227 // @namespace JVChat // @license MIT // @version 0.1.1-debug // @match https://*.jeuxvideo.com/forums/42-* // @match https://*.jeuxvideo.com/forums/1-* // @grant none // ==/UserScript== const _DBG = true; const _P = '%c[JVChat]'; const _S = 'color:#00e5ff;font-weight:bold'; const _W = 'color:#ffab00;font-weight:bold'; const _E = 'color:#ff1744;font-weight:bold'; function dbg(...a) { if (_DBG) console.log(_P, _S, ...a); } function wrn(...a) { if (_DBG) console.warn(_P, _W, ...a); } function err(...a) { if (_DBG) console.error(_P, _E, ...a); } (function addStyle() { const style = document.createElement('style'); style.textContent = ` @font-face { font-family: "jvchat-icons"; src: url(data:font/woff;base64,d09GRgABAAAAAAXkAAsAAAAAB4wAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAABHU1VCAAAFuAAAACkAAAAquPq49E9TLzIAAAS0AAAARQAAAGBAg1QHY21hcAAABPwAAABYAAAAhJQqfw1nbHlmAAABCAAAAwQAAARovY2OvmhlYWQAAARIAAAANgAAADYpthI3aGhlYQAABJQAAAAfAAAAJAzUCM1obXR4AAAEgAAAABEAAAAiEM0AAGxvY2EAAAQsAAAAGgAAABoHyAZhbWF4cAAABAwAAAAfAAAAIAEiAV5uYW1lAAAFVAAAAFIAAAB8BIgdIHBvc3QAAAWoAAAAEAAAACAADQAAeNp1UkOALEkQjcisrur5KMxkVWtYao6y9bG2bdu2cV9797rutXGa47+s7dMaffrHrfmRYzYCGREvCBxg+nj+EnsJBJRhHAB9Qzd0kfEynmy32q1GMS7G3Fe0oXSpLEL59GHRyCCD6849e8v27VvOPvePeeG60088fIw+hx/54bxwYfL7lbu9hMezl5Z5zgj/wzLXGYH1Jr9dsVsHjwekCr/iVVaFjQBYNCipkWln2kVe3a3bnf9hd4kyEwPaxxxgE0Dk9KAzHBY9VTnr4i/JyDQkD94VHXPGQ886SRd/xp+TP15868MHn7/oegBGsR2ayAlggQvQ5+suF119RLaaTiMO3FTUTEUvhZMoQ9YJpQyTlxJqjFo7XgYJBFIGjGjn66+P/+qrGbQL+APsKdgPjgaIaJ7tPXAcx9DCWLEq+rqFulGMFfOGUHdFxjBxCAdxT/QU246ytSe22plPsbiY8sewsQfKQRQm8knGT9QZP0YzjQv8wMaN6WfSG9EK/AvSpna0xvUTtTUdomHlcAznenJL5YBK5YDjFPm2r9q7n2YZ9xopVs5Zl6U3bEhfZubLLEVPlrZfxvFW263cvH1sDobI7CRBQ46QhdLMZZmoTmsQ1W3tgeq4xrAY9xkZNygaQbNdbNYz7TqDi8487b5iqVS877QzP10Un3juuVMWfhyWm+fF5LklXrM1HM9PYI+BDoMAKdpiIDxsNuiei0R9fQB1qmsADXekObM4icFotbZb1Ip2q1VHj2edl2QYSoSv9746LpoDhSgqDJjF+Kp9vj6+WgVAoA+v4GsgACLaaeyrferCk3uqRlsNntt87dX2hH31tZvtkZrYsUPURt41r7jUcS69wrQng96pqd5gcgZp+k6O7G5wSKFB9aBCaSPNiVBxL9dFkfzjlre7eKDrJh+I3dhlYqtIdiY7xdaSwN1Iwe1iDol9PYfkCaOHiO4TSoPQJJui6H9QEBKhuIS2HtLy7qitSd2nDotxoyWHaIHSEzzn/riiOTzE+2x5c7sAaMv133jaY2BkYGDgYQxi4GEAASYg5gJCBob/DGAAABK+AYIAAAAAAABRAGsAiQCwAS4BcAGhAcYB6wIQAjQAAAABAAAAAQAAHiteY18PPPUACwQAAAAAAOBi554AAAAA4GLnnv/H/vwKCgMCAAAACAACAAAAAAAAeNpjAAIWGD4LotEBABJzAN4AAAB42mNgZGBgZvjPwMDAxfD/+P/jXFxAEVTACgBxLwS0AHjaY2Bh4WacwMDKwMAWwXSGgYGhH0IzvmYwYuRgYGBiYGVmwAoC0lxTGA684nvVx8zwn4EhhjmGkQUozIiiiAkANzILFgAAAHjaY2BgYAJiZiAWAZKMYJqFoQBISzAIAEU4XvG9En6l8cr5Vf6r8lfVr1pedbzqedX3/z8DAy4Z0c+i70Vvix4UnSbaL9oj2iLaKFonWgs0GwcAALUUKS542kzIgQYCMQAA0Le2RkOA9FUhBEAyGenY3P8fDAOeh+whCukiKEyfXJXpuHxafHZzn84Kmo/N3/BUfe1+3rqXqhvaMbCsAoMhgx6DAQBDxwnBAAB42mNgZoAALgasAAABYwAOeNpjYGRgYOBiUGPQYGBycfMJYeDLSSzJY5BgYGEAgv//GeAAAG2XBV0AAAA=); } .refresh-loader { --size: 30px; --thickness: 3px; --value: 0; width: var(--size); aspect-ratio: 1; border-radius: 50%; background: conic-gradient(var(--jv-text-secondary) calc(var(--value) * 1%), var(--jv-block-bg-color) 0); position: relative; display: flex; align-items: center; justify-content: center; font: 600 calc(var(--size) * 0.25)/1.1 system-ui; color: var(--jv-text-secondary); transition: background .4s ease; align-self: end; } .refresh-loader::before { content: ''; position: absolute; inset: calc(var(--thickness)); border-radius: 50%; background: var(--jv-block-bg-color); } .refresh-loader__text { position: relative; pointer-events: none; } .btn-open-jvchat { margin-right: 0.3125rem; } #jvchat-user-notif.has-notif::after, #jvchat-user-mp.has-notif::after { z-index: 2; content: " " attr(data-val) ""; color: #fff; line-height: 1.25rem; font-size: 0.9rem; padding: 0 .25rem; position: absolute; top: .6875rem; right: -.6875rem; background: #ff3c00; width: 1.1rem; height: 1.1rem; border-radius: 1rem; } #jvchat-mp-and-notif .nav-link, .nav-link-search, .account-pseudo { color: white !important; } .jvchat-container { display: flex; height: 100vh; background-color: var(--jv-bg-color); font-family: system-ui, sans-serif; } .jvchat-sidebar { flex: 0 0 15%; max-width: 254px; background-color: var(--jv-block-bg-color); border-right: 1px solid #2b2e30; padding: 1rem; box-sizing: border-box; overflow-y: auto; display: flex; flex-direction: column; position: relative; } .jvchat-profile { width: 100%; display: flex; flex-direction: column; align-items: center; position: relative; } .jvchat-username { margin: 0 0 0.5rem 0; font-size: 1.25rem; font-weight: 700; text-align: center; color: #fff; } /* Nouveau : compteur de connectés sous le pseudo */ .jvchat-profile-connected { margin: 0 0 0.5rem 0; font-size: 0.8125rem; text-align: center; color: var(--jv-text-secondary, #9aa0a6); display: flex; align-items: center; gap: 0.35rem; } .jvchat-profile-connected::before { content: ''; width: 8px; height: 8px; border-radius: 50%; background: #22c55e; box-shadow: 0 0 6px rgba(34, 197, 94, 0.7); } .jvchat-profile-avatar { width: 150px; height: 150px; border-radius: 50%; object-fit: cover; } .jvchat-profile-actions { display: flex; gap: 0.75rem; } .jvchat-topic-heading { font-size: 0.875rem; line-height: 1.42857; margin: 0; align-self: flex-start; } .jvchat-back-to-forum { display: inline-flex; align-items: center; gap: 0.4rem; margin-top: 1rem; padding: 0.4rem 0.6rem; font-size: 0.8125rem; color: var(--jv-text-secondary, #9aa0a6); background: transparent; border: 1px solid var(--jv-border-color, #2b2e30); border-radius: 0.375rem; text-decoration: none; cursor: pointer; transition: background 0.15s ease, color 0.15s ease, border-color 0.15s ease; align-self: flex-start; } .jvchat-back-to-forum:hover { background: var(--jv-block-even-bg-color, rgba(255,255,255,0.04)); color: var(--jv-text-color, #fff); border-color: var(--jv-text-secondary, #9aa0a6); text-decoration: none; } .jvchat-back-to-forum::before { content: '←'; font-size: 1rem; line-height: 1; } .jvchat-chat { flex: 1; display: flex; flex-direction: column; height: 100%; min-height: 0; } .jvchat-messages { flex: 1; padding: 1rem; overflow-y: auto; display: flex; flex-direction: column; min-height: 0; } .jvchat-message { display: flex; gap: 0.75rem; background: var(--jv-block-bg-color); padding: 0.75rem; border-radius: 0.5rem; border: 0.0625rem solid var(--jv-border-color); } .jvchat-message:nth-of-type(2n) { background: var(--jv-block-even-bg-color); } .jvchat-content p:last-of-type { margin-bottom: 0px; } .jvchat-message-controls { display: flex; align-items: center; gap: 0.75rem; visibility: hidden; } .jvchat-message-controls span { cursor: pointer; } .jvchat-message:hover .jvchat-message-controls { visibility: visible; } .jvchat-avatar { width: 40px; height: 40px; border-radius: 50%; flex-shrink: 0; } .jvchat-message-body { flex: 1; color: var(--jv-text-color) } .jvchat-message-header { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 0.5rem; margin-bottom: 0.25rem; } .jvchat-user { font-weight: 600; } .jvchat-date { font-size: 0.75rem; opacity: 0.7; } .jvchat-form { padding: 1rem; padding-top: 0; flex-shrink: 0; } /* Textarea 4x plus haut par défaut + resize vertical manuel */ /* IMPORTANT: pas de !important sur height, sinon le drag natif ne peut pas la modifier */ .jvchat-form textarea#message_reponse { min-height: 40px !important; resize: vertical !important; overflow-y: auto !important; box-sizing: border-box !important; max-height: none !important; display: block !important; width: 100% !important; /* Espace en bas pour que la poignée de resize soit bien accessible */ margin-bottom: 12px !important; } /* Neutraliser le overflow:hidden du parent qui clippe le resize */ /* IMPORTANT : on NE cible PAS la textarea elle-même ici (sinon height:auto empêche le drag natif de modifier sa hauteur). */ .jvchat-form .messageEditor__containerEdit { overflow: visible !important; max-height: none !important; height: auto !important; } .jvchat-form .messageEditor__containerEdit > div { overflow: visible !important; max-height: none !important; height: auto !important; } /* Rendre la poignée de resize plus visible */ .jvchat-form .messageEditor__containerEdit { position: relative; padding-bottom: 8px !important; } /* Indicateur visuel custom sur le coin bas-droit */ .jvchat-form .messageEditor__containerEdit::after { content: ''; position: absolute; bottom: 2px; right: 2px; width: 14px; height: 14px; background: linear-gradient( 135deg, transparent 0%, transparent 40%, var(--jv-text-secondary, #9aa0a6) 40%, var(--jv-text-secondary, #9aa0a6) 50%, transparent 50%, transparent 70%, var(--jv-text-secondary, #9aa0a6) 70%, var(--jv-text-secondary, #9aa0a6) 80%, transparent 80% ); pointer-events: none; opacity: 0.5; border-radius: 2px; } .jvchat-badge { position: absolute; top: 8px; right: -10px; min-width: 16px; padding: 1px 4px; font: 16px/16px Arial, sans-serif; color: #fff; background: #ff3c00; border-radius: 9999px; text-align: center; z-index: 10; pointer-events: none; } .jvchat-settings-panel { position: absolute; inset: 0; background-color: var(--jv-block-bg-color); transform: translateX(-100%); transition: transform 0.35s cubic-bezier(.4,0,.2,1); z-index: 2000; display: flex; flex-direction: column; gap: .75rem; padding: 1rem 1.25rem; box-shadow: 2px 0 12px rgba(0,0,0,.15); overflow-y: auto; } .jvchat-settings-panel.open { transform: translateX(0); } #jvchat-settings-list { display: flex; flex-direction: column; gap: .75rem; } .jvchat-message-deleted > div { opacity: 0.2; filter: grayscale(100%); } .jvchat-message-deleted { position: relative; } .jvchat-message-deleted:after { content: "Message supprimé"; position: absolute; top: 40%; left: 50%; color: gray; font-weight: bold; opacity: 0.7; } .jvchat-message-deleted .jvchat-message-controls { display: none; } /* Liens cliquables dans les messages */ .jvchat-content a[href^="http"], .jvchat-content a[href^="https"] { color: #4a9eff; text-decoration: underline; word-break: break-all; } .jvchat-content a[href^="http"]:hover, .jvchat-content a[href^="https"]:hover { color: #7ab8ff; text-decoration: underline; } `; document.head.appendChild(style); })(); (function createJVChatButton() { const html = '<button class="buttonsNavbar__button btn-open-jvchat" type="button"><i class="buttonsNavbar__icon icon-topics-list"></i><div class="buttonsNavbar__label">JVChat</div></button>' const refreshBtn = document.querySelector('.buttonsNavbar .buttonsNavbar__icon.icon-refresh').parentNode; refreshBtn.insertAdjacentHTML('beforebegin', html); const button = document.querySelector('.btn-open-jvchat'); button.addEventListener('click', () => { const jvchat = new JVChat(); jvchat.start(); }); })(); class JVChat { constructor() { this.jvcApi = null; this.jvcClient = null; this.lastPage = 1; this.messages = []; this.refreshRate = 3000; this.isTabActive = true; this.unreadMessagesCount = 0; this.autoScrollingEnabled = true; this.initialLoadDone = false; } async start() { this.jvcClient = new JVCClient(); const info = this.jvcClient.parseURL(location.href); this.topicId = info.topicId; this.forumId = info.forumId; this.viewId = info.viewId; this.topicTitle = info.title; this.jvcApi = new JVCAPI(this.viewId, this.forumId, this.topicId, this.topicTitle); this.jvchatSettings = new JVChatSettings(); this.createJVChatInterface(); const page = parsePage(document); this.lastPage = page.lastPage; dbg(`start() | lastPage initial = ${this.lastPage}`); // Premier chargement : récupère la page courante + la précédente (~20 à 40 msgs) await this.initialFetch(); this.initialLoadDone = true; const loader = document.querySelector('.refresh-loader'); let lastRefresh = performance.now(); setInterval(() => { const now = performance.now(); const elapsed = now - lastRefresh; const percent = (elapsed / this.refreshRate) * 100; loader.style.setProperty('--value', percent); if (elapsed >= this.refreshRate) { lastRefresh = now; this.fetchNewMessages(); } }, 50); document.addEventListener('visibilitychange', () => { this.isTabActive = document.visibilityState === 'visible'; if (this.isTabActive) { this.unreadMessagesCount = 0; this.jvcClient.updateFaviconWithCount(0); this.scrollToBottom(); this.autoScrollingEnabled = true; } }); } /** * Chargement initial : récupère la page courante + la précédente (si elle existe) * pour afficher jusqu'à ~40 messages au lieu de seulement la dernière page. */ async initialFetch() { dbg(`▶ initialFetch: chargement page courante + précédente`); const t0 = performance.now(); try { // On commence par charger la page courante pour connaître lastPage réel const currentDoc = await this.jvcApi.getPageDocument(this.lastPage); const currentPageData = parsePage(currentDoc); this.payload = getPayload(currentDoc); this.jvcApi.payload = this.payload; // Met à jour lastPage au cas où il aurait changé this.lastPage = currentPageData.lastPage; // Si on est sur la page >= 2, on charge aussi la précédente const pagesToLoad = []; if (this.lastPage >= 2) { pagesToLoad.push(this.lastPage - 1); } // La page courante en dernier pour que ses messages soient ajoutés après pagesToLoad.push(this.lastPage); dbg(`▶ initialFetch: chargement des pages ${pagesToLoad.join(', ')}`); // Mettre à jour l'UI (titre, connectés, user connecté) AVANT d'ajouter les messages // pour que les boutons edit/delete soient attachés correctement aux messages de l'utilisateur. this.updateUIFromDoc(currentDoc, currentPageData); // Traiter dans l'ordre chronologique : précédente d'abord, puis courante for (const pageNum of pagesToLoad) { let pageData; if (pageNum === this.lastPage) { pageData = currentPageData; } else { const doc = await this.jvcApi.getPageDocument(pageNum); pageData = parsePage(doc); // Force le numéro de page correct pour les messages de la page précédente pageData.messages.forEach(m => { m.page = pageNum; }); } for (const message of pageData.messages) { this.addMessage(message); } } if (this.autoScrollingEnabled) { this.scrollToBottom(); } const dt = (performance.now() - t0).toFixed(0); dbg(`✅ initialFetch OK (${dt}ms) | ${this.messages.length} messages chargés | lastPage=${this.lastPage}`); } catch (error) { err(`initialFetch ❌:`, error.message, error.stack); } } /** * Met à jour les éléments d'UI (titre, compteurs, profil utilisateur) * à partir d'un document HTML parsé. */ updateUIFromDoc(doc, pageData) { document.querySelector('#jvchat-topic-title').textContent = pageData.title; const connectedText = pageData.connectedCount + (pageData.connectedCount === 1 ? ' connecté' : ' connectés'); document.querySelector('#jvchat-messages-count').textContent = pageData.messagesCount + (pageData.messagesCount === 1 ? ' message' : ' messages'); // Compteur en sidebar (existant) const sidebarConnected = document.querySelector('#jvchat-connected-count'); if (sidebarConnected) sidebarConnected.textContent = connectedText; // Nouveau : compteur sous le pseudo dans le bloc profil const profileConnected = document.querySelector('#jvchat-profile-connected-count'); if (profileConnected) profileConnected.textContent = connectedText; this.connectedUser = this.getConnectedUser(doc); const profileContainer = document.querySelector('.jvchat-profile'); if (this.connectedUser) { profileContainer.style.display = ''; document.querySelector('#jvchat-username').textContent = this.connectedUser.username; document.querySelector('#jvchat-user-avatar').src = this.connectedUser.avatarUrl; const messageBadge = document.querySelector('#jvchat-user-mp-badge'); if (this.connectedUser.messageCount > 0) { messageBadge.textContent = this.connectedUser.messageCount; messageBadge.style.display = ''; } else { messageBadge.style.display = 'none'; } const notificationBadge = document.querySelector('#jvchat-user-notif-badge'); if (this.connectedUser.notificationCount > 0) { notificationBadge.textContent = this.connectedUser.notificationCount; notificationBadge.style.display = ''; } else { notificationBadge.style.display = 'none'; } const profileAnchors = document.querySelectorAll('.jvchat-profile-url'); for (const anchor of profileAnchors) { anchor.href = this.connectedUser.profileUrl; } document.querySelector('.jvchat-subscriptions-url').href = this.connectedUser.subscriptionsUrl; // Synchroniser les boutons edit/delete sur tous les messages déjà affichés // (cas où la connectedUser est définie APRÈS que des messages aient été ajoutés, // ce qui arrive au premier chargement ou si la session se charge lentement) this._syncOwnMessageControls(); } else { profileContainer.style.display = 'none'; } } /** * Parcourt tous les messages déjà affichés et s'assure que ceux appartenant * à l'utilisateur connecté ont bien leurs boutons edit/delete attachés et visibles. * Utile quand la connectedUser est définie tardivement (session qui se charge en * retard, premier cycle où le polling n'a pas encore récupéré les infos, etc.). */ _syncOwnMessageControls() { if (!this.connectedUser) return; const myUsername = this.connectedUser.username; for (const message of this.messages) { if (message.username !== myUsername) continue; if (!message.element) continue; if (message._controlsAttached) continue; // déjà attachés, éviter les doublons const deleteBtn = message.element.querySelector('.jvchat-delete-btn'); if (deleteBtn) { deleteBtn.style.display = 'block'; deleteBtn.addEventListener('click', () => this.deleteMessage(message)); } const editBtn = message.element.querySelector('.jvchat-edit-btn'); if (editBtn) { editBtn.style.display = 'block'; editBtn.addEventListener('click', () => this.editMessage(message, message.element)); } message._controlsAttached = true; } } async fetchNewMessages() { const cycle = (this._cycle = (this._cycle || 0) + 1); const prevLastPage = this.lastPage; const t0 = performance.now(); dbg(`━━━ Cycle #${cycle} ━━━ polling page ${this.lastPage}`); try { const doc = await this.jvcApi.getPageDocument(this.lastPage); const page = parsePage(doc); this.payload = getPayload(doc); this.jvcApi.payload = this.payload; this.lastPage = page.lastPage; if (!this.payload) { err(`Cycle #${cycle} payload est NULL !`); } else if (!this.payload.ajaxToken) { wrn(`Cycle #${cycle} payload sans ajaxToken`); } this.updateUIFromDoc(doc, page); let newMsgCount = 0; for (const message of page.messages) { const existed = this.messages.some(m => m.id === message.id); this.addMessage(message); if (!existed) newMsgCount++; } if (newMsgCount > 0) { dbg(`➕ ${newMsgCount} nouveaux messages ajoutés`); } // Probe page suivante si page courante pleine if (page.messages.length >= 20) { try { const nextPageNum = this.lastPage + 1; const nextDoc = await this.jvcApi.getPageDocument(nextPageNum); const nextPageData = parsePage(nextDoc); if (nextPageData.lastPage > this.lastPage) { this.lastPage = nextPageData.lastPage; dbg(`📄 Page suivante ${this.lastPage} existe → avance`); for (const msg of nextPageData.messages) { this.addMessage(msg); } } } catch (e) { dbg(`📄 Probe page suivante échoué: ${e.message}`); } } if (this.lastPage !== prevLastPage) { dbg(`📄 lastPage: ${prevLastPage} → ${this.lastPage}`); } const lastMessages = this.messages.slice(this.messages.length - 20, this.messages.length); for (const message of lastMessages) { if (message.page < this.lastPage) { continue; } const existing = page.messages.find((m) => m.id === message.id); if (!existing && !message.isDeleted) { const msg = await this.jvcApi.getMessage(message.id); if (!msg) { message.element.classList.add('jvchat-message-deleted'); message.isDeleted = true; dbg(`🗑️ Message #${message.id} marqué supprimé`); } } } if (!this.isTabActive) { this.jvcClient.updateFaviconWithCount(this.unreadMessagesCount); } if (this.autoScrollingEnabled) { this.scrollToBottom(); } const dt = (performance.now() - t0).toFixed(0); dbg(`Cycle #${cycle} OK (${dt}ms) | lastPage=${this.lastPage} | total msgs=${this.messages.length} | autoScroll=${this.autoScrollingEnabled}`); } catch (error) { err(`Cycle #${cycle} ❌ CRASH:`, error.message); err(`Stack:`, error.stack); } } scrollToBottom() { requestAnimationFrame(() => { this.messagesContainer.scrollTo({ top: this.messagesContainer.scrollHeight, behavior: 'smooth' }); }); } addMessage(message) { const existing = this.messages.find((m) => m.id === message.id); if (existing) { return; } // Ne pas incrémenter unread pendant le chargement initial if (!this.isTabActive && this.initialLoadDone) { this.unreadMessagesCount++; } const html = ` <article class="jvchat-message mt-2" data-id="${message.id}"> <div> <a href="${message.profileUrl}" target="_blank"> <img src="${message.avatarUrl}" alt="${message.username}" class="jvchat-avatar mb-2"> </a> </div> <div class="jvchat-message-body"> <div class="jvchat-message-header"> <a href="${message.profileUrl}" target="_blank"> <span class="jvchat-user">${message.username}</span> </a> <div class="d-flex"> <div class="jvchat-message-controls me-3"> <span class="picto-msg-quote jvchat-quote-btn" title="Citer"><span>Citer</span></span> <span class="picto-msg-crayon jvchat-edit-btn" title="Editer" style="display: none;"><span>Editer</span></span> <span class="picto-msg-croix jvchat-delete-btn" title="Supprimer" style="display: none;"><span>Supprimer</span></span> </div> <span class="jvchat-date">${message.creationDate.slice(message.creationDate.length - 8, message.creationDate.length)}</span> </div> </div> <div class="jvchat-content text-enrichi-forum txt-msg"> ${message.content} </div> </div> </article> `; this.messagesContainer.insertAdjacentHTML('beforeend', html); const messageElement = document.querySelector(`.jvchat-message[data-id="${message.id}"]`); message.element = messageElement; if (this.jvchatSettings.getSettingValue('display_page_separator')) { let pageSeparator = document.querySelector(`.jvchat-page-separator-${message.page}`); if (!pageSeparator) { const template = document.createElement('template'); template.innerHTML = ` <div class="jvchat-page-separator-${message.page} text-center"> <small class="text-muted"> Page ${message.page} </small> </div> `.trim(); pageSeparator = template.content.firstElementChild; } messageElement.insertAdjacentElement('afterend', pageSeparator); } const togglableQuotes = Array.from(messageElement.querySelectorAll('.text-enrichi-forum > blockquote > blockquote')); for (const togglableQuote of togglableQuotes) { const toggleButton = document.createElement('div'); toggleButton.classList.add('nested-quote-toggle-box'); togglableQuote.insertBefore(toggleButton, togglableQuote.firstChild); toggleButton.addEventListener('click', () => { const blockQuote = toggleButton.closest('.blockquote-jv'); const visible = blockQuote.getAttribute('data-visible'); const value = visible === '1' ? '' : '1'; blockQuote.setAttribute('data-visible', value); }); } if (this.connectedUser && this.connectedUser.username === message.username) { const deleteBtn = messageElement.querySelector('.jvchat-delete-btn'); deleteBtn.style.display = 'block'; deleteBtn.addEventListener('click', () => { this.deleteMessage(message); }); const editBtn = messageElement.querySelector('.jvchat-edit-btn'); editBtn.style.display = 'block'; editBtn.addEventListener('click', () => { this.editMessage(message, messageElement); }); message._controlsAttached = true; } const quoteBtn = messageElement.querySelector('.jvchat-quote-btn'); quoteBtn.addEventListener('click', async () => { dbg(`💬 Citation demandée pour #${message.id}`); try { const tempDiv = document.createElement('div'); tempDiv.innerHTML = message.content; const txt = tempDiv.textContent.trim(); let content = `\n> Le ${message.creationDate} ${message.username} a écrit :\n> `; content += txt.split('\n').join('\n> '); content = content.replace(/^[\r\n]+|[\r\n]+$/g, ''); content += '\n\n'; if (this.jvcClient.createMessageTextarea.value.trim() !== '') { content = '\n\n' + content; } this.jvcClient.insertAtCursor(content); this.jvcClient.createMessageTextarea.focus(); dbg(`💬 Citation insérée OK`); } catch (e) { err(`💬 Citation ❌:`, e.message, e.stack); this.jvcClient.alert('error', `Erreur citation : ${e.message}`); } }); // ═══ Images cliquables : décodage JvCare + fallback NoelShack ═══ this.makeImagesClickable(messageElement); // ═══ Liens cliquables : décodage JvCare + fallback URLs brutes ═══ this.makeLinksClickable(messageElement); const mosaics = this.findMosaics(messageElement); for (const mosaic of mosaics) { const container = document.createElement('div'); container.style.lineHeight = 0; mosaic[0].parentElement.insertAdjacentElement('beforebegin', container); for (const image of mosaic) { let insertLineBreak = false; if (image.parentElement.nextElementSibling && image.parentElement.nextElementSibling.tagName === 'BR') { insertLineBreak = true; } container.appendChild(image.parentElement); if (insertLineBreak) { container.appendChild(document.createElement('br')); } } const btn = document.createElement('button'); btn.classList.add('btn', 'btn-annuler-modif-msg', 'mt-2'); if (this.jvchatSettings.getSettingValue('hide_mosaics')) { btn.textContent = 'Cacher'; container.classList.add('d-none'); } else { btn.textContent = 'Afficher'; } btn.addEventListener('click', () => { container.classList.toggle('d-none'); if (container.classList.contains('d-none')) { btn.textContent = 'Afficher'; } else { btn.textContent = 'Cacher'; } }); container.insertAdjacentElement('afterend', btn); } this.messages.push(message); } /** * Rend toutes les images d'un message cliquables (lien vers l'image en grand). * Gère 3 cas : * 1. Images JvCare obfusquées (classe "JvCare <hash>") → décode via jvCake * 2. Images NoelShack non-JvCare → construit l'URL full size (mini_ → final) * 3. Toute autre image → wrap avec un lien vers son src */ makeImagesClickable(messageElement) { // ---- Cas 1 : images JvCare (système d'obfuscation JVC) ---- const jvCareImages = messageElement.querySelectorAll('.img-shack'); for (const image of jvCareImages) { const parent = image.parentElement; if (!parent) continue; // Si déjà dans un <a>, rien à faire if (parent.tagName === 'A' && parent.href) continue; let link = null; const klass = parent.getAttribute('class') || ''; // Décodage JvCare sécurisé try { if (klass.includes(' ')) { link = this.jvcClient.jvCake(klass); } } catch (e) { wrn(`makeImagesClickable | jvCake a échoué: ${e.message}`); } // Fallback : déduire depuis l'URL de l'image elle-même if (!link || !link.startsWith('http')) { link = this.inferFullSizeUrl(image); } if (!link) { wrn(`makeImagesClickable | pas de lien déductible pour`, image); continue; } const anchor = document.createElement('a'); anchor.href = link; anchor.target = '_blank'; anchor.rel = 'noopener noreferrer'; anchor.style.cursor = 'zoom-in'; anchor.appendChild(image); parent.insertAdjacentElement('afterend', anchor); parent.remove(); } // ---- Cas 2 & 3 : toutes les autres images non cliquables ---- const allImages = messageElement.querySelectorAll('img'); for (const img of allImages) { // Skip si déjà dans un <a> fonctionnel const existingAnchor = img.closest('a'); if (existingAnchor && existingAnchor.href && existingAnchor.href !== '#') continue; // Skip avatars, smileys, et icônes if (img.classList.contains('jvchat-avatar')) continue; if (img.classList.contains('smiley')) continue; const src = img.src || img.dataset.src || ''; if (!src) continue; if (src.includes('/smileys/')) continue; const fullUrl = this.inferFullSizeUrl(img); if (!fullUrl) continue; const anchor = document.createElement('a'); anchor.href = fullUrl; anchor.target = '_blank'; anchor.rel = 'noopener noreferrer'; anchor.style.cursor = 'zoom-in'; img.parentElement.insertBefore(anchor, img); anchor.appendChild(img); } } /** * Rend les liens cliquables dans un message. * Gère 3 cas : * 1. Liens JvCare obfusqués : <span class="JvCare XX...">texte</span> → décode via jvCake * 2. Liens JvCare sur <a> : <a class="JvCare ..." href="#">texte</a> → remplace le href * 3. URLs brutes en texte : rare, mais on scanne au cas où * NB : les images (qui sont parfois dans des spans JvCare) sont déjà traitées * par makeImagesClickable(), on les ignore ici. */ makeLinksClickable(messageElement) { // ---- Cas 1 & 2 : éléments avec classe "JvCare" ---- const jvCareElements = messageElement.querySelectorAll('.JvCare'); for (const el of jvCareElements) { // Skip si l'élément contient une image (déjà traité par makeImagesClickable) if (el.querySelector('img')) continue; const klass = el.getAttribute('class') || ''; if (!klass.includes(' ')) continue; let url = null; try { url = this.jvcClient.jvCake(klass); } catch (e) { wrn(`makeLinksClickable | jvCake a échoué: ${e.message}`); continue; } if (!url || !url.match(/^https?:\/\//)) continue; if (el.tagName === 'A') { // Cas 2 : c'est déjà un <a>, on met juste à jour le href et les attrs el.setAttribute('href', url); el.setAttribute('target', '_blank'); el.setAttribute('rel', 'noopener noreferrer'); // Retirer la classe JvCare pour éviter qu'un script JVC re-override el.classList.remove('JvCare'); // Retirer la classe hash (tous les autres tokens après JvCare sont le hash) for (const cls of [...el.classList]) { if (/^[0-9A-F]{10,}$/i.test(cls)) el.classList.remove(cls); } } else { // Cas 1 : c'est un <span>, on le remplace par un <a> const anchor = document.createElement('a'); anchor.href = url; anchor.target = '_blank'; anchor.rel = 'noopener noreferrer'; // Préserver le texte et les enfants while (el.firstChild) { anchor.appendChild(el.firstChild); } el.parentNode.replaceChild(anchor, el); } } // ---- Cas 3 : URLs brutes dans les text nodes ---- // On parcourt uniquement les text nodes qui ne sont pas déjà dans un <a> const urlRegex = /(https?:\/\/[^\s<>()"']+)/g; const walker = document.createTreeWalker( messageElement, NodeFilter.SHOW_TEXT, { acceptNode: (node) => { // Skip si le parent est déjà un <a> ou une balise spéciale let p = node.parentElement; while (p && p !== messageElement) { if (p.tagName === 'A' || p.tagName === 'SCRIPT' || p.tagName === 'STYLE') { return NodeFilter.FILTER_REJECT; } p = p.parentElement; } return urlRegex.test(node.nodeValue) ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_REJECT; } } ); // Collecter avant de modifier (éviter d'invalider le walker) const textNodes = []; let n; while ((n = walker.nextNode())) textNodes.push(n); for (const textNode of textNodes) { const text = textNode.nodeValue; const frag = document.createDocumentFragment(); let lastIndex = 0; // Reset regex state (urlRegex a le flag g) urlRegex.lastIndex = 0; let match; while ((match = urlRegex.exec(text)) !== null) { // Texte avant l'URL if (match.index > lastIndex) { frag.appendChild(document.createTextNode(text.slice(lastIndex, match.index))); } // L'URL comme <a> const anchor = document.createElement('a'); anchor.href = match[0]; anchor.target = '_blank'; anchor.rel = 'noopener noreferrer'; anchor.textContent = match[0]; frag.appendChild(anchor); lastIndex = match.index + match[0].length; } // Texte après la dernière URL if (lastIndex < text.length) { frag.appendChild(document.createTextNode(text.slice(lastIndex))); } if (frag.childNodes.length > 0) { textNode.parentNode.replaceChild(frag, textNode); } } } /** * Tente de construire l'URL pleine résolution à partir du src d'une image. * - NoelShack : mini_xxx.png → xxx.png * - Autres : retourne le src tel quel */ inferFullSizeUrl(img) { const src = img.src || img.dataset.src || ''; if (!src) return null; // NoelShack : transforme l'URL de miniature en URL pleine taille // Format actuel: /minis/YYYY/WW/D/timestamp-nom.ext → /fichiers/YYYY/WW/D/timestamp-nom.ext // Ancien format (legacy): mini_xxx.ext → xxx.ext if (src.includes('noelshack.com')) { let noelshackFull = src; // Nouveau format : /minis/ → /fichiers/ noelshackFull = noelshackFull.replace('/minis/', '/fichiers/'); // Ancien format : mini_xxx → xxx noelshackFull = noelshackFull.replace(/\/([^\/]*?)mini_([^\/]+)$/, '/$1$2'); return noelshackFull; } // Imgur : transforme les thumbnails (b, s, m, l, t) en full // ex: https://i.imgur.com/abc123b.png → https://i.imgur.com/abc123.png if (src.includes('imgur.com')) { const imgurFull = src.replace(/([a-zA-Z0-9]{7})[bslmt](\.[a-z]+)$/, '$1$2'); return imgurFull; } // Par défaut, retourner le src tel quel (permet au moins d'ouvrir l'image en grand dans un onglet) return src; } findMosaics(messageEl) { const tol = 6; const nodes = [...messageEl.querySelectorAll('img.img-shack')].map(img => { const { x, y, width: w, height: h } = img.getBoundingClientRect(); return { img, x, y, w, h }; }); if (nodes.length < 4) return []; const snap = (value, buckets) => { const bucket = buckets.find(v => Math.abs(v - value) < tol); if (bucket !== undefined) return bucket; buckets.push(value); return value; }; const rowVals = []; const colVals = []; nodes.forEach(n => { n.row = snap(n.y, rowVals); n.col = snap(n.x, colVals); }); const adj = new Map(nodes.map(n => [n, new Set])); const byRow = new Map(); const byCol = new Map(); nodes.forEach(n => { (byRow.get(n.row) || byRow.set(n.row, []).get(n.row)).push(n); (byCol.get(n.col) || byCol.set(n.col, []).get(n.col)).push(n); }); byRow.forEach(list => { list.sort((a, b) => a.x - b.x); for (let i = 0; i < list.length - 1; i++) { if (Math.abs(list[i + 1].x - list[i].x - list[i].w) < tol) { adj.get(list[i]).add(list[i + 1]); adj.get(list[i + 1]).add(list[i]); } } }); byCol.forEach(list => { list.sort((a, b) => a.y - b.y); for (let i = 0; i < list.length - 1; i++) { if (Math.abs(list[i + 1].y - list[i].y - list[i].h) < tol) { adj.get(list[i]).add(list[i + 1]); adj.get(list[i + 1]).add(list[i]); } } }); const mosaics = []; const seen = new Set(); nodes.forEach(start => { if (seen.has(start)) return; const stack = [start]; const component = []; while (stack.length) { const n = stack.pop(); if (seen.has(n)) continue; seen.add(n); component.push(n); adj.get(n).forEach(nb => stack.push(nb)); } const row = new Set(component.map(n => n.row)); const col = new Set(component.map(n => n.col)); if (row.size < 2 || col.size < 2) { return; } component.sort((a, b) => (a.row === b.row ? a.col - b.col : a.row - b.row)); mosaics.push(component.map(n => n.img)); }); return mosaics; } getPageUrl(page) { return `https://www.jeuxvideo.com/forums/${this.viewId}-${this.forumId}-${this.topicId}-${page}-0-1-0-${this.topicTitle}.htm`; } createJVChatInterface() { // ═══ Préserver #forums-info-app visible pour maintenir le polling JVC ═══ // Le web-component JVC qui gère le compteur "X connectés" utilise probablement // un IntersectionObserver ou similaire : quand l'élément n'est plus visible, // il arrête de mettre à jour sa valeur. Pour garder le compteur temps réel, // on le déplace dans un conteneur minuscule (1×1 px) mais techniquement // visible (pas display:none) AVANT de masquer le reste. const forumsInfoApp = document.querySelector('#forums-info-app'); let keepAliveContainer = null; if (forumsInfoApp) { keepAliveContainer = document.createElement('div'); keepAliveContainer.id = 'jvchat-keepalive'; keepAliveContainer.style.cssText = ` position: fixed; top: 0; left: 0; width: 1px; height: 1px; overflow: hidden; opacity: 0.01; pointer-events: none; z-index: -1; `; document.body.appendChild(keepAliveContainer); keepAliveContainer.appendChild(forumsInfoApp); dbg(`✅ #forums-info-app déplacé dans jvchat-keepalive pour maintenir le polling`); } else { wrn(`⚠ #forums-info-app introuvable au démarrage — compteur connectés restera figé`); } // Maintenant on masque tous les enfants du body SAUF le keepAlive for (const child of document.body.children) { if (child.id === 'jvchat-keepalive') continue; child.style.display = 'none'; } const html = ` <div class="jvchat-container"> <aside class="jvchat-sidebar"> <span class="breadcrumb-icon icon-config align-self-end jvchat-toggle-settings" title="Paramètres"><span>Réglages</span></span> <div class="jvchat-profile" style="display: none;"> <a class="jvchat-profile-url" target="_blank"> <h2 id="jvchat-username" class="jvchat-username"></h2> </a> <!-- Nouveau : compteur de connectés sous le pseudo --> <div class="jvchat-profile-connected"> <span id="jvchat-profile-connected-count">0 connecté</span> </div> <a class="jvchat-profile-url" target="_blank"> <img id="jvchat-user-avatar" src="https://image.jeuxvideo.com/avatar/default.jpg" alt="Profile avatar" class="jvchat-profile-avatar"> </a> <div class="jvchat-profile-actions"> <a href="https://www.jeuxvideo.com/messages-prives/boite-reception.php" target="_blank"> <span class="headerAccount__pm" data-val="0"> <i class="icon-pm"></i> <span id="jvchat-user-mp-badge" class="jvchat-badge" style="display: none;">0</span> </span> </a> <a class="jvchat-subscriptions-url" target="_blank"> <span class="headerAccount__notif" data-val="0"> <i class="icon-bell-off"></i> <span id="jvchat-user-notif-badge" class="jvchat-badge" style="display: none;">0</span> </span> </a> <button type="button" class="toggleTheme" onclick="window.jvc.toggleTheme();"></button> </div> </div> <a href="https://www.jeuxvideo.com/forums/0-${this.forumId}-0-1-0-1-0-0.htm" class="jvchat-back-to-forum" title="Retour à la liste des sujets"> Liste des sujets </a> <a href="${this.getPageUrl(1)}" class="mt-3"> <h3 id="jvchat-topic-title" class="jvchat-topic-heading"></h3> </a> <span id="jvchat-connected-count"> </span> <span id="jvchat-messages-count"> </span> <div style="flex-grow: 1;"></div> <div class="refresh-loader" style="--value:0"> <span class="refresh-loader__text" style="display: none;">0%</span> </div> <div class="jvchat-settings-panel" id="jvchat-settings-panel"> <span class="breadcrumb-icon icon-config align-self-end jvchat-toggle-settings" title="Paramètres"><span>Réglages</span></span> <div class="jvchat-settings-title">Settings</div> <div id="jvchat-settings-list"> </div> <button class="jvchat-toggle-settings btn btn-annuler-modif-msg mt-2"> Fermer </button> </div> </aside> <section class="jvchat-chat"> <div class="jvchat-messages"> </div> <div class="jvchat-form"> </div> </section> </div> `; document.body.insertAdjacentHTML('beforeend', html); this.messagesContainer = document.querySelector('.jvchat-messages'); const JVCForm = document.querySelector('#forums-post-message-editor'); const formContainer = document.querySelector('.jvchat-form'); formContainer.appendChild(JVCForm); const observer = new MutationObserver(() => { const captcha = JVCForm.querySelector('.js-captcha-logo'); if (captcha) { captcha.parentElement.parentElement.style.display = 'none'; observer.disconnect(); } }); observer.observe(JVCForm, { attributes: true, childList: true, subtree: true }); const postButton = JVCForm.querySelector('.postMessage'); const editorButtons = JVCForm.querySelector('.buttonsEditor'); editorButtons.appendChild(postButton); postButton.classList.add('float-end'); const previewToggleButton = document.querySelector('.buttonSwitch'); const wantsPreview = this.jvchatSettings.getSettingValue('display_preview_by_default'); // On attend un tick pour que JVC ait fini son initialisation du state du bouton, // puis on aligne son état avec le setting utilisateur. setTimeout(() => { const isCurrentlyActive = previewToggleButton.classList.contains('buttonSwitch--isActive'); if (isCurrentlyActive && !wantsPreview) { previewToggleButton.click(); dbg('🔕 Prévisualisation désactivée (setting)'); } else if (!isCurrentlyActive && wantsPreview) { previewToggleButton.click(); dbg('🔔 Prévisualisation activée (setting)'); } }, 100); // Si le setting est désactivé, on s'assure aussi via CSS que le conteneur // de preview reste caché même si JVC le réaffiche pour une raison X. if (!wantsPreview) { const previewContainer = JVCForm.querySelector('.messageEditor__containerPreview'); if (previewContainer) { previewContainer.style.display = 'none'; } } previewToggleButton.addEventListener('click', () => { setTimeout(() => { const previewContainer = JVCForm.querySelector('.messageEditor__containerPreview'); if (previewToggleButton.classList.contains('buttonSwitch--isActive')) { previewContainer.classList.add('mt-3'); previewContainer.style.display = ''; } else { previewContainer.style.display = 'none'; } }, 0); }); // ═══ Textarea : taille initiale + resizable + taille persistée ═══ // Comportement voulu : // - L'auto-grow natif de JVC fonctionne pendant qu'on écrit (ergonomie) // - Après post ou clear, la textarea retourne à la hauteur sauvegardée // - Le drag manuel de la poignée met à jour et persiste la hauteur const TEXTAREA_HEIGHT_KEY = 'jvchat_textarea_height'; const DEFAULT_TEXTAREA_HEIGHT = '140px'; const textarea = JVCForm.querySelector('textarea#message_reponse'); textarea.removeAttribute('rows'); textarea.setAttribute('placeholder', 'Hop hop hop, le message ne va pas s\'écrire tout seul !'); // Nettoyer les styles inline nuisibles au démarrage const containerEdit = JVCForm.querySelector('.messageEditor__containerEdit'); if (containerEdit) { containerEdit.style.overflow = 'visible'; containerEdit.style.maxHeight = 'none'; containerEdit.style.height = 'auto'; } textarea.style.removeProperty('height'); // Hauteur "de repos" : celle restaurée depuis localStorage ou la valeur par défaut let restingHeight = localStorage.getItem(TEXTAREA_HEIGHT_KEY) || DEFAULT_TEXTAREA_HEIGHT; textarea.style.height = restingHeight; dbg(`📏 Hauteur textarea restaurée : ${restingHeight}`); // Helper pour remettre la textarea à sa hauteur de repos // On force à plusieurs reprises car JVC a un auto-grow qui peut re-override // après le vidage de la textarea (événement 'input' déclenche un recalcul). const resetTextareaHeight = () => { textarea.style.height = restingHeight; // Forcer après le prochain cycle de rendu au cas où JVC override dans un listener async requestAnimationFrame(() => { textarea.style.height = restingHeight; // Double sécurité à 50ms pour les setTimeout de JVC setTimeout(() => { textarea.style.height = restingHeight; }, 50); }); dbg(`↩️ Reset textarea à ${restingHeight}`); }; // Exposer le helper sur l'instance pour que createMessage puisse l'appeler après post this._resetTextareaHeight = resetTextareaHeight; // Détecter le drag manuel de la poignée de resize pour persister la nouvelle hauteur. // On utilise mousedown/mouseup et on compare les hauteurs. let dragStartHeight = null; let dragStartValue = null; textarea.addEventListener('mousedown', () => { dragStartHeight = textarea.offsetHeight; dragStartValue = textarea.value; }); document.addEventListener('mouseup', () => { if (dragStartHeight === null) return; const currentHeight = textarea.offsetHeight; // On ne considère que c'est un drag que si la hauteur a changé ET que // le contenu n'a pas changé (sinon c'est juste l'auto-grow qui a agi) if (currentHeight !== dragStartHeight && textarea.value === dragStartValue) { restingHeight = currentHeight + 'px'; localStorage.setItem(TEXTAREA_HEIGHT_KEY, restingHeight); dbg(`💾 Hauteur textarea sauvegardée : ${restingHeight}`); } dragStartHeight = null; dragStartValue = null; }); // Si l'utilisateur vide manuellement la textarea (Ctrl+A + Suppr par exemple), // on reset la hauteur à la hauteur de repos. textarea.addEventListener('input', () => { if (textarea.value === '') { resetTextareaHeight(); } }); // Neutraliser l'init JVC après 500ms (au cas où il posait encore quelque chose) setTimeout(() => { if (!textarea.value && textarea.style.height !== restingHeight) { textarea.style.height = restingHeight; } }, 500); postButton.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); this.createMessage(textarea.value); }); JVCForm.classList.remove('mb-3'); JVCForm.querySelector('.messageEditor__containerEdit').style.marginBottom = '0px'; JVCForm.querySelector('.messageEditor__containerPreview').style.marginBottom = '0px'; const settingPanel = document.querySelector('#jvchat-settings-panel'); const settingsToggleBtns = document.querySelectorAll('.jvchat-toggle-settings'); for (const btn of settingsToggleBtns) { btn.addEventListener('click', () => { settingPanel.classList.toggle('open'); }); } this.messagesContainer.addEventListener('scroll', () => { const isAtTheBottom = this.messagesContainer.scrollHeight - this.messagesContainer.scrollTop <= this.messagesContainer.clientHeight + 1; this.autoScrollingEnabled = isAtTheBottom; }); this.setupSettings(); } getConnectedUser(doc) { if (doc.querySelector('.headerAccount__pm')) { const messageCount = parseInt(doc.querySelector('.headerAccount__pm').getAttribute('data-val')); const notificationCount = parseInt(doc.querySelector('.headerAccount__notif').getAttribute('data-val')); const avatarUrl = doc.querySelector('.headerAccount__avatar').style.backgroundImage.slice(5, -2).replace('/avatar-md/', '/avatar/'); const username = doc.querySelector('.headerAccount__pseudo').textContent.trim(); const profileUrl = `https://www.jeuxvideo.com/profil/${username.toLowerCase()}?mode=infos`; const subscriptionsUrl = `https://www.jeuxvideo.com/profil/${username.toLowerCase()}?mode=abonnements`; return { username, avatarUrl, profileUrl, subscriptionsUrl, messageCount, notificationCount }; } return null; } setupSettings() { const settingsList = document.querySelector('#jvchat-settings-list'); for (const setting of this.jvchatSettings.settings) { const html = ` <div class="jvchat-setting"> <label for="${setting.key}"> ${setting.name} </label> <input type="checkbox" id="${setting.key}" ${setting.value ? 'checked' : ''} /> </div> `; settingsList.insertAdjacentHTML('beforeend', html); const input = settingsList.querySelector(`#${setting.key}`); input.addEventListener('change', () => { this.jvchatSettings.setSetting(setting.key, input.checked); }); } } async createMessage(message) { try { const formData = new FormData(); formData.set('text', message); formData.set('topicId', this.topicId); formData.set('forumId', this.forumId); formData.set('group', 1); formData.set('messageId', 'undefined'); formData.set('ajax_hash', this.payload.ajaxToken); for (const key in this.payload.formSession) { formData.append(key, this.payload.formSession[key]); } const response = await fetch('https://www.jeuxvideo.com/forums/message/add', { credentials: 'include', method: 'POST', mode: 'cors', body: formData }); if (!response.ok) { throw new Error(await response.text()); } const result = await response.json(); if (result.errors) { const messages = []; for (const key of Object.keys(result.errors)) { messages.push(result.errors[key]); } throw new Error(messages.join(' | ')); } this.jvcClient.setTextAreaValue(''); // Remettre la textarea à la hauteur de repos après un post réussi if (this._resetTextareaHeight) { this._resetTextareaHeight(); } this.fetchNewMessages(); } catch (e) { this.jvcClient.alert('error', e.message); console.error(e); } } async deleteMessage(message) { try { // Confirmation avant suppression if (!confirm('Supprimer ce message ?')) return; // ═══ Récupération du hash de modération ═══ // On tente plusieurs sources car JVC peut avoir changé l'identifiant. let deleteHash = null; let hashSource = null; // Source 1 : input classique #ajax_hash_moderation_forum const hashInput = document.querySelector('#ajax_hash_moderation_forum'); if (hashInput && hashInput.value) { deleteHash = hashInput.value; hashSource = '#ajax_hash_moderation_forum'; } // Source 2 : fallback sur d'autres inputs similaires if (!deleteHash) { const altSelectors = [ '[name="ajax_hash_moderation_forum"]', '[name="ajax_hash"]', 'input[id*="moderation"]', 'input[id*="ajax_hash"]', ]; for (const sel of altSelectors) { const el = document.querySelector(sel); if (el && el.value) { deleteHash = el.value; hashSource = sel; break; } } } // Source 3 : fallback sur le ajaxToken du payload (pas idéal car c'est un autre hash, mais on tente) if (!deleteHash && this.payload && this.payload.ajaxToken) { deleteHash = this.payload.ajaxToken; hashSource = 'payload.ajaxToken (fallback)'; wrn(`deleteMessage : utilisation du payload.ajaxToken en fallback, risque d'échec`); } if (!deleteHash) { throw new Error( 'Token de modération introuvable. JVC a peut-être changé la structure. ' + 'Essaie de recharger la page ou de supprimer le message depuis l\'interface JVC native.' ); } dbg(`🗑️ Hash trouvé via ${hashSource}`); const url = `https://www.jeuxvideo.com/forums/message/delete?type=delete&ajax_hash=${deleteHash}&ids=${message.id}`; dbg(`🗑️ Delete request : ${url}`); const response = await fetch(url, { credentials: 'include', method: 'POST', mode: 'cors' }); if (!response.ok) { throw new Error(`HTTP ${response.status} : ${response.statusText}`); } const result = await response.json(); dbg('🗑️ Delete response :', result); if (result.erreur && result.erreur.length > 0) { throw new Error(result.erreur.join(' | ')); } this.jvcClient.alert('success', 'Message supprimé'); this.fetchNewMessages(); } catch (error) { err(`deleteMessage ❌:`, error.message); this.jvcClient.alert('error', `Erreur suppression : ${error.message}`); } } async editMessage(message, messageElement) { try { // L'endpoint ajax_edit_message.php a été supprimé (404) mais POST /forums/message/edit // fonctionne toujours. On récupère le contenu source depuis le DOM du message affiché // au lieu de le demander au serveur. if (!this.jvcApi.payload || !this.jvcApi.payload.ajaxToken) { throw new Error('Token AJAX indisponible. Attends la fin du prochain cycle de polling.'); } // On essaie d'extraire un JVCode plausible depuis le HTML affiché. // C'est imparfait — les quotes complexes, les URL JvCare décodées, les BBcode // spéciaux ne seront pas parfaitement préservés. On fait au mieux. const messageContent = messageElement.querySelector('.jvchat-content'); const oldMessageContent = messageContent.innerHTML; const sourceContent = this._htmlToJvcode(messageContent); dbg(`✏️ Edit : contenu reconstruit depuis le DOM : "${sourceContent.slice(0, 80)}..."`); messageContent.innerHTML = ` <div class="messageEditor__containerEdit"> <textarea class="messageEditor__edit mb-2" style="width: 100%; min-height: 120px; resize: vertical; box-sizing: border-box;"></textarea> <div style="font-size: 0.75rem; opacity: 0.7; margin-bottom: 0.5rem;"> ℹ️ Le formatage original (quotes, liens JvCare, etc.) peut être imparfait — à vérifier avant modification. </div> </div> <div class="d-flex justify-content-start gap-2"> <button class="simpleButton cancelMessage"> <div class="cancelMessage__label">Annuler</div> </button> <button class="simpleButton postMessage" tabindex="1"> <div class="postMessage__label">Modifier</div> <i class="postMessage__icon icon-post-message"></i> </button> </div> `; const textarea = messageContent.querySelector('.messageEditor__edit'); textarea.value = sourceContent; textarea.focus(); const cancelBtn = messageContent.querySelector('.cancelMessage'); cancelBtn.addEventListener('click', () => { messageContent.innerHTML = oldMessageContent; }); const submitBtn = messageContent.querySelector('.postMessage'); submitBtn.addEventListener('click', async () => { try { submitBtn.disabled = true; submitBtn.querySelector('.postMessage__label').textContent = 'Envoi...'; const html = await this.jvcApi.updateMessage(message.id, textarea.value); messageContent.innerHTML = html; this.jvcClient.alert('success', 'Message modifié'); this.makeImagesClickable(messageElement); this.makeLinksClickable(messageElement); } catch (submitError) { err(`Edit submit ❌:`, submitError.message); this.jvcClient.alert('error', `Erreur modification : ${submitError.message}`); submitBtn.disabled = false; submitBtn.querySelector('.postMessage__label').textContent = 'Modifier'; } }); } catch (error) { err(`editMessage ❌:`, error.message); this.jvcClient.alert('error', `Erreur édition : ${error.message}`); } } /** * Convertit le HTML rendu d'un message JVC en JVCode source (avec BBcode). * Best effort : les cas complexes (citations imbriquées, liens JvCare non décodés, * formatage custom) peuvent ne pas être parfaitement reconstruits. */ _htmlToJvcode(rootElement) { // Cloner pour ne pas modifier l'original const clone = rootElement.cloneNode(true); // Virer les boutons nested-quote-toggle qu'on a ajoutés clone.querySelectorAll('.nested-quote-toggle-box').forEach(el => el.remove()); // Virer les conteneurs de mosaïques d'images qu'on a ajoutés clone.querySelectorAll('.btn-annuler-modif-msg').forEach(el => el.remove()); // Parcours récursif pour convertir chaque balise HTML en BBcode const convert = (node) => { if (node.nodeType === Node.TEXT_NODE) { return node.textContent; } if (node.nodeType !== Node.ELEMENT_NODE) return ''; const tag = node.tagName.toLowerCase(); const innerText = Array.from(node.childNodes).map(convert).join(''); switch (tag) { case 'strong': case 'b': return `[b]${innerText}[/b]`; case 'em': case 'i': return `[i]${innerText}[/i]`; case 'u': return `[u]${innerText}[/u]`; case 's': case 'strike': case 'del': return `[s]${innerText}[/s]`; case 'blockquote': // JVC utilise [quote] pour les citations return `[quote]${innerText}[/quote]`; case 'code': return `[code]${innerText}[/code]`; case 'ul': return `[liste]${innerText}[/liste]`; case 'ol': return `[liste=1]${innerText}[/liste]`; case 'li': return `[*]${innerText}\n`; case 'a': { const href = node.getAttribute('href') || ''; if (href && href.startsWith('http') && innerText.trim() !== href) { return `[url=${href}]${innerText}[/url]`; } return href || innerText; } case 'img': { const src = node.getAttribute('src') || node.getAttribute('data-src') || ''; return src ? `[img]${src}[/img]` : ''; } case 'br': return '\n'; case 'p': case 'div': return innerText + '\n'; case 'span': return innerText; default: return innerText; } }; let result = convert(clone); // Nettoyer les multiples sauts de ligne result = result.replace(/\n{3,}/g, '\n\n').trim(); return result; } } /** * Scanne une racine DOM à la recherche d'un nombre de connectés. * Parcourt TOUS les éléments (pas seulement les feuilles) et teste leur * textContent direct (sans le texte des descendants) contre plusieurs patterns. * Retourne { count, source } ou null. * * @param {Document|Element} root - racine à scanner * @param {boolean} [broadFallback=false] - si true, scanne tout le body en fallback */ function scanForConnectedCount(root, broadFallback = false) { const patterns = [ /(\d+)\s*connect[ée]s?\b/i, /(\d+)\s*en\s+ligne/i, /(\d+)\s*membres?\s+(?:connect|en\s+ligne)/i, /(\d+)\s*utilisateurs?/i, ]; // Zones prioritaires où chercher (évite faux positifs sur "4 messages" etc.) const zoneSelectors = [ '#forums-info-app', '.sideCardForum', 'aside', '[class*="sideCard"]', '[class*="connect"]', '[class*="online"]', '[class*="Connect"]', '[class*="Online"]', ]; let zones = Array.from(root.querySelectorAll(zoneSelectors.join(', '))); // Fallback large : si aucune zone trouvée OU si broadFallback explicite, // on scanne l'intégralité du body. Plus lent mais rattrape les HTML exotiques. if (zones.length === 0 || broadFallback) { const body = root.body || root.querySelector('body') || root; zones = [body]; } for (const zone of zones) { const allEls = [zone, ...zone.querySelectorAll('*')]; for (const el of allEls) { // Skip scripts, styles, et autres éléments non-visuels if (['SCRIPT', 'STYLE', 'NOSCRIPT', 'META', 'LINK'].includes(el.tagName)) continue; // textContent direct = texte de l'élément sans celui de ses descendants let directText = ''; for (const node of el.childNodes) { if (node.nodeType === Node.TEXT_NODE) { directText += node.textContent; } } directText = directText.trim(); if (directText) { for (const pat of patterns) { const m = directText.match(pat); if (m) { return { count: parseInt(m[1]), source: `${el.tagName}.${el.className || '(no-class)'} "${directText.slice(0, 50)}"` }; } } } // Fallback : textContent complet de l'élément s'il est petit const fullText = el.textContent.trim(); if (fullText && fullText.length < 60) { for (const pat of patterns) { const m = fullText.match(pat); if (m) { return { count: parseInt(m[1]), source: `${el.tagName}.${el.className || '(no-class)'} (full) "${fullText.slice(0, 50)}"` }; } } } } } return null; } function parsePage(doc) { const title = doc.querySelector('#title-display-container').textContent.trim(); // ═══ Détection du nombre de connectés ═══ // Le compteur n'est PAS présent dans le HTML renvoyé par le serveur JVC — // il est injecté dynamiquement côté client via un web-component (<slot>). // On doit donc le lire depuis le DOM live original (masqué mais toujours mis // à jour par le JS de JVC en arrière-plan). // // Cible prioritaire confirmée : .userCount__number let connectedCount = 0; let connectedSource = null; // Palier 1 : lecture directe depuis le DOM live (cible connue) if (typeof document !== 'undefined' && doc !== document) { const userCountEl = document.querySelector('.userCount__number'); if (userCountEl) { const val = parseInt(userCountEl.textContent.trim()); if (!isNaN(val)) { connectedCount = val; connectedSource = `live[.userCount__number]`; } } } // Palier 2 : fallback sur sélecteurs CSS divers dans le doc fetché // (utile si jamais JVC remet ça dans le HTML serveur un jour) if (connectedCount === 0) { const connectedSelectors = [ '.userCount__number', '#forums-info-app .userCount__number', '.userCount', '#forums-info-app .sideCardForum__headerExtra .sideCardForum__Link', '.sideCardForum__headerExtra .sideCardForum__Link', '.sideCardForum__headerExtra', '[data-connected-count]', '[data-online-count]', ]; for (const sel of connectedSelectors) { const el = doc.querySelector(sel); if (!el) continue; const dataVal = el.getAttribute('data-val') || el.getAttribute('data-connected-count') || el.getAttribute('data-online-count'); if (dataVal && !isNaN(parseInt(dataVal))) { connectedCount = parseInt(dataVal); connectedSource = `doc[${sel}][data-val]`; break; } const m = el.textContent.match(/(\d+)/); if (m) { connectedCount = parseInt(m[1]); connectedSource = `doc[${sel}]`; break; } } } // Palier 3 : scan textuel large dans le doc fetché (au cas où) if (connectedCount === 0) { const found = scanForConnectedCount(doc, true); if (found) { connectedCount = found.count; connectedSource = `scan-doc[${found.source}]`; } } // Palier 4 : scan textuel large dans le DOM live (dernier recours) if (connectedCount === 0 && doc !== document) { const found = scanForConnectedCount(document, true); if (found) { connectedCount = found.count; connectedSource = `scan-live[${found.source}]`; } } if (connectedSource) { dbg(`parsePage | connectés = ${connectedCount} (via ${connectedSource})`); } else { wrn(`parsePage | ⚠ nombre de connectés introuvable`); } let lastPage = 1; const pageAnchors = doc.querySelectorAll('.pagination .pagination__navigation a'); const liveAnchors = document.querySelectorAll('.pagination .pagination__navigation a'); dbg(`parsePage | doc anchors: ${pageAnchors.length} | document (live/caché) anchors: ${liveAnchors.length}`); if (liveAnchors.length === 0 && doc !== document) { dbg(`✅ Confirmation: le DOM live est caché (0 anchors). On utilise bien doc.`); } for (const anchor of pageAnchors) { const page = parseInt(anchor.textContent.trim()); if (!isNaN(page) && page > lastPage) { lastPage = page; } } const currentPage = doc.querySelector('.pagination__item--current'); if (currentPage) { const page = parseInt(currentPage.textContent); if (!isNaN(page) && page > lastPage) { lastPage = page; } } const items = doc.querySelectorAll('.bloc-liste-num-page span a'); for (const item of items) { const page = parseInt(item.textContent.trim()); if (!isNaN(page) && page > lastPage) { lastPage = page; } } const messageList = doc.querySelectorAll('#listMessages .messageUser'); const messages = []; for (const message of messageList) { let avatarUrl = 'https://image.jeuxvideo.com/avatar/default.jpg'; const avatar = message.querySelector('img.avatar__image'); if (avatar) { // JVC utilisait data-src (lazy-loading custom), maintenant ils utilisent // l'attribut natif loading="lazy" avec l'URL dans src directement. // On accepte les deux pour compatibilité. const url = avatar.dataset.src || avatar.getAttribute('src'); if (url) avatarUrl = url; } const username = message.querySelector('.messageUser__label').textContent.trim(); const profileUrl = `https://www.jeuxvideo.com/profil/${username.toLowerCase()}?mode=infos`; const id = message.id.split('-').pop(); messages.push({ id, page: lastPage, username, avatarUrl, profileUrl, content: message.querySelector('.messageUser__msg').innerHTML, creationDate: message.querySelector('.messageUser__date').textContent.trim() }); } const messagesCount = (lastPage * 20) - 20 + messages.length; dbg(`parsePage résultat | lastPage=${lastPage} | msgs sur page=${messages.length} | total estimé=${messagesCount}`); return { title, connectedCount, messagesCount, lastPage, messages }; } function getPayload(doc) { const scripts = doc.getElementsByTagName('script'); let rawPayloadString = null; for (let i = 0; i < scripts.length; i++) { const scriptContent = scripts[i].textContent || scripts[i].innerText; if (scriptContent) { const match = scriptContent.match(/window\.jvc\.forumsAppPayload\s*=\s*['"]([^'"]+)['"]/); if (match && match[1]) { rawPayloadString = match[1]; break; } const jvcVarMatch = scriptContent.match(/jvc\.forumsAppPayload\s*=\s*['"]([^'"]+)['"]/); if (!rawPayloadString && jvcVarMatch && jvcVarMatch[1]) { rawPayloadString = jvcVarMatch[1]; break; } } } if (rawPayloadString) { try { const decodedPayload = JSON.parse(atob(rawPayloadString)); return decodedPayload; } catch (e) { err('getPayload décodage ❌:', e); return null; } } else { wrn('getPayload: forumsAppPayload introuvable dans le doc'); return null; } } class JVCAPI { constructor(viewId, forumId, topicId, topicTitle) { this.viewId = viewId; this.forumId = forumId; this.topicId = topicId; this.topicTitle = topicTitle; this.payload = null; } async getMessageContentToUpdate(messageId) { try { if (!this.payload || !this.payload.ajaxToken) { throw new Error('Token AJAX indisponible'); } const url = `https://www.jeuxvideo.com/forums/ajax_edit_message.php?id_message=${messageId}&ajax_hash=${this.payload.ajaxToken}&action=get`; dbg(`✏️ GET edit content : ${url}`); const response = await fetch(url, { method: 'GET', credentials: 'include' }); if (!response.ok) { throw new Error(`HTTP ${response.status} sur ajax_edit_message.php — l'endpoint a peut-être été supprimé par JVC`); } const data = await response.json(); dbg(`✏️ GET edit response :`, data); if (data.errors && data.errors.length === 0) { return data.jvcode; } else { throw new Error(data.errors?.[0] || 'Erreur inconnue de l\'API JVC'); } } catch (error) { throw new Error(error.message || error); } } async updateMessage(messageId, content) { if (!this.payload || !this.payload.ajaxToken) { throw new Error('Token AJAX indisponible'); } const formData = new FormData(); formData.set('text', content); formData.set('topicId', this.topicId); formData.set('forumId', this.forumId); formData.set('group', 1); formData.set('messageId', messageId); formData.set('ajax_hash', this.payload.ajaxToken); formData.set('resetFormAfterSuccess', 'false'); for (const key in this.payload.formSession) { formData.append(key, this.payload.formSession[key]); } try { dbg(`✏️ POST message/edit pour #${messageId}`); const response = await fetch('https://www.jeuxvideo.com/forums/message/edit', { method: 'POST', credentials: 'include', body: formData }); if (!response.ok) { throw new Error(`HTTP ${response.status} sur message/edit`); } const data = await response.json(); dbg(`✏️ POST edit response :`, data); if (!data.errors || (Array.isArray(data.errors) && data.errors.length === 0)) { return data.html; } else { throw new Error(data.errors[0] || 'Erreur inconnue'); } } catch (error) { throw new Error(error.message || error); } } async getMessage(messageId) { try { const url = `https://www.jeuxvideo.com/forums/message/${messageId}`; const response = await fetch(url); if (!response.ok) { return null; } return await response.text(); } catch (e) { return null; } } async getPageDocument(page) { try { const url = `https://www.jeuxvideo.com/forums/${this.viewId}-${this.forumId}-${this.topicId}-${page}-0-1-0-${this.topicTitle}.htm`; dbg(`⬇ Fetch page ${page}: ${url}`); const t0 = performance.now(); const response = await fetch(url); if (!response.ok) { throw new Error(`Erreur ${response.status}`); } const html = await response.text(); const parser = new DOMParser(); const doc = parser.parseFromString(html, 'text/html'); dbg(`⬆ Page ${page} OK (${(performance.now() - t0).toFixed(0)}ms)`); return doc; } catch (error) { err(`⬆ Fetch page ${page} ❌:`, error.message); throw new Error(error); } } async getMessageQuote(messageId) { try { const url = `https://www.jeuxvideo.com/forums/ajax_citation.php?id_message=${messageId}&ajax_hash=${this.payload.ajaxToken}`; const response = await fetch(url); if (!response.ok) { throw new Error(`Erreur ${response.status}`); } const result = await response.json(); return result.txt; } catch (error) { throw new Error(error); } } } class JVCClient { constructor() { const link = document.querySelector('link[rel*=\'icon\']'); this.faviconUrl = link ? link.href : '/favicon.ico'; this.createMessageTextarea = document.querySelector('textarea#message_reponse'); } parseURL(url) { const matches = url.match(/^https:\/\/www\.jeuxvideo\.com\/(?:recherche\/)?forums\/(\d+)-(\d+)-(\d+)-(\d+)-(\d+)-(\d+)-(\d+)-(.*?)\.htm/); if (matches === null) { throw new Error('Url mismatch'); } const viewId = parseInt(matches[1]); const forumId = parseInt(matches[2]); const topicId = parseInt(matches[3]); const topicPage = parseInt(matches[4]); const forumOffset = parseInt(matches[6]); const title = matches[8]; return { viewId, forumId, topicId, topicPage, forumOffset, title }; } updateFaviconWithCount(count) { const img = new Image(); img.crossOrigin = 'anonymous'; img.onload = function () { const canvas = document.createElement('canvas'); const size = 16; canvas.width = size; canvas.height = size; const ctx = canvas.getContext('2d'); ctx.drawImage(img, 0, 0, size, size); if (count > 0) { ctx.fillStyle = 'DodgerBlue'; ctx.fillRect(0, 0, ctx.measureText(count).width + 3, 11); ctx.fillStyle = 'white'; ctx.font = 'bold 10px Verdana'; ctx.textBaseline = 'bottom'; ctx.fillText(count, 1, 11); } const newFavicon = canvas.toDataURL('image/png'); let link = document.querySelector('link[rel*=\'icon\']'); if (!link) { link = document.createElement('link'); link.rel = 'icon'; document.head.appendChild(link); } link.href = newFavicon; }; img.src = this.faviconUrl; } alert(type, message) { let color = 'text-white'; let bgColor = 'bg-danger'; switch (type) { case 'error': bgColor = 'bg-danger'; break; case 'warning': color = 'text-black'; bgColor = 'bg-warning'; break; case 'success': bgColor = 'bg-success'; break; } const id = `jvchat-alert-${Date.now()}`; const html = ` <div id="${id}" class="position-fixed top-0 w-100 pe-none" style="z-index: 2147483647;"> <div class="toast-container w-100 d-flex align-items-center flex-column p-3 pe-auto"> <div class="toast align-items-center ${color} ${bgColor} border-0 fade show"> <div class="d-flex"> <div class="toast-body"> <div>${message}</div> </div> <button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast" aria-label="Close"></button> </div> </div> </div> </div> `; document.body.insertAdjacentHTML('beforeend', html.trim()); const alert = document.querySelector(`#${id}`); setTimeout(() => { alert.remove(); }, 5000); } jvCake(str) { const base16 = '0A12B34C56D78E9F'; const s = str.split(' ')[1]; let lien = ''; for (let i = 0; i < s.length; i += 2) { lien += String.fromCharCode(base16.indexOf(s.charAt(i)) * 16 + base16.indexOf(s.charAt(i + 1))); } return lien; } setTextAreaValue(value) { const prototype = Object.getPrototypeOf(this.createMessageTextarea); const nativeSetter = Object.getOwnPropertyDescriptor(prototype, 'value').set; nativeSetter.call(this.createMessageTextarea, value); this.createMessageTextarea.dispatchEvent(new Event('input', { bubbles: true })); } insertAtCursor(textToInsert) { const value = this.createMessageTextarea.value; const start = this.createMessageTextarea.selectionStart; const end = this.createMessageTextarea.selectionEnd; const newValue = value.slice(0, start) + textToInsert + value.slice(end); this.setTextAreaValue(newValue); this.createMessageTextarea.selectionStart = this.createMessageTextarea.selectionEnd = start + textToInsert.length; } } class JVChatSettings { constructor() { this.settings = [ { key: 'hide_mosaics', name: 'Masquer les mosaïques', description: 'Cache automatiquement les mosaïques d\'images NoelShack pour réduire le flooding.', value: true }, { key: 'display_page_separator', name: 'Afficher le numéro de page courante', description: '', value: true }, { key: 'display_preview_by_default', name: 'Afficher la prévisualisation par défaut', description: '', value: false } ]; this.loadSettings(); } getSetting(key) { return this.settings.find((setting) => setting.key === key); } getSettingValue(key) { return this.getSetting(key).value; } setSetting(key, value) { const setting = this.getSetting(key); setting.value = value; this.saveSettings(); } saveSettings() { for (const setting of this.settings) { localStorage.setItem(setting.key, setting.value); } } loadSettings() { for (const setting of this.settings) { const item = localStorage.getItem(setting.key); if (item) { setting.value = JSON.parse(item); } } } }

Public Dernière mise à jour: 2026-04-16 09:32:30 PM