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
