Files
2026-03-05 10:25:39 +01:00

425 lines
14 KiB
JavaScript
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// /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ésarchiv­er')
: (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); }
})();