Init Git Prod Mars 2026
This commit is contained in:
commit
d81efe93b1
362 files changed
+86627
No files matched your search
@@ -0,0 +1,424 @@
|
||||
// /messages/messages.js
|
||||
|
||||
(function () {
|
||||
const cfg = window.MESSAGING_CFG || {};
|
||||
const app = document.querySelector('.msg-app');
|
||||
const convList = document.getElementById('conv-list');
|
||||
const threadPanel = document.getElementById('msg-thread-panel');
|
||||
const titleEl = document.getElementById('thread-title');
|
||||
const msgsEl = document.getElementById('messages');
|
||||
const btnBack = document.getElementById('btn-back');
|
||||
const form = document.getElementById('send-form');
|
||||
const input = document.getElementById('msg-input');
|
||||
const fileInput = document.getElementById('msg-file');
|
||||
const hint = document.getElementById('send-hint');
|
||||
const btnArchive = document.getElementById('btn-archive');
|
||||
const btnClose = document.getElementById('btn-close');
|
||||
|
||||
// État
|
||||
let currentConv = Number(threadPanel?.dataset?.convId || cfg.initialConv || 0) || 0;
|
||||
let lastMsgId = 0;
|
||||
let pollTimer = null;
|
||||
let isArchived = false;
|
||||
let isClosed = false;
|
||||
|
||||
// Utils
|
||||
function qs(obj) { return new URLSearchParams(obj).toString(); }
|
||||
function h(s) { const d = document.createElement('div'); d.textContent = (s ?? ''); return d.innerHTML; }
|
||||
function atBottom(el) { return Math.abs(el.scrollHeight - el.scrollTop - el.clientHeight) < 6; }
|
||||
function scrollToBottom() { msgsEl.scrollTop = msgsEl.scrollHeight; }
|
||||
function setShowThread(on) { if (on) app.classList.add('show-thread'); else app.classList.remove('show-thread'); }
|
||||
|
||||
// ===== API helpers =====
|
||||
async function apiListConversations() {
|
||||
const r = await fetch('/messages/api/list_conversations.php', { credentials: 'same-origin' });
|
||||
const j = await r.json();
|
||||
if (!r.ok) throw new Error('list_failed');
|
||||
return j.items || [];
|
||||
}
|
||||
|
||||
async function apiFetchMessages(onlyNew) {
|
||||
if (!currentConv) return [];
|
||||
const url = new URL('/messages/api/fetch_messages.php', location.origin);
|
||||
url.searchParams.set('conversation_id', String(currentConv));
|
||||
if (onlyNew && lastMsgId) url.searchParams.set('since_id', String(lastMsgId));
|
||||
const r = await fetch(url.toString(), { credentials: 'same-origin' });
|
||||
const j = await r.json();
|
||||
if (!r.ok) throw new Error('fetch_failed');
|
||||
return j.items || [];
|
||||
}
|
||||
|
||||
async function apiSendMessage(fd) {
|
||||
const r = await fetch('/messages/api/send_message.php', { method: 'POST', body: fd, credentials: 'same-origin' });
|
||||
const j = await r.json().catch(() => ({}));
|
||||
if (!r.ok || !j.ok) throw new Error(j.error || 'send_failed');
|
||||
return j;
|
||||
}
|
||||
|
||||
async function apiMarkRead() {
|
||||
if (!currentConv) return;
|
||||
|
||||
try {
|
||||
const res = await fetch('/messages/api/mark_read.php', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
credentials: 'same-origin',
|
||||
body: qs({ conversation_id: String(currentConv) })
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
if (data.ok && window.applyBadges) {
|
||||
window.applyBadges(data);
|
||||
}
|
||||
|
||||
} catch (e) {
|
||||
// silencieux volontairement
|
||||
}
|
||||
}
|
||||
|
||||
async function apiDeleteMessage(id) {
|
||||
const fd = new FormData();
|
||||
fd.set('_csrf', cfg.csrf);
|
||||
fd.set('message_id', String(id));
|
||||
const r = await fetch('/messages/api/delete_message.php', { method: 'POST', body: fd, credentials: 'same-origin' });
|
||||
const j = await r.json();
|
||||
if (!r.ok || !j.ok) throw new Error(j.error || 'delete_failed');
|
||||
return j;
|
||||
}
|
||||
|
||||
async function apiArchiveConversation(convId, archive) {
|
||||
const fd = new FormData();
|
||||
fd.set('_csrf', cfg.csrf);
|
||||
fd.set('conversation_id', String(convId));
|
||||
fd.set('archive', String(archive ? 1 : 0));
|
||||
const r = await fetch('/messages/api/archive_conversation.php', { method: 'POST', body: fd, credentials: 'same-origin' });
|
||||
const j = await r.json();
|
||||
if (!r.ok || !j.ok) throw new Error(j.error || 'archive_failed');
|
||||
return j;
|
||||
}
|
||||
|
||||
async function apiCloseConversation(convId, close) {
|
||||
const fd = new FormData();
|
||||
fd.set('_csrf', cfg.csrf);
|
||||
fd.set('conversation_id', String(convId));
|
||||
fd.set('close', String(close ? 1 : 0));
|
||||
const r = await fetch('/messages/api/close_conversation.php', { method: 'POST', body: fd, credentials: 'same-origin' });
|
||||
const j = await r.json();
|
||||
if (!r.ok || !j.ok) throw new Error(j.error || 'close_failed');
|
||||
return j;
|
||||
}
|
||||
|
||||
async function apiGetConversation(convId) {
|
||||
const url = new URL('/messages/api/get_conversation.php', location.origin);
|
||||
url.searchParams.set('conversation_id', String(convId));
|
||||
const r = await fetch(url.toString(), { credentials: 'same-origin' });
|
||||
const j = await r.json();
|
||||
if (!r.ok || !j.ok) throw new Error(j.error || 'meta_failed');
|
||||
return j; // {status, archived_for_me, ...}
|
||||
}
|
||||
|
||||
// ===== Rendu UI =====
|
||||
async function loadConversations() {
|
||||
try {
|
||||
const items = await apiListConversations();
|
||||
renderConversations(items);
|
||||
} catch (e) {
|
||||
convList.innerHTML = `<div class="conv-item"><div class="conv-title">API indisponible</div></div>`;
|
||||
}
|
||||
}
|
||||
|
||||
function renderConversations(items){
|
||||
convList.innerHTML = '';
|
||||
|
||||
if (!items.length){
|
||||
const el = document.createElement('div');
|
||||
el.className = 'conv-item';
|
||||
el.innerHTML = `
|
||||
<div style="flex:1; text-align:left; overflow:hidden">
|
||||
<div class="conv-title">${h(cfg.i18n?.noConversations || 'Sélectionner une conversation')}</div>
|
||||
<div class="conv-last"></div>
|
||||
</div>
|
||||
`;
|
||||
convList.appendChild(el);
|
||||
return;
|
||||
}
|
||||
|
||||
items.forEach(it => {
|
||||
const btn = document.createElement('div'); // div pour pouvoir avoir 2 zones cliquables
|
||||
btn.className = 'conv-item';
|
||||
|
||||
btn.innerHTML = `
|
||||
<div class="conv-main" style="flex:1; text-align:left; overflow:hidden; cursor:pointer;">
|
||||
<div class="conv-title">${h(it.peer_name || ('#'+it.id))}</div>
|
||||
<div class="conv-last">${h(it.last_body || '')}</div>
|
||||
</div>
|
||||
${Number(it.unread||0) ? `<span class="conv-badge">${it.unread}</span>` : ''}
|
||||
<button type="button" class="conv-delete" title="${h(cfg.i18n?.deleteConv || 'Supprimer')}">🗑️</button>
|
||||
`;
|
||||
|
||||
// ouvrir la conversation si on clique sur le bloc texte
|
||||
btn.querySelector('.conv-main').onclick = () => openConversation(Number(it.id));
|
||||
|
||||
// suppression via la corbeille
|
||||
btn.querySelector('.conv-delete').onclick = async (e) => {
|
||||
e.stopPropagation();
|
||||
if (!confirm(cfg.i18n?.deleteConvConfirm || 'Supprimer cette conversation ?')) return;
|
||||
try{
|
||||
await apiDeleteConversation(it.id);
|
||||
// Si on est en train de regarder cette conv, on ferme le thread
|
||||
if (Number(threadPanel.dataset.convId||0) === Number(it.id)) {
|
||||
threadPanel.dataset.convId = '0';
|
||||
titleEl.textContent = cfg.i18n?.noConversations || 'Sélectionner une conversation';
|
||||
msgsEl.innerHTML = '';
|
||||
setShowThread(false);
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.delete('conv');
|
||||
window.history.replaceState(null, '', url.toString());
|
||||
}
|
||||
await loadConversations();
|
||||
}catch(err){
|
||||
alert(cfg.i18n?.deleteConvFail || 'Suppression impossible.');
|
||||
}
|
||||
};
|
||||
|
||||
convList.appendChild(btn);
|
||||
});
|
||||
}
|
||||
async function apiDeleteConversation(convId){
|
||||
const fd = new FormData();
|
||||
fd.set('_csrf', cfg.csrf);
|
||||
fd.set('conversation_id', String(convId));
|
||||
const r = await fetch('/messages/api/delete_conversation.php', { method:'POST', body: fd, credentials: 'same-origin' });
|
||||
const j = await r.json();
|
||||
if (!r.ok || !j.ok) throw new Error(j.error || 'delete_conv_failed');
|
||||
return j;
|
||||
}
|
||||
|
||||
|
||||
async function fetchAndRender(onlyNew) {
|
||||
if (!currentConv) return;
|
||||
try {
|
||||
const items = await apiFetchMessages(onlyNew);
|
||||
renderMessages(items, !!onlyNew);
|
||||
} catch (e) {
|
||||
// silencieux
|
||||
}
|
||||
}
|
||||
|
||||
function renderMessages(items, append) {
|
||||
if (!append) msgsEl.innerHTML = '';
|
||||
const stick = atBottom(msgsEl);
|
||||
|
||||
items.forEach(m => {
|
||||
const mine = (m.sender_role === cfg.role && Number(m.sender_id) === Number(cfg.meId));
|
||||
const wrap = document.createElement('div');
|
||||
wrap.className = 'msg' + (mine ? ' me' : '');
|
||||
wrap.dataset.id = String(m.id);
|
||||
|
||||
const parts = [];
|
||||
if (m.body) parts.push(`<div>${h(m.body)}</div>`);
|
||||
if (m.attachment_path) {
|
||||
const name = m.attachment_name || 'fichier';
|
||||
parts.push(`<div><a href="/messages/api/download_attachment.php?message_id=${Number(m.id)}" rel="noopener">${h(name)}</a></div>`);
|
||||
}
|
||||
|
||||
const actions = `
|
||||
<div class="msg-actions">
|
||||
<button type="button" data-action="delete-for-me">🗑️ ${h(cfg.i18n?.hide || 'Masquer')}</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
wrap.innerHTML = `
|
||||
<div class="bubble">${parts.join('')}</div>
|
||||
<div class="meta">${h(m.created_at || '')}</div>
|
||||
${actions}
|
||||
`;
|
||||
msgsEl.appendChild(wrap);
|
||||
lastMsgId = Math.max(lastMsgId, Number(m.id || 0));
|
||||
});
|
||||
|
||||
if (stick) scrollToBottom();
|
||||
}
|
||||
|
||||
function applyClosedUI(on) {
|
||||
isClosed = !!on;
|
||||
if (btnClose) btnClose.textContent = on ? (cfg.i18n?.reopen || 'Rouvrir') : (cfg.i18n?.close || 'Clôturer');
|
||||
if (input) input.disabled = on;
|
||||
if (fileInput) fileInput.disabled = on;
|
||||
const sendBtn = form?.querySelector('.send-btn');
|
||||
if (sendBtn) sendBtn.disabled = on;
|
||||
|
||||
// bannière info
|
||||
let banner = document.getElementById('thread-closed-banner');
|
||||
if (on) {
|
||||
if (!banner) {
|
||||
banner = document.createElement('div');
|
||||
banner.id = 'thread-closed-banner';
|
||||
banner.className = 'thread-banner';
|
||||
banner.textContent = cfg.i18n?.closedBanner || 'Cette conversation est clôturée.';
|
||||
const body = threadPanel.querySelector('.thread-body');
|
||||
threadPanel.insertBefore(banner, body || threadPanel.firstChild);
|
||||
}
|
||||
} else if (banner) {
|
||||
banner.remove();
|
||||
}
|
||||
}
|
||||
|
||||
async function openConversation(id) {
|
||||
currentConv = Number(id) || 0;
|
||||
threadPanel.dataset.convId = String(currentConv);
|
||||
titleEl.textContent = `…`;
|
||||
setShowThread(true);
|
||||
lastMsgId = 0;
|
||||
|
||||
// labels par défaut
|
||||
if (btnArchive) btnArchive.textContent = cfg.i18n?.archive || 'Archiver';
|
||||
if (btnClose) btnClose.textContent = cfg.i18n?.close || 'Clôturer';
|
||||
applyClosedUI(false);
|
||||
isArchived = false;
|
||||
|
||||
// synchro état réel
|
||||
try {
|
||||
const meta = await apiGetConversation(currentConv);
|
||||
titleEl.textContent = meta.peer_name || `${(cfg.i18n?.conversation || 'Conversation')} #${currentConv}`;
|
||||
applyClosedUI(meta.status === 'closed');
|
||||
isArchived = !!meta.archived_for_me;
|
||||
if (btnArchive) btnArchive.textContent = isArchived
|
||||
? (cfg.i18n?.unarchive || cfg.i18n?.desarchiver || 'Désarchiver')
|
||||
: (cfg.i18n?.archive || 'Archiver');
|
||||
} catch (e) { /* ok si indispo */ }
|
||||
|
||||
await fetchAndRender(false);
|
||||
await fetchAndRender(false);
|
||||
// force descendre tout en bas à l’ouverture
|
||||
msgsEl.scrollTop = msgsEl.scrollHeight;
|
||||
await apiMarkRead();
|
||||
startPolling();
|
||||
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set('conv', String(currentConv));
|
||||
window.history.replaceState(null, '', url.toString());
|
||||
}
|
||||
|
||||
function startPolling() {
|
||||
if (pollTimer) clearInterval(pollTimer);
|
||||
pollTimer = setInterval(async () => {
|
||||
if (!currentConv) return;
|
||||
await fetchAndRender(true);
|
||||
// Optionnel: rafraîchir la liste pour les badges
|
||||
loadConversations();
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
// ===== Handlers =====
|
||||
form?.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
if (!currentConv) return;
|
||||
const fd = new FormData(form);
|
||||
fd.set('conversation_id', String(currentConv));
|
||||
fd.set('_csrf', cfg.csrf);
|
||||
|
||||
const btn = form.querySelector('.send-btn');
|
||||
if (btn) btn.disabled = true;
|
||||
hint.textContent = '';
|
||||
try {
|
||||
await apiSendMessage(fd);
|
||||
form.reset();
|
||||
await fetchAndRender(true);
|
||||
await apiMarkRead();
|
||||
loadConversations();
|
||||
input?.focus();
|
||||
} catch (e2) {
|
||||
hint.textContent = cfg.i18n?.sendError || 'Erreur lors de l’envoi.';
|
||||
} finally {
|
||||
if (btn) btn.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
// Délégation: suppression “pour moi”
|
||||
msgsEl.addEventListener('click', async (e) => {
|
||||
const delBtn = e.target.closest?.('[data-action="delete-for-me"]');
|
||||
if (!delBtn) return;
|
||||
const wrap = delBtn.closest('.msg');
|
||||
const id = Number(wrap?.dataset.id || 0);
|
||||
if (!id) return;
|
||||
|
||||
if (!confirm(cfg.i18n?.hideConfirm || 'Masquer ce message pour vous ?')) return;
|
||||
|
||||
try {
|
||||
await apiDeleteMessage(id);
|
||||
wrap.remove();
|
||||
hint.textContent = cfg.i18n?.hideOk || 'Message masqué.';
|
||||
setTimeout(() => { hint.textContent = ''; }, 2000);
|
||||
} catch (err) {
|
||||
alert(cfg.i18n?.hideFail || 'Suppression impossible pour le moment.');
|
||||
}
|
||||
});
|
||||
|
||||
// Archiver / Désarchiver (toggle)
|
||||
btnArchive?.addEventListener('click', async () => {
|
||||
if (!currentConv) return;
|
||||
try {
|
||||
btnArchive.disabled = true;
|
||||
const target = isArchived ? 0 : 1;
|
||||
await apiArchiveConversation(currentConv, target);
|
||||
isArchived = !!target;
|
||||
|
||||
// Label
|
||||
btnArchive.textContent = isArchived
|
||||
? (cfg.i18n?.unarchive || cfg.i18n?.desarchiver || 'Désarchiver')
|
||||
: (cfg.i18n?.archive || 'Archiver');
|
||||
|
||||
await loadConversations();
|
||||
|
||||
if (isArchived) {
|
||||
// fermer le fil, nettoyer l’URL
|
||||
currentConv = 0;
|
||||
threadPanel.dataset.convId = '0';
|
||||
titleEl.textContent = cfg.i18n?.noConversations || 'Sélectionner une conversation';
|
||||
msgsEl.innerHTML = '';
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.delete('conv');
|
||||
window.history.replaceState(null, '', url.toString());
|
||||
setShowThread(false);
|
||||
}
|
||||
} catch (e) {
|
||||
alert('Archive: erreur.');
|
||||
} finally {
|
||||
btnArchive.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
// Clôturer / Rouvrir (toggle)
|
||||
btnClose?.addEventListener('click', async () => {
|
||||
if (!currentConv) return;
|
||||
try {
|
||||
btnClose.disabled = true;
|
||||
if (!isClosed) {
|
||||
await apiCloseConversation(currentConv, 1);
|
||||
applyClosedUI(true);
|
||||
} else {
|
||||
await apiCloseConversation(currentConv, 0);
|
||||
applyClosedUI(false);
|
||||
}
|
||||
} catch (e) {
|
||||
alert('Clôture: ' + (e.message || 'erreur.'));
|
||||
} finally {
|
||||
btnClose.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
// Retour mobile
|
||||
btnBack?.addEventListener('click', () => {
|
||||
setShowThread(false);
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.delete('conv');
|
||||
window.history.replaceState(null, '', url.toString());
|
||||
});
|
||||
|
||||
// ===== INIT =====
|
||||
loadConversations();
|
||||
if (currentConv) { setShowThread(true); openConversation(currentConv); }
|
||||
else { setShowThread(false); }
|
||||
})();
|
||||
Reference in new issue
Block a user