/* =====================================================================
MSG MOTOC — client PWA
Vanilla JS, aucun build. Sections : état, API, auth, WebSocket,
groupes, conversation, images P2P (WebRTC), réglages, administration.
===================================================================== */
(() => {
'use strict';
/* ------------------------------------------------------------------ */
/* État */
/* ------------------------------------------------------------------ */
const state = {
token: localStorage.getItem('motoc_token') || '',
me: null,
groups: [],
groupsById: new Map(),
currentGroup: null,
messages: [],
oldestId: null,
hasMore: false,
replyTo: null,
ws: null,
wsRetry: 0,
typing: new Map(), // group_id -> Map(phone -> timestamp)
pins: [],
receipts: [], // état de lecture des membres du groupe ouvert
pendingPhone: '',
loginRole: 'benevole',
loadingHistory: false,
};
const $ = (sel) => document.querySelector(sel);
const $$ = (sel) => Array.from(document.querySelectorAll(sel));
/* ------------------------------------------------------------------ */
/* Utilitaires */
/* ------------------------------------------------------------------ */
function escapeHtml(s) {
return String(s ?? '')
.replace(/&/g, '&')
.replace(//g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}
// Applique les liens et les mentions sur du texte DÉJÀ échappé.
function enrich(escaped) {
return escaped
.replace(
/(https?:\/\/[^\s<]+)/g,
(m) => `${m} `,
)
.replace(/(^|\s)(@[\wÀ-ÿ'-]+)/gu, '$1$2 ');
}
function initials(name) {
// Les comptes du bac à sable sont tous préfixés « test_ » : sans cette
// coupe, les vingt avatars afficheraient le même « TE ».
const raw = String(name || '')
.trim()
.replace(/\btest_/gi, '');
// Un compte pas encore activé n'a que son numéro : « 06 11 22 » ne fait
// pas des initiales lisibles, on retombe sur un neutre.
if (!raw || /^[\d\s+.-]+$/.test(raw)) return '?';
const parts = raw.split(/\s+/).filter(Boolean);
if (!parts.length) return '?';
if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase();
return (parts[0][0] + parts[1][0]).toUpperCase();
}
// Couleur d'avatar dérivée du numéro : stable, et alternée cyan/braise.
function avatarClass(phone) {
let h = 0;
for (const c of String(phone || '')) h = (h * 31 + c.charCodeAt(0)) >>> 0;
return h % 2 === 0 ? '' : 'ember';
}
function fmtTime(ms) {
return new Date(ms).toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' });
}
function fmtDay(ms) {
const d = new Date(ms);
const today = new Date();
const yest = new Date();
yest.setDate(today.getDate() - 1);
const same = (a, b) => a.toDateString() === b.toDateString();
if (same(d, today)) return "Aujourd'hui";
if (same(d, yest)) return 'Hier';
return d.toLocaleDateString('fr-FR', { weekday: 'long', day: 'numeric', month: 'long' });
}
function fmtRelative(ms) {
if (!ms) return '';
const diff = Date.now() - ms;
if (diff < 60000) return "à l'instant";
if (diff < 3600000) return `${Math.floor(diff / 60000)} min`;
if (diff < 86400000) return fmtTime(ms);
const d = new Date(ms);
return d.toLocaleDateString('fr-FR', { day: '2-digit', month: '2-digit' });
}
function fmtBytes(n) {
if (n === null || n === undefined) return '—';
if (n < 1024) return `${Math.round(n)} o`;
if (n < 1048576) return `${(n / 1024).toFixed(0)} Ko`;
if (n < 1073741824) return `${(n / 1048576).toFixed(1)} Mo`;
return `${(n / 1073741824).toFixed(2)} Go`;
}
function fmtDateTime(ms) {
return new Date(ms).toLocaleString('fr-FR', {
day: '2-digit',
month: '2-digit',
hour: '2-digit',
minute: '2-digit',
});
}
function toast(message, kind = '') {
const el = document.createElement('div');
el.className = `toast ${kind}`;
el.textContent = message;
$('#toasts').appendChild(el);
setTimeout(() => {
el.style.opacity = '0';
el.style.transition = 'opacity .3s';
setTimeout(() => el.remove(), 300);
}, 3600);
}
function vibrate(pattern) {
if (navigator.vibrate) {
try {
navigator.vibrate(pattern);
} catch (e) {
/* ignoré */
}
}
}
/* ------------------------------------------------------------------ */
/* API */
/* ------------------------------------------------------------------ */
async function api(path, options = {}) {
// Un FormData part tel quel : le navigateur pose lui-même le Content-Type
// avec la frontière multipart, qu'on ne peut pas écrire à la main.
const isForm = options.body instanceof FormData;
const headers = {
...(isForm ? {} : { 'Content-Type': 'application/json' }),
...(options.headers || {}),
};
if (state.token) headers.Authorization = `Bearer ${state.token}`;
const res = await fetch(path, {
...options,
headers,
body: options.body ? (isForm ? options.body : JSON.stringify(options.body)) : undefined,
});
let data = null;
try {
data = await res.json();
} catch (e) {
data = null;
}
if (!res.ok) {
if (res.status === 401 && state.token) {
logout(true);
throw new Error('Session expirée. Reconnectez-vous.');
}
throw new Error((data && data.detail) || `Erreur ${res.status}`);
}
return data;
}
/* ------------------------------------------------------------------ */
/* Navigation entre écrans */
/* ------------------------------------------------------------------ */
function showScreen(id) {
$$('.screen').forEach((s) => s.classList.remove('active'));
$(`#${id}`).classList.add('active');
}
function openOverlay(id) {
$(`#${id}`).classList.add('active');
history.pushState({ overlay: id }, '');
}
function closeOverlay(id) {
$(`#${id}`).classList.remove('active');
// Quitter le panneau d'administration arrête le sondage matériel et
// rend à l'application son cadre étroit.
if (id === 'screen-admin') {
stopSystem();
$('#app').classList.remove('desk');
}
}
window.addEventListener('popstate', () => {
// Le bouton « retour » ferme la vue superposée la plus haute.
for (const id of ['screen-sandbox', 'screen-chat', 'screen-admin', 'screen-settings']) {
const el = $(`#${id}`);
if (el.classList.contains('active')) {
if (id === 'screen-chat') leaveChat(false);
else closeOverlay(id);
return;
}
}
});
/* ------------------------------------------------------------------ */
/* Authentification */
/* ------------------------------------------------------------------ */
function authError(message) {
$('#auth-error').textContent = message || '';
}
function showAuthStep(step) {
$$('.auth-step').forEach((el) => {
el.hidden = el.id !== step;
});
authError('');
}
/* --- Choix du profil : Admin ou Bénévole ---------------------------- */
// « Admin » signifie superadmin. La vérification est faite côté serveur
// après le PIN : l'écran ne révèle jamais quels numéros sont admins.
function setRole(role) {
state.loginRole = role;
const isAdmin = role === 'admin';
$('#role-pill-slot').innerHTML = `
${isAdmin ? 'Espace admin' : 'Espace bénévole'} `;
showAuthStep('step-phone');
setTimeout(() => $('#in-phone').focus(), 120);
}
$$('.role-card').forEach((card) =>
card.addEventListener('click', () => setRole(card.dataset.role)),
);
$$('[data-back-to-role]').forEach((b) =>
b.addEventListener('click', () => showAuthStep('step-role')),
);
// Saisie du téléphone : groupée par deux, chiffres uniquement.
$('#in-phone').addEventListener('input', (e) => {
const digits = e.target.value.replace(/\D/g, '').slice(0, 10);
e.target.value = digits.replace(/(\d{2})(?=\d)/g, '$1 ').trim();
});
// Champs PIN : chiffres uniquement.
$$('.input-pin').forEach((input) => {
input.addEventListener('input', (e) => {
e.target.value = e.target.value.replace(/\D/g, '').slice(0, 6);
});
});
async function submitPhone() {
const phone = $('#in-phone').value.replace(/\D/g, '');
if (phone.length !== 10) return authError('Saisissez un numéro à 10 chiffres.');
const btn = $('#btn-phone');
btn.disabled = true;
try {
const res = await api('/api/auth/check', { method: 'POST', body: { phone } });
state.pendingPhone = phone;
if (res.state === 'register') {
$('#reg-phone').textContent = res.phone_display;
// Numéro jamais vu : on prévient que les groupes viendront plus tard,
// sinon l'écran suivant paraît cassé (aucun groupe à l'arrivée).
$('#reg-newcomer').hidden = !res.new_account;
showAuthStep('step-register');
setTimeout(() => $('#in-first').focus(), 120);
} else if (res.state === 'login') {
$('#login-name').textContent = res.display_name || 'Bienvenue';
$('#login-phone').textContent = res.phone_display;
showAuthStep('step-login');
setTimeout(() => $('#in-pin').focus(), 120);
} else {
authError(res.message || 'Numéro non reconnu.');
}
} catch (err) {
authError(err.message);
} finally {
btn.disabled = false;
}
}
async function submitRegister() {
const first = $('#in-first').value.trim();
const last = $('#in-last').value.trim();
const nick = $('#in-nick').value.trim();
const pin1 = $('#in-pin1').value;
const pin2 = $('#in-pin2').value;
if (!first) return authError('Le prénom est obligatoire.');
if (!last) return authError('Le nom est obligatoire.');
if (pin1.length !== 6) return authError('Le code PIN doit contenir 6 chiffres.');
if (pin1 !== pin2) return authError('Les deux codes PIN ne correspondent pas.');
const btn = $('#btn-register');
btn.disabled = true;
try {
const res = await api('/api/auth/register', {
method: 'POST',
body: {
phone: state.pendingPhone,
first_name: first,
last_name: last,
nickname: nick,
pin: pin1,
role: state.loginRole,
},
});
onAuthenticated(res);
} catch (err) {
authError(err.message);
} finally {
btn.disabled = false;
}
}
async function submitLogin() {
const pin = $('#in-pin').value;
if (pin.length !== 6) return authError('Le code PIN doit contenir 6 chiffres.');
const btn = $('#btn-login');
btn.disabled = true;
try {
const res = await api('/api/auth/login', {
method: 'POST',
body: { phone: state.pendingPhone, pin, role: state.loginRole },
});
onAuthenticated(res);
} catch (err) {
authError(err.message);
$('#in-pin').value = '';
} finally {
btn.disabled = false;
}
}
function onAuthenticated(res) {
state.token = res.token;
state.me = res.user;
localStorage.setItem('motoc_token', res.token);
$('#in-pin').value = $('#in-pin1').value = $('#in-pin2').value = '';
enterApp();
}
function logout(silent) {
state.token = '';
state.me = null;
localStorage.removeItem('motoc_token');
if (state.ws) {
try {
state.ws.close();
} catch (e) {
/* ignoré */
}
state.ws = null;
}
$$('.screen.overlay').forEach((s) => s.classList.remove('active'));
$('#app').classList.remove('shell', 'desk');
showScreen('screen-auth');
showAuthStep('step-role');
if (!silent) toast('Vous êtes déconnecté.');
}
$('#btn-phone').addEventListener('click', submitPhone);
$('#btn-register').addEventListener('click', submitRegister);
$('#btn-login').addEventListener('click', submitLogin);
$$('[data-back-to-phone]').forEach((b) =>
b.addEventListener('click', () => showAuthStep('step-phone')),
);
$('#in-phone').addEventListener('keydown', (e) => e.key === 'Enter' && submitPhone());
$('#in-pin').addEventListener('keydown', (e) => e.key === 'Enter' && submitLogin());
$('#in-pin2').addEventListener('keydown', (e) => e.key === 'Enter' && submitRegister());
/* ------------------------------------------------------------------ */
/* Entrée dans l'application */
/* ------------------------------------------------------------------ */
async function enterApp() {
showScreen('screen-groups');
// Passe l'application en disposition « poste de travail » : au-delà de
// 1024 px, la liste des groupes et la conversation cohabitent.
$('#app').classList.add('shell');
$('#btn-admin').hidden = !state.me.is_superadmin;
await loadGroups();
connectWS();
refreshPushSwitch();
loadHelpRequests();
// Ouverture directe d'un groupe depuis une notification.
const target = new URLSearchParams(location.search).get('groupe');
if (target) {
history.replaceState(null, '', '/');
const g = state.groupsById.get(Number(target));
if (g) openChat(g.id);
}
}
async function boot() {
if (!state.token) {
showScreen('screen-auth');
return;
}
try {
state.me = await api('/api/me');
await enterApp();
} catch (err) {
logout(true);
}
}
/* ------------------------------------------------------------------ */
/* WebSocket */
/* ------------------------------------------------------------------ */
function setConnState(text, ok) {
const el = $('#conn-state');
el.textContent = text;
el.style.color = ok ? 'var(--text-faint)' : 'var(--ember)';
}
function connectWS() {
if (!state.token) return;
if (state.ws && state.ws.readyState <= 1) return;
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
const ws = new WebSocket(`${proto}://${location.host}/ws`);
state.ws = ws;
setConnState('Connexion…', false);
ws.onopen = () => {
// Le token part dans une frame, jamais dans l'URL : une query string
// finit dans les logs de proxy et l'historique du navigateur.
ws.send(JSON.stringify({ type: 'auth', token: state.token }));
state.wsRetry = 0;
setConnState('Authentification…', false);
if (state.currentGroup) wsSend({ type: 'focus', group_id: state.currentGroup.id });
};
ws.onmessage = (event) => {
let data;
try {
data = JSON.parse(event.data);
} catch (e) {
return;
}
handleWsMessage(data);
};
ws.onclose = (event) => {
state.ws = null;
if (event.code === 4001) {
logout(true);
return;
}
if (event.code === 4002) {
setConnState('Trop d’appareils connectés', false);
return;
}
setConnState('Hors ligne', false);
// Reconnexion en exponential backoff, plafonnée à 15 s.
state.wsRetry = Math.min(state.wsRetry + 1, 6);
setTimeout(connectWS, Math.min(1000 * 2 ** state.wsRetry, 15000));
};
ws.onerror = () => {
/* onclose prend le relais */
};
}
function wsSend(payload) {
if (state.ws && state.ws.readyState === 1) {
state.ws.send(JSON.stringify(payload));
return true;
}
return false;
}
function handleWsMessage(data) {
switch (data.type) {
case 'ready':
setConnState('En ligne', true);
break;
case 'message':
onIncomingMessage(data.message);
break;
case 'message_deleted':
if (state.currentGroup && state.currentGroup.id === data.group_id) {
state.messages = state.messages.filter((m) => m.id !== data.id);
renderThread();
}
loadGroups();
break;
case 'typing':
onTyping(data);
break;
case 'groups_changed':
loadGroups();
break;
case 'help_request':
toast(`${data.label} demande de l'aide pour se connecter.`);
vibrate([40, 60, 40]);
loadHelpRequests();
break;
case 'read_receipt': {
// Fait avancer les coches sans recharger toute la conversation.
if (!state.currentGroup || state.currentGroup.id !== data.group_id) break;
const member = state.receipts.find((m) => m.phone === data.phone);
if (member) {
member.last_read_id = Math.max(member.last_read_id, data.last_read_id);
member.online = true;
renderThread();
}
break;
}
case 'moderation_alert':
toast(
`Signalement · ${data.alert.category_label} — ${data.alert.author} dans ${data.alert.group_name}`,
'error',
);
vibrate([40, 60, 40]);
if ($('#screen-admin').classList.contains('active')) loadModeration();
break;
case 'new_self_registration':
// Le compte existe mais n'a aucun groupe : sans cette alerte, la
// personne attendrait indéfiniment devant une liste vide.
toast(`${data.label} vient de s'inscrire. À affecter à un groupe.`);
vibrate([40, 60, 40]);
loadAdminUsers();
break;
case 'message_pinned':
if (state.currentGroup && state.currentGroup.id === data.group_id) {
const existing = state.messages.find((m) => m.id === data.message.id);
if (existing) existing.pinned_at = data.message.pinned_at;
loadPins(data.group_id);
renderThread();
}
break;
case 'rtc-signal':
handleRtcSignal(data);
break;
case 'image-request':
handleImageRequest(data);
break;
}
}
function onIncomingMessage(msg) {
const open = state.currentGroup && state.currentGroup.id === msg.group_id;
if (open) {
if (state.messages.some((m) => m.id === msg.id)) return;
const atBottom = isThreadAtBottom();
state.messages.push(msg);
renderThread();
if (atBottom) scrollThreadToBottom();
markRead(msg.group_id, msg.id);
} else if (msg.author_phone !== state.me.phone && msg.kind !== 'system') {
showInAppNotif(msg);
vibrate(40);
}
loadGroups();
}
function showInAppNotif(msg) {
const group = state.groupsById.get(msg.group_id);
const el = document.createElement('div');
el.className = 'inapp-notif';
const preview = msg.kind === 'image' ? '📷 Photo' : msg.body;
el.innerHTML = `
${escapeHtml(initials(msg.author_label))}
${escapeHtml(group ? group.name : 'Nouveau message')}
${escapeHtml(msg.author_label)} : ${escapeHtml(preview)}
`;
el.addEventListener('click', () => {
el.remove();
openChat(msg.group_id);
});
document.body.appendChild(el);
setTimeout(() => el.remove(), 4500);
}
function onTyping(data) {
if (!state.currentGroup || state.currentGroup.id !== data.group_id) return;
if (data.phone === state.me.phone) return;
let map = state.typing.get(data.group_id);
if (!map) {
map = new Map();
state.typing.set(data.group_id, map);
}
map.set(data.phone, { name: data.name, at: Date.now() });
renderTyping();
}
function renderTyping() {
const line = $('#typing-line');
if (!state.currentGroup) {
line.textContent = '';
return;
}
const map = state.typing.get(state.currentGroup.id);
if (!map) {
line.textContent = '';
return;
}
const now = Date.now();
for (const [phone, info] of map) if (now - info.at > 4000) map.delete(phone);
const names = Array.from(map.values()).map((v) => v.name);
if (!names.length) line.textContent = '';
else if (names.length === 1) line.textContent = `${names[0]} écrit…`;
else line.textContent = `${names.slice(0, 2).join(', ')} écrivent…`;
}
setInterval(renderTyping, 1500);
/* ------------------------------------------------------------------ */
/* Liste des groupes */
/* ------------------------------------------------------------------ */
async function loadGroups() {
try {
const res = await api('/api/groups');
state.groups = res.groups;
state.groupsById = new Map(res.groups.map((g) => [g.id, g]));
renderGroups();
updateBadge();
} catch (err) {
/* la reconnexion réessaiera */
}
}
function updateBadge() {
const total = state.groups.reduce((sum, g) => sum + g.unread, 0);
if (navigator.setAppBadge) {
try {
total ? navigator.setAppBadge(total) : navigator.clearAppBadge();
} catch (e) {
/* ignoré */
}
}
document.title = total ? `(${total}) MSG MOTOC` : 'MSG MOTOC';
}
function renderGroups() {
const term = $('#in-search').value.trim().toLowerCase();
const list = $('#groups-list');
let groups = state.groups;
if (term) groups = groups.filter((g) => g.name.toLowerCase().includes(term));
if (!groups.length) {
list.innerHTML = `
${term ? 'Aucun résultat' : 'Aucun groupe'}
${
term
? 'Essayez un autre terme de recherche.'
: "Vous n'appartenez encore à aucun groupe. Un administrateur doit vous y ajouter."
}
`;
return;
}
// Arborescence à deux niveaux : groupe principal puis sous-groupes.
const ids = new Set(groups.map((g) => g.id));
const roots = groups.filter((g) => !g.parent_id || !ids.has(g.parent_id));
const children = new Map();
for (const g of groups) {
if (g.parent_id && ids.has(g.parent_id)) {
if (!children.has(g.parent_id)) children.set(g.parent_id, []);
children.get(g.parent_id).push(g);
}
}
const html = roots
.map((root) => {
const kids = children.get(root.id) || [];
return `
${groupRow(root, false)}
${kids.map((k) => groupRow(k, true)).join('')}
`;
})
.join('');
list.innerHTML = html;
list.querySelectorAll('.group-row').forEach((row) => {
row.addEventListener('click', () => openChat(Number(row.dataset.id)));
});
}
function groupRow(g, isChild) {
const last = g.last_message;
let preview = 'Aucun message';
if (last) {
if (last.kind === 'system') preview = last.body;
else if (last.kind === 'image') preview = `${last.author_label} : 📷 Photo`;
else preview = `${last.author_label} : ${last.body}`;
}
return `
${escapeHtml(initials(g.name))}
${escapeHtml(g.name)}
${g.role === 'admin' ? 'Admin ' : ''}
${g.notif_mode === 'mute' ? 'Muet ' : ''}
${escapeHtml(preview)}
${last ? fmtRelative(last.created_at) : ''}
${g.unread ? `${g.unread > 99 ? '99+' : g.unread} ` : ''}
`;
}
$('#in-search').addEventListener('input', renderGroups);
/* ------------------------------------------------------------------ */
/* Conversation */
/* ------------------------------------------------------------------ */
async function openChat(groupId) {
const group = state.groupsById.get(groupId);
if (!group) return;
state.currentGroup = group;
state.messages = [];
state.replyTo = null;
updateReplyBar();
$('#chat-title').textContent = group.name;
$('#chat-sub').textContent = `${group.member_count} membre${group.member_count > 1 ? 's' : ''}`;
$('#thread').innerHTML =
'
';
openOverlay('screen-chat');
wsSend({ type: 'focus', group_id: groupId });
loadPins(groupId);
await loadReceipts(groupId);
try {
const res = await api(`/api/groups/${groupId}/messages`);
state.messages = res.messages;
state.hasMore = res.has_more;
state.oldestId = res.messages.length ? res.messages[0].id : null;
renderThread();
scrollThreadToBottom();
if (res.messages.length) {
markRead(groupId, res.messages[res.messages.length - 1].id);
}
} catch (err) {
toast(err.message, 'error');
}
}
/* --- Accusés de réception ------------------------------------------- */
// Un seul chargement par conversation : l'état de lecture de chaque membre.
// Les coches de chaque message s'en déduisent par comparaison d'identifiant,
// ce qui évite de stocker un accusé par message et par destinataire.
async function loadReceipts(groupId) {
try {
const res = await api(`/api/groups/${groupId}/receipts`);
state.receipts = res.members;
} catch (err) {
state.receipts = [];
}
}
// Destinataires : tout le monde sauf soi.
function others() {
return state.receipts.filter((m) => m.phone !== state.me.phone);
}
function readersOf(messageId) {
return others().filter((m) => m.last_read_id >= messageId);
}
// « Remis » : le destinataire s'est connecté après l'envoi, donc son
// appareil a reçu le message. C'est une approximation assumée — le seul
// moyen d'être exact serait un accusé par message et par appareil, dont le
// coût de stockage est disproportionné ici.
function deliveredTo(message) {
return others().filter((m) => m.online || m.last_seen >= message.created_at);
}
function receiptState(message) {
const total = others().length;
if (!total) return { level: 'sent', label: 'Envoyé' };
const read = readersOf(message.id).length;
if (read >= total) return { level: 'read', label: 'Lu par tout le monde' };
if (read > 0) return { level: 'read-partial', label: `Lu par ${read} sur ${total}` };
const delivered = deliveredTo(message).length;
if (delivered >= total) return { level: 'delivered', label: 'Remis à tout le monde' };
if (delivered > 0) return { level: 'delivered', label: `Remis à ${delivered} sur ${total}` };
return { level: 'sent', label: 'Envoyé' };
}
function receiptMark(message) {
// Uniquement sur ses propres messages : ailleurs, l'information n'a pas de sens.
if (message.author_phone !== state.me.phone || message.kind === 'system') return '';
const { level, label } = receiptState(message);
const icon = level === 'sent' ? 'i-check' : 'i-check2';
return ` `;
}
async function showReaders(message) {
await loadReceipts(message.group_id);
const read = readersOf(message.id);
const delivered = deliveredTo(message).filter((m) => m.last_read_id < message.id);
const pending = others().filter(
(m) => m.last_read_id < message.id && !delivered.includes(m),
);
const block = (title, people, className) =>
people.length
? `
${escapeHtml(title)} · ${people.length}
${people
.map(
(m) => `
${escapeHtml(initials(m.display_name))}
${escapeHtml(m.display_name)}
${m.online ? '
' : ''}
`,
)
.join('')}
`
: '';
openSheet(`
Accusés de lecture
« ${escapeHtml(
(message.kind === 'image' ? 'Photo' : message.body).slice(0, 90),
)} »
${block('Lu', read, 'read')}
${block('Remis, pas encore lu', delivered, 'delivered')}
${block('Pas encore remis', pending, 'pending')}
${
!others().length
? 'Vous êtes seul dans ce groupe pour le moment.
'
: ''
}
`);
}
/* --- Messages épinglés ---------------------------------------------- */
// Bandeau permanent en haut du fil : les consignes de service doivent
// rester visibles sans remonter l'historique.
async function loadPins(groupId) {
const bar = $('#pin-bar');
try {
const res = await api(`/api/groups/${groupId}/pins`);
state.pins = res.messages;
if (!res.messages.length) {
bar.hidden = true;
bar.innerHTML = '';
return;
}
bar.hidden = false;
bar.innerHTML = res.messages
.map(
(m) => `
${escapeHtml(m.author_label)}
${escapeHtml(m.kind === 'image' ? 'Photo épinglée' : m.body).slice(0, 160)}
`,
)
.join('');
bar.querySelectorAll('[data-pin]').forEach((item) =>
item.addEventListener('click', () => {
const target = document.getElementById(`msg-${item.dataset.pin}`);
if (target) target.scrollIntoView({ behavior: 'smooth', block: 'center' });
else toast("Ce message n'est pas dans la partie chargée du fil.");
}),
);
} catch (err) {
bar.hidden = true;
}
}
function leaveChat(pop = true) {
closeOverlay('screen-chat');
state.currentGroup = null;
wsSend({ type: 'focus', group_id: null });
loadGroups();
if (pop) history.back();
}
function markRead(groupId, lastId) {
wsSend({ type: 'read', group_id: groupId, last_read_id: lastId });
const g = state.groupsById.get(groupId);
if (g && g.unread) {
g.unread = 0;
renderGroups();
updateBadge();
}
}
function isThreadAtBottom() {
const t = $('#thread');
return t.scrollHeight - t.scrollTop - t.clientHeight < 120;
}
function scrollThreadToBottom() {
const t = $('#thread');
requestAnimationFrame(() => {
t.scrollTop = t.scrollHeight;
});
}
function renderThread() {
const thread = $('#thread');
if (!state.messages.length) {
thread.innerHTML = `
Conversation vide
Envoyez le premier message.
`;
return;
}
const parts = [];
if (state.hasMore) {
parts.push(
'Charger les messages précédents ',
);
}
let lastDay = '';
let prev = null;
for (const msg of state.messages) {
const day = fmtDay(msg.created_at);
if (day !== lastDay) {
parts.push(`${escapeHtml(day)}
`);
lastDay = day;
prev = null;
}
if (msg.kind === 'system') {
parts.push(`${escapeHtml(msg.body)}
`);
prev = null;
continue;
}
// Regroupement : même auteur, à moins de 5 minutes d'écart.
const grouped =
prev &&
prev.author_phone === msg.author_phone &&
msg.created_at - prev.created_at < 300000;
parts.push(renderMessage(msg, grouped));
prev = msg;
}
thread.innerHTML = parts.join('');
const more = $('#btn-more');
if (more) more.addEventListener('click', loadOlder);
attachMessageHandlers();
hydrateImages();
}
function renderMessage(msg, grouped) {
const mine = msg.author_phone === state.me.phone;
const cls = `msg ${mine ? 'mine' : 'theirs'} ${grouped ? 'grouped' : ''}`;
let quote = '';
if (msg.reply_to) {
const parent = state.messages.find((m) => m.id === msg.reply_to);
quote = `
${escapeHtml(parent ? parent.author_label : 'Message')}
${escapeHtml(parent ? (parent.kind === 'image' ? '📷 Photo' : parent.body) : 'Message supprimé')}
`;
}
let body = '';
if (msg.kind === 'image') {
const meta = msg.meta || {};
body = `
Photo · ${escapeHtml(fmtBytes(meta.size || 0))}
`;
if (msg.body) body += `${enrich(escapeHtml(msg.body))}
`;
} else {
body = `${enrich(escapeHtml(msg.body))}
`;
}
return `
${
mine
? ''
: `
${escapeHtml(initials(msg.author_label))}
`
}
${mine ? '' : `
${escapeHtml(msg.author_label)}
`}
${quote}
${body}
${
msg.pinned_at ? ' ' : ''
}${fmtTime(msg.created_at)}${msg.edited_at ? ' · modifié' : ''}${receiptMark(msg)}
`;
}
async function loadOlder() {
if (state.loadingHistory || !state.oldestId) return;
state.loadingHistory = true;
const thread = $('#thread');
const prevHeight = thread.scrollHeight;
try {
const res = await api(
`/api/groups/${state.currentGroup.id}/messages?before=${state.oldestId}`,
);
if (res.messages.length) {
state.messages = res.messages.concat(state.messages);
state.oldestId = res.messages[0].id;
}
state.hasMore = res.has_more;
renderThread();
// On conserve la position de lecture après insertion en tête.
thread.scrollTop = thread.scrollHeight - prevHeight;
} catch (err) {
toast(err.message, 'error');
} finally {
state.loadingHistory = false;
}
}
// Appui long (ou clic droit) : actions sur le message.
function attachMessageHandlers() {
$('#thread')
.querySelectorAll('.msg')
.forEach((el) => {
let timer = null;
const start = () => {
timer = setTimeout(() => {
vibrate(18);
openMessageSheet(Number(el.dataset.id));
}, 480);
};
const cancel = () => timer && clearTimeout(timer);
el.addEventListener('pointerdown', start);
el.addEventListener('pointerup', cancel);
el.addEventListener('pointerleave', cancel);
el.addEventListener('pointercancel', cancel);
el.addEventListener('contextmenu', (e) => {
e.preventDefault();
openMessageSheet(Number(el.dataset.id));
});
});
}
/* --- Envoi ---------------------------------------------------------- */
const input = $('#in-message');
function autoGrow() {
input.style.height = 'auto';
input.style.height = `${Math.min(input.scrollHeight, 130)}px`;
$('#btn-send').disabled = !input.value.trim();
}
input.addEventListener('input', () => {
autoGrow();
throttledTyping();
});
input.addEventListener('keydown', (e) => {
// Entrée envoie sur ordinateur ; sur mobile le clavier insère un saut de ligne.
if (e.key === 'Enter' && !e.shiftKey && window.matchMedia('(min-width: 720px)').matches) {
e.preventDefault();
sendMessage();
}
});
let typingAt = 0;
function throttledTyping() {
const now = Date.now();
if (now - typingAt > 2500 && state.currentGroup) {
typingAt = now;
wsSend({ type: 'typing', group_id: state.currentGroup.id });
}
}
async function sendMessage() {
const body = input.value.trim();
if (!body || !state.currentGroup) return;
const groupId = state.currentGroup.id;
input.value = '';
autoGrow();
const replyTo = state.replyTo ? state.replyTo.id : null;
state.replyTo = null;
updateReplyBar();
try {
const res = await api(`/api/groups/${groupId}/messages`, {
method: 'POST',
body: { body, kind: 'text', reply_to: replyTo },
});
// Le message revient par le WebSocket : rien à insérer ici.
// Terme surveillé : le message est bien parti, on le signale seulement.
if (res.message && res.message.warning) toast(res.message.warning);
} catch (err) {
toast(err.message, 'error');
input.value = body;
autoGrow();
}
}
$('#btn-send').addEventListener('click', sendMessage);
$$('[data-close-chat]').forEach((b) => b.addEventListener('click', () => leaveChat()));
$$('[data-close-overlay]').forEach((b) =>
b.addEventListener('click', () => {
closeOverlay(b.dataset.closeOverlay);
history.back();
}),
);
function updateReplyBar() {
const bar = $('#reply-bar');
if (!state.replyTo) {
bar.classList.remove('show');
return;
}
bar.classList.add('show');
$('#reply-author').textContent = state.replyTo.author_label;
$('#reply-text').textContent =
state.replyTo.kind === 'image' ? '📷 Photo' : state.replyTo.body.slice(0, 80);
}
$('#btn-cancel-reply').addEventListener('click', () => {
state.replyTo = null;
updateReplyBar();
});
$('#thread').addEventListener('scroll', () => {
if ($('#thread').scrollTop < 60 && state.hasMore && !state.loadingHistory) loadOlder();
});
/* ------------------------------------------------------------------ */
/* Feuilles contextuelles */
/* ------------------------------------------------------------------ */
function openSheet(html) {
$('#sheet').innerHTML = `
${html}`;
$('#sheet-backdrop').classList.add('open');
}
function closeSheet() {
$('#sheet-backdrop').classList.remove('open');
}
$('#sheet-backdrop').addEventListener('click', (e) => {
if (e.target === $('#sheet-backdrop')) closeSheet();
});
function openMessageSheet(id) {
const msg = state.messages.find((m) => m.id === id);
if (!msg) return;
const mine = msg.author_phone === state.me.phone;
const isAdmin = state.currentGroup && state.currentGroup.role === 'admin';
const mineAndSent = mine && msg.kind !== 'system';
openSheet(`
Message
${
mineAndSent
? `
Voir qui a lu ce message `
: ''
}
Répondre
${
msg.kind !== 'image'
? ' Copier le texte '
: ''
}
${
isAdmin
? `
${msg.pinned_at ? 'Retirer des épingles' : 'Épingler dans le groupe'} `
: ''
}
${
mine || isAdmin
? ' Supprimer '
: ''
}
`);
$('#sheet')
.querySelectorAll('[data-act]')
.forEach((btn) =>
btn.addEventListener('click', async () => {
const act = btn.dataset.act;
closeSheet();
if (act === 'readers') {
showReaders(msg);
} else if (act === 'reply') {
state.replyTo = msg;
updateReplyBar();
input.focus();
} else if (act === 'copy') {
try {
await navigator.clipboard.writeText(msg.body);
toast('Texte copié.', 'ok');
} catch (e) {
toast('Copie impossible.', 'error');
}
} else if (act === 'pin') {
try {
const res = await api(`/api/messages/${msg.id}/pin`, {
method: 'POST',
body: { pinned: !msg.pinned_at },
});
msg.pinned_at = res.message.pinned_at;
toast(msg.pinned_at ? 'Message épinglé.' : 'Épingle retirée.', 'ok');
loadPins(msg.group_id);
renderThread();
} catch (err) {
toast(err.message, 'error');
}
} else if (act === 'delete') {
try {
await api(`/api/messages/${msg.id}`, { method: 'DELETE' });
state.messages = state.messages.filter((m) => m.id !== msg.id);
renderThread();
} catch (err) {
toast(err.message, 'error');
}
}
}),
);
}
/* --- Fiche du groupe ------------------------------------------------ */
$('#btn-group-info').addEventListener('click', async () => {
if (!state.currentGroup) return;
const gid = state.currentGroup.id;
try {
const info = await api(`/api/groups/${gid}`);
const mode = state.currentGroup.notif_mode || 'all';
const isAdmin = info.role === 'admin';
openSheet(`
${escapeHtml(info.name)}
${info.description ? `${escapeHtml(info.description)}
` : ''}
${info.parent ? `Sous-groupe de ${escapeHtml(info.parent.name)}
` : ''}
Notifications
Tous
Mentions
Muet
${info.members.length} membre${info.members.length > 1 ? 's' : ''}
${info.members
.map(
(m) => `
${escapeHtml(initials(m.display_name))}
${escapeHtml(m.display_name)}
${m.role === 'admin' ? 'Admin ' : ''}
${escapeHtml(m.phone_display)}
${
isAdmin && m.phone !== state.me.phone
? `
Retirer `
: ''
}
`,
)
.join('')}
${
isAdmin
? ``
: ''
}
`);
$('#seg-notif')
.querySelectorAll('button')
.forEach((b) =>
b.addEventListener('click', async () => {
try {
await api(`/api/groups/${gid}/prefs`, {
method: 'PATCH',
body: { notif_mode: b.dataset.mode },
});
state.currentGroup.notif_mode = b.dataset.mode;
$('#seg-notif')
.querySelectorAll('button')
.forEach((x) => x.classList.toggle('active', x === b));
renderGroups();
} catch (err) {
toast(err.message, 'error');
}
}),
);
$('#sheet')
.querySelectorAll('[data-remove]')
.forEach((b) =>
b.addEventListener('click', async () => {
if (!confirm('Retirer cette personne du groupe ?')) return;
try {
await api(`/api/groups/${gid}/members/${b.dataset.remove}`, { method: 'DELETE' });
closeSheet();
toast('Membre retiré.', 'ok');
} catch (err) {
toast(err.message, 'error');
}
}),
);
const addBtn = $('#btn-add-member');
if (addBtn) {
const phoneInput = $('#add-member-phone');
phoneInput.addEventListener('input', (e) => {
const digits = e.target.value.replace(/\D/g, '').slice(0, 10);
e.target.value = digits.replace(/(\d{2})(?=\d)/g, '$1 ').trim();
});
addBtn.addEventListener('click', async () => {
try {
await api(`/api/groups/${gid}/members`, {
method: 'POST',
body: { phone: phoneInput.value.replace(/\D/g, ''), role: 'member' },
});
closeSheet();
toast('Membre ajouté.', 'ok');
} catch (err) {
toast(err.message, 'error');
}
});
}
} catch (err) {
toast(err.message, 'error');
}
});
/* ------------------------------------------------------------------ */
/* Images en pair-à-pair (WebRTC) */
/* ------------------------------------------------------------------ */
/* Le serveur ne relaie que la signalisation : les octets de l'image
passent directement d'un appareil à l'autre. Chaque photo envoyée est
conservée localement par l'expéditeur, qui la resservira aux pairs
tant qu'il est en ligne. */
const RTC_CONFIG = {
iceServers: [{ urls: ['stun:stun.l.google.com:19302', 'stun:stun1.l.google.com:19302'] }],
};
const CHUNK = 16 * 1024;
const peers = new Map(); // transfer_id + pair -> RTCPeerConnection
const incoming = new Map(); // transfer_id -> { chunks, received, total }
/* --- Cache local (IndexedDB) --------------------------------------- */
let idbPromise = null;
function idb() {
if (idbPromise) return idbPromise;
idbPromise = new Promise((resolve, reject) => {
const req = indexedDB.open('motoc-images', 1);
req.onupgradeneeded = () => {
const db = req.result;
if (!db.objectStoreNames.contains('img')) db.createObjectStore('img', { keyPath: 'id' });
};
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
return idbPromise;
}
async function idbPut(id, blob) {
const db = await idb();
return new Promise((resolve, reject) => {
const tx = db.transaction('img', 'readwrite');
tx.objectStore('img').put({ id, blob, ts: Date.now() });
tx.oncomplete = resolve;
tx.onerror = () => reject(tx.error);
});
}
async function idbGet(id) {
const db = await idb();
return new Promise((resolve) => {
const tx = db.transaction('img', 'readonly');
const req = tx.objectStore('img').get(id);
req.onsuccess = () => resolve(req.result ? req.result.blob : null);
req.onerror = () => resolve(null);
});
}
/* --- Envoi ---------------------------------------------------------- */
// Réduction avant envoi : 1600 px max, JPEG qualité 0.82.
async function downscale(file) {
const bitmap = await createImageBitmap(file);
const max = 1600;
let { width, height } = bitmap;
if (width > max || height > max) {
const ratio = Math.min(max / width, max / height);
width = Math.round(width * ratio);
height = Math.round(height * ratio);
}
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
canvas.getContext('2d').drawImage(bitmap, 0, 0, width, height);
bitmap.close();
const blob = await new Promise((r) => canvas.toBlob(r, 'image/jpeg', 0.82));
return { blob, width, height };
}
$('#btn-image').addEventListener('click', () => $('#file-image').click());
$('#file-image').addEventListener('change', async (e) => {
const file = e.target.files[0];
e.target.value = '';
if (!file || !state.currentGroup) return;
if (!file.type.startsWith('image/')) return toast('Ce fichier n’est pas une image.', 'error');
try {
toast('Préparation de la photo…');
const { blob, width, height } = await downscale(file);
const transferId = `${state.me.phone}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
await idbPut(transferId, blob);
await api(`/api/groups/${state.currentGroup.id}/messages`, {
method: 'POST',
body: {
body: '',
kind: 'image',
meta: {
transfer_id: transferId,
name: file.name,
size: blob.size,
width,
height,
type: 'image/jpeg',
},
},
});
} catch (err) {
toast(err.message || 'Envoi de la photo impossible.', 'error');
}
});
/* --- Affichage / récupération --------------------------------------- */
async function hydrateImages() {
const nodes = $('#thread').querySelectorAll('.p2p-image[data-transfer]');
for (const node of nodes) {
const id = node.dataset.transfer;
if (!id) continue;
const blob = await idbGet(id);
if (blob) {
showImage(node, blob);
} else if (node.dataset.owner && node.dataset.owner !== state.me.phone) {
requestImage(node);
} else {
setImageState(node, 'Photo indisponible sur cet appareil.', true);
}
}
}
function showImage(node, blob) {
const url = URL.createObjectURL(blob);
node.innerHTML = ` `;
node.querySelector('img').addEventListener('click', () => window.open(url, '_blank'));
}
function setImageState(node, text, error, progress) {
node.innerHTML = `
${escapeHtml(text)}
${progress !== undefined ? `
` : ''}`;
}
function requestImage(node) {
const transferId = node.dataset.transfer;
const owner = node.dataset.owner;
setImageState(node, 'Réception en cours…', false, 0);
const sent = wsSend({
type: 'image-request',
to: owner,
transfer_id: transferId,
group_id: Number(node.dataset.group),
});
if (!sent) {
setImageState(node, 'Hors ligne : photo indisponible.', true);
return;
}
// Sans réponse au bout de 20 s, l'expéditeur est probablement absent.
setTimeout(() => {
if (node.querySelector('.p2p-state') && !incoming.has(transferId)) {
setImageState(node, "Photo indisponible (expéditeur hors ligne).", true);
}
}, 20000);
}
// Côté expéditeur : un pair réclame une photo qu'on possède.
async function handleImageRequest(data) {
const blob = await idbGet(data.transfer_id);
if (!blob) return;
const key = `${data.transfer_id}:${data.from}`;
const pc = new RTCPeerConnection(RTC_CONFIG);
peers.set(key, pc);
pc.onicecandidate = (e) => {
if (e.candidate) {
wsSend({
type: 'rtc-signal',
to: data.from,
transfer_id: data.transfer_id,
signal: { candidate: e.candidate },
});
}
};
pc.onconnectionstatechange = () => {
if (['failed', 'closed', 'disconnected'].includes(pc.connectionState)) {
pc.close();
peers.delete(key);
}
};
const channel = pc.createDataChannel('img');
channel.binaryType = 'arraybuffer';
channel.onopen = async () => {
const buffer = await blob.arrayBuffer();
channel.send(JSON.stringify({ size: buffer.byteLength, type: blob.type }));
let offset = 0;
const pump = () => {
while (offset < buffer.byteLength) {
// Backpressure : on laisse le buffer se vider avant de continuer.
if (channel.bufferedAmount > 4 * CHUNK) {
channel.onbufferedamountlow = pump;
channel.bufferedAmountLowThreshold = 2 * CHUNK;
return;
}
channel.send(buffer.slice(offset, offset + CHUNK));
offset += CHUNK;
}
channel.onbufferedamountlow = null;
};
pump();
};
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
wsSend({
type: 'rtc-signal',
to: data.from,
transfer_id: data.transfer_id,
signal: { sdp: pc.localDescription },
});
}
// Réception de la signalisation (des deux côtés).
async function handleRtcSignal(data) {
const key = `${data.transfer_id}:${data.from}`;
let pc = peers.get(key);
const signal = data.signal || {};
if (signal.sdp && signal.sdp.type === 'offer') {
pc = new RTCPeerConnection(RTC_CONFIG);
peers.set(key, pc);
pc.onicecandidate = (e) => {
if (e.candidate) {
wsSend({
type: 'rtc-signal',
to: data.from,
transfer_id: data.transfer_id,
signal: { candidate: e.candidate },
});
}
};
pc.ondatachannel = (e) => setupReceiveChannel(e.channel, data.transfer_id, key);
await pc.setRemoteDescription(signal.sdp);
const answer = await pc.createAnswer();
await pc.setLocalDescription(answer);
wsSend({
type: 'rtc-signal',
to: data.from,
transfer_id: data.transfer_id,
signal: { sdp: pc.localDescription },
});
} else if (signal.sdp && signal.sdp.type === 'answer' && pc) {
await pc.setRemoteDescription(signal.sdp);
} else if (signal.candidate && pc) {
try {
await pc.addIceCandidate(signal.candidate);
} catch (e) {
/* candidat tardif : sans conséquence */
}
}
}
function setupReceiveChannel(channel, transferId, key) {
channel.binaryType = 'arraybuffer';
const node = () => $('#thread').querySelector(`.p2p-image[data-transfer="${CSS.escape(transferId)}"]`);
channel.onmessage = async (event) => {
const rec = incoming.get(transferId);
// Premier message : l'en-tête JSON décrivant le transfert.
if (!rec) {
try {
const header = JSON.parse(event.data);
incoming.set(transferId, { chunks: [], received: 0, total: header.size, type: header.type });
} catch (e) {
/* en-tête illisible */
}
return;
}
rec.chunks.push(event.data);
rec.received += event.data.byteLength;
const el = node();
if (el) {
const pct = Math.round((rec.received / rec.total) * 100);
setImageState(el, `Réception… ${pct} %`, false, pct);
}
if (rec.received >= rec.total) {
const blob = new Blob(rec.chunks, { type: rec.type || 'image/jpeg' });
incoming.delete(transferId);
await idbPut(transferId, blob);
const target = node();
if (target) showImage(target, blob);
channel.close();
const pc = peers.get(key);
if (pc) {
pc.close();
peers.delete(key);
}
}
};
}
/* ------------------------------------------------------------------ */
/* Réglages */
/* ------------------------------------------------------------------ */
$('#btn-settings').addEventListener('click', () => {
$('#set-first').value = state.me.first_name || '';
$('#set-last').value = state.me.last_name || '';
$('#set-nick').value = state.me.nickname || '';
$('#set-phone').value = state.me.phone_display || '';
refreshPushSwitch();
openOverlay('screen-settings');
});
$('#btn-save-profile').addEventListener('click', async () => {
try {
state.me = await api('/api/me', {
method: 'PATCH',
body: {
first_name: $('#set-first').value.trim(),
last_name: $('#set-last').value.trim(),
nickname: $('#set-nick').value.trim(),
},
});
toast('Profil enregistré.', 'ok');
} catch (err) {
toast(err.message, 'error');
}
});
$('#btn-change-pin').addEventListener('click', async () => {
const oldPin = $('#set-pin-old').value;
const newPin = $('#set-pin-new').value;
if (oldPin.length !== 6 || newPin.length !== 6) {
return toast('Les deux codes doivent contenir 6 chiffres.', 'error');
}
try {
const res = await api('/api/me/pin', {
method: 'POST',
body: { current_pin: oldPin, new_pin: newPin },
});
state.token = res.token;
localStorage.setItem('motoc_token', res.token);
$('#set-pin-old').value = $('#set-pin-new').value = '';
toast('Code PIN modifié.', 'ok');
} catch (err) {
toast(err.message, 'error');
}
});
$('#btn-logout').addEventListener('click', () => {
if (confirm('Se déconnecter de cet appareil ?')) logout();
});
/* ------------------------------------------------------------------ */
/* Notifications push */
/* ------------------------------------------------------------------ */
function b64ToUint8(base64) {
const padded = (base64 + '='.repeat((4 - (base64.length % 4)) % 4))
.replace(/-/g, '+')
.replace(/_/g, '/');
const raw = atob(padded);
return Uint8Array.from(raw, (c) => c.charCodeAt(0));
}
const pushSupported = () =>
'serviceWorker' in navigator && 'PushManager' in window && 'Notification' in window;
async function currentSubscription() {
if (!pushSupported()) return null;
const reg = await navigator.serviceWorker.ready;
return reg.pushManager.getSubscription();
}
async function refreshPushSwitch() {
const sw = $('#sw-push');
const desc = $('#push-desc');
if (!pushSupported()) {
sw.classList.remove('on');
desc.textContent = "Non pris en charge par ce navigateur.";
return;
}
if (Notification.permission === 'denied') {
sw.classList.remove('on');
desc.textContent = 'Bloquées dans les réglages du navigateur.';
return;
}
const sub = await currentSubscription();
sw.classList.toggle('on', !!sub);
// Sur iOS, le push n'existe que si l'app est installée sur l'écran d'accueil.
const standalone =
window.matchMedia('(display-mode: standalone)').matches || navigator.standalone;
const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent);
if (isIOS && !standalone) {
desc.textContent = 'Sur iPhone : « Partager » puis « Sur l’écran d’accueil ».';
} else {
desc.textContent = sub
? 'Activées sur cet appareil.'
: 'Recevoir les messages même application fermée.';
}
}
$('#sw-push').addEventListener('click', async () => {
if (!pushSupported()) return toast('Notifications non prises en charge.', 'error');
const sw = $('#sw-push');
try {
const existing = await currentSubscription();
if (existing) {
await api('/api/push/unsubscribe', {
method: 'POST',
body: { endpoint: existing.endpoint },
});
await existing.unsubscribe();
toast('Notifications désactivées.');
} else {
const permission = await Notification.requestPermission();
if (permission !== 'granted') {
toast('Autorisation refusée.', 'error');
return refreshPushSwitch();
}
const { public_key: key } = await api('/api/push/key');
const reg = await navigator.serviceWorker.ready;
const sub = await reg.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: b64ToUint8(key),
});
await api('/api/push/subscribe', { method: 'POST', body: { subscription: sub.toJSON() } });
toast('Notifications activées.', 'ok');
}
} catch (err) {
toast(err.message || 'Activation impossible.', 'error');
}
refreshPushSwitch();
});
$('#btn-test-push').addEventListener('click', async () => {
try {
await api('/api/push/test', { method: 'POST' });
toast('Notification test envoyée.', 'ok');
} catch (err) {
toast(err.message, 'error');
}
});
/* ------------------------------------------------------------------ */
/* Administration (superadmin) */
/* ------------------------------------------------------------------ */
$('#btn-admin').addEventListener('click', () => {
openOverlay('screen-admin');
// Libère le cadre « téléphone » : le panneau superadmin est le seul
// écran conçu pour être lu en grand.
$('#app').classList.add('desk');
// On rouvre toujours sur le tableau de bord, pas sur le dernier onglet vu.
$$('.tabs button').forEach((b) => b.classList.toggle('active', b.dataset.tab === 'tab-dash'));
$$('.tab-panel').forEach((p) => p.classList.toggle('active', p.id === 'tab-dash'));
loadDashboard();
loadHelpRequests();
loadAdminGroups();
loadAdminUsers();
loadStats();
});
$$('.tabs button').forEach((btn) =>
btn.addEventListener('click', () => {
$$('.tabs button').forEach((b) => b.classList.toggle('active', b === btn));
$$('.tab-panel').forEach((p) => p.classList.toggle('active', p.id === btn.dataset.tab));
// Les données du bord vieillissent vite : on rafraîchit à chaque retour.
const tab = btn.dataset.tab;
// La supervision matérielle ne sonde que si son onglet est à l'écran.
if (tab !== 'tab-system') stopSystem();
if (tab === 'tab-dash') {
loadDashboard();
loadHelpRequests();
} else if (tab === 'tab-stats') {
loadStats();
} else if (tab === 'tab-moderation') {
loadModeration();
} else if (tab === 'tab-system') {
startSystem();
} else if (tab === 'tab-playground') {
loadPlayground();
}
}),
);
async function loadAdminGroups() {
try {
const res = await api('/api/admin/groups');
const groups = res.groups;
// Alimente le sélecteur de parent (uniquement les groupes racines).
const select = $('#ng-parent');
select.innerHTML =
'— Aucun (groupe principal) — ' +
groups
.filter((g) => !g.parent_id)
.map((g) => `${escapeHtml(g.name)} `)
.join('');
// Sélecteur de l'import : tous les groupes, sous-groupes compris.
$('#in-import-group').innerHTML =
'Aucun groupe ' +
groups
.map(
(g) =>
`${g.parent_id ? '↳ ' : ''}${escapeHtml(g.name)} `,
)
.join('');
const byId = new Map(groups.map((g) => [g.id, g]));
$('#admin-groups').innerHTML = groups.length
? groups
.map((g) => {
const parent = g.parent_id ? byId.get(g.parent_id) : null;
return `
${escapeHtml(initials(g.name))}
${escapeHtml(g.name)}
${parent ? `↳ ${escapeHtml(parent.name)} · ` : ''}${g.member_count} membre(s) · ${g.message_count} message(s)
Suppr.
`;
})
.join('')
: 'Aucun groupe pour le moment.
';
$('#admin-groups')
.querySelectorAll('[data-del-group]')
.forEach((b) =>
b.addEventListener('click', async () => {
const g = byId.get(Number(b.dataset.delGroup));
if (
!confirm(
`Supprimer « ${g.name} » ?\n\nLes messages et les sous-groupes seront définitivement effacés.`,
)
)
return;
try {
await api(`/api/admin/groups/${g.id}`, { method: 'DELETE' });
toast('Groupe supprimé.', 'ok');
loadAdminGroups();
loadGroups();
} catch (err) {
toast(err.message, 'error');
}
}),
);
} catch (err) {
toast(err.message, 'error');
}
}
$('#btn-create-group').addEventListener('click', async () => {
const name = $('#ng-name').value.trim();
if (!name) return toast('Donnez un nom au groupe.', 'error');
try {
await api('/api/admin/groups', {
method: 'POST',
body: {
name,
parent_id: $('#ng-parent').value ? Number($('#ng-parent').value) : null,
description: $('#ng-desc').value.trim(),
},
});
$('#ng-name').value = $('#ng-desc').value = '';
toast('Groupe créé.', 'ok');
loadAdminGroups();
loadGroups();
} catch (err) {
toast(err.message, 'error');
}
});
$('#btn-add-users').addEventListener('click', async () => {
const phones = $('#nu-phones').value.trim();
if (!phones) return toast('Saisissez au moins un numéro.', 'error');
try {
const res = await api('/api/admin/users/bulk', { method: 'POST', body: { phones } });
$('#nu-phones').value = '';
const bits = [`${res.added.length} ajouté(s)`];
if (res.skipped.length) bits.push(`${res.skipped.length} déjà connu(s)`);
if (res.invalid.length) bits.push(`${res.invalid.length} invalide(s)`);
toast(bits.join(' · '), res.added.length ? 'ok' : 'error');
loadAdminUsers();
loadStats();
} catch (err) {
toast(err.message, 'error');
}
});
/* --- Import d'un tableur de bénévoles ------------------------------- */
// Deux temps : un aperçu qui montre les colonnes reconnues, puis l'import.
// Le fichier est renvoyé au serveur à la confirmation — rien n'est gardé
// entre les deux appels, ni ici ni côté serveur.
let importFile = null;
function importFormData(dryRun) {
const form = new FormData();
form.append('file', importFile);
form.append('dry_run', dryRun ? 'true' : 'false');
const group = $('#in-import-group').value;
if (group) form.append('group_id', group);
// Colonnes corrigées à la main depuis l'aperçu, s'il y en a.
for (const [field, id] of [
['first_col', '#imp-first'],
['last_col', '#imp-last'],
['phone_col', '#imp-phone'],
]) {
const select = $(id);
if (select && select.value !== '' && select.dataset.dirty === '1') {
form.append(field, select.value);
}
}
return form;
}
function columnOptions(count, selected, headers) {
let html = '';
for (let i = 0; i < count; i += 1) {
const label = headers[i] ? `${headers[i]}` : `Colonne ${i + 1}`;
html += `${escapeHtml(label)} `;
}
return html;
}
function renderImportPreview(res) {
const box = $('#import-result');
const map = res.mapping;
const headers = res.preview.length ? [] : [];
// Nombre de colonnes proposées : au moins celles repérées par le serveur.
const width = Math.max(
(map.phone_col ?? 0) + 1,
(map.first_col ?? 0) + 1,
(map.last_col ?? 0) + 1,
(map.full_col ?? 0) + 1,
3,
);
const labels = map.labels;
const rows = res.preview
.map(
(p) => `
${escapeHtml(p.first_name || '—')}
${escapeHtml(p.last_name || '—')}
${escapeHtml(p.phone_display)}
${p.known ? 'déjà connu' : 'nouveau'}
`,
)
.join('');
box.hidden = false;
box.innerHTML = `
${res.detected} contact(s) lus dans
${escapeHtml(res.filename || 'le fichier')} —
${res.new} nouveau(x), ${res.existing} déjà connu(s)${
res.rejected_count ? `, ${res.rejected_count} ligne(s) ignorée(s)` : ''
}.
Colonnes reconnues :
Prénom → ${escapeHtml(labels.first || labels.full || '—')}
Nom → ${escapeHtml(labels.last || labels.full || '—')}
Téléphone → ${escapeHtml(labels.phone || '—')}
${
map.assumed_order
? `
Aucun en-tête dans ce fichier : l'ordre « prénom puis nom »
est une hypothèse. Vérifiez l'aperçu ci-dessous et corrigez si besoin.
`
: ''
}
Corriger les colonnes
Prénom
${columnOptions(width, map.first_col ?? map.full_col, headers)}
Nom
${columnOptions(width, map.last_col, headers)}
Téléphone
${columnOptions(width, map.phone_col, headers)}
Relire avec ces colonnes
Prénom Nom Téléphone
${rows}
${
res.rejected.length
? `${res.rejected_count} ligne(s) ignorée(s)
${res.rejected
.map((r) => `Ligne ${r.row} — ${escapeHtml(r.reason)} : ${escapeHtml(r.value)} `)
.join('')} `
: ''
}
Importer ${res.new} nouveau(x) contact(s)
Annuler
`;
$$('#imp-first, #imp-last, #imp-phone').forEach((select) =>
select.addEventListener('change', () => {
select.dataset.dirty = '1';
}),
);
$('#imp-recheck').addEventListener('click', () => runImport(true));
$('#imp-cancel').addEventListener('click', resetImport);
$('#imp-confirm').addEventListener('click', () => runImport(false));
}
function resetImport() {
importFile = null;
$('#in-import-file').value = '';
$('#import-result').hidden = true;
$('#import-result').innerHTML = '';
}
async function runImport(dryRun) {
if (!importFile) return;
const button = dryRun ? $('#btn-pick-file') : $('#imp-confirm');
if (button) button.disabled = true;
try {
const res = await api('/api/admin/users/import', {
method: 'POST',
body: importFormData(dryRun),
});
if (dryRun) {
renderImportPreview(res);
} else {
const bits = [`${res.added} compte(s) créé(s)`];
if (res.updated) bits.push(`${res.updated} mis à jour`);
if (res.joined) bits.push(`${res.joined} ajouté(s) au groupe`);
toast(bits.join(' · '), 'ok');
resetImport();
loadAdminUsers();
loadAdminGroups();
loadStats();
}
} catch (err) {
toast(err.message, 'error');
} finally {
if (button) button.disabled = false;
}
}
$('#btn-pick-file').addEventListener('click', () => $('#in-import-file').click());
$('#in-import-file').addEventListener('change', (e) => {
importFile = e.target.files[0] || null;
if (importFile) runImport(true);
});
/* ------------------------------------------------------------------ */
/* Modération */
/* ------------------------------------------------------------------ */
let moderationStatus = 'pending';
async function loadModeration() {
try {
const res = await api(
`/api/admin/moderation?status=${encodeURIComponent(moderationStatus)}&limit=100`,
);
const badge = $('#moderation-badge');
badge.hidden = !res.pending;
badge.textContent = res.pending;
$('#moderation-list').innerHTML = res.alerts.length
? res.alerts
.map(
(a) => `
${escapeHtml(a.category_label)}
${fmtDateTime(a.created_at)}
« ${escapeHtml(a.excerpt)} »
${escapeHtml(a.author || 'inconnu')} · terme
${escapeHtml(a.term)}
${a.matched && a.matched !== a.term ? `(écrit « ${escapeHtml(a.matched)} »)` : ''}
${
a.status === 'pending'
? `
Marquer traité `
: ''
}
`,
)
.join('')
: 'Aucun signalement.
';
$('#moderation-list')
.querySelectorAll('[data-review]')
.forEach((b) =>
b.addEventListener('click', async () => {
try {
await api(`/api/admin/moderation/${b.dataset.review}/review`, { method: 'POST' });
loadModeration();
} catch (err) {
toast(err.message, 'error');
}
}),
);
// Éditeur de la liste surveillée : un textarea par catégorie.
$('#terms-editor').innerHTML = Object.entries(res.categories)
.map(
([key, label]) => `
${escapeHtml(label)}
`,
)
.join('');
} catch (err) {
toast(err.message, 'error');
}
}
$('#seg-moderation')
.querySelectorAll('button')
.forEach((b) =>
b.addEventListener('click', () => {
moderationStatus = b.dataset.status;
$('#seg-moderation')
.querySelectorAll('button')
.forEach((x) => x.classList.toggle('active', x === b));
loadModeration();
}),
);
$('#btn-save-terms').addEventListener('click', async () => {
const terms = {};
$$('[data-terms]').forEach((area) => {
terms[area.dataset.terms] = area.value
.split('\n')
.map((line) => line.trim())
.filter(Boolean);
});
try {
await api('/api/admin/moderation/terms', { method: 'PUT', body: { terms } });
toast('Liste enregistrée.', 'ok');
loadModeration();
} catch (err) {
toast(err.message, 'error');
}
});
/* ------------------------------------------------------------------ */
/* Supervision machine et réseau */
/* ------------------------------------------------------------------ */
// Sondage toutes les 3 s tant que l'onglet est ouvert ET la page visible :
// inutile de mesurer une machine que personne ne regarde.
const SPARK_POINTS = 40;
// Surtout pas `history` : le nom masquerait `window.history` dans toute la
// portée du module, et la navigation entre écrans cesserait de fonctionner.
const samples = { cpu: [], rx: [], tx: [] };
let systemTimer = null;
let systemPaused = false;
function pushSample(series, value) {
series.push(Number.isFinite(value) ? value : 0);
while (series.length > SPARK_POINTS) series.shift();
}
function drawSpark(id, series, max) {
const svg = $(id);
if (!svg) return;
const line = svg.querySelector('polyline');
const box = svg.viewBox.baseVal;
const ceiling = Math.max(max || 0, ...series, 1);
const step = series.length > 1 ? box.width / (series.length - 1) : box.width;
line.setAttribute(
'points',
series
.map((v, i) => `${(i * step).toFixed(1)},${(box.height - (v / ceiling) * box.height).toFixed(1)}`)
.join(' '),
);
}
function gauge(label, value, percent, hint) {
const level = percent >= 90 ? 'danger' : percent >= 70 ? 'warn' : '';
return `
${escapeHtml(label)}
${escapeHtml(value)}
${
percent === null || percent === undefined
? ''
: `
`
}
${hint ? `
${escapeHtml(hint)}
` : ''}
`;
}
function renderSystem(s) {
const na = 'indisponible';
const cpu = s.cpu || {};
const mem = s.memory || {};
const disk = s.disk || {};
const net = s.network || {};
const proc = s.process || {};
pushSample(samples.cpu, cpu.percent || 0);
pushSample(samples.rx, net.rx_rate || 0);
pushSample(samples.tx, net.tx_rate || 0);
// `has` et non la simple véracité : une valeur nulle est une mesure
// légitime (un disque vide, un process qui n'a rien écrit), pas une
// absence de mesure. La confondre affichait « indisponible » sur un
// disque parfaitement lisible mais encore vide.
const has = (v) => v !== null && v !== undefined;
$('#sys-grid').innerHTML = [
gauge(
'Processeur',
has(cpu.percent) ? `${cpu.percent} %` : na,
cpu.percent,
cpu.scope === 'machine'
? `machine entière · ${cpu.cores || '?'} cœur(s)`
: `${cpu.allocated_cores || cpu.cores || '?'} cœur(s) alloué(s)`,
),
gauge(
'Mémoire',
has(mem.used) ? `${fmtBytes(mem.used)} / ${fmtBytes(mem.total)}` : na,
mem.percent,
mem.limited_by_cgroup ? 'limite du conteneur' : 'mémoire de la machine',
),
gauge(
'Disque persistant',
has(disk.used)
? disk.dedicated
? `${fmtBytes(disk.used)} / ${fmtBytes(disk.total)}`
: fmtBytes(disk.used)
: na,
disk.percent,
disk.dedicated
? disk.path
: `${disk.path} · volume non dédié, place restante inconnue`,
),
gauge(
'Base de données',
s.database ? fmtBytes(s.database.size) : na,
s.database ? (s.database.size / s.database.hard_limit) * 100 : null,
s.database ? `purge agressive au-delà de ${fmtBytes(s.database.soft_limit)}` : '',
),
// La charge est celle de la machine hôte, partagée avec les autres
// Spaces : elle ne dit rien de la santé de celui-ci, et l'afficher en
// rouge ferait sonner une alarme sur laquelle personne ne peut agir.
gauge(
'Charge de l’hôte',
cpu.load ? cpu.load.map((v) => v.toFixed(2)).join(' · ') : na,
null,
'machine partagée · 1 / 5 / 15 min',
),
gauge(
'Temps réel',
s.realtime ? `${s.realtime.connections} connexion(s)` : na,
null,
s.realtime ? `${s.realtime.online_users} utilisateur(s) en ligne` : '',
),
gauge(
'Process',
has(proc.rss) ? fmtBytes(proc.rss) : na,
null,
`${proc.threads || '?'} fils · ${proc.open_files || '?'} fichiers ouverts`,
),
gauge('En service depuis', fmtDuration(s.app_uptime), null, 'redémarrage du serveur'),
].join('');
$('#net-rx').textContent = net.rx_rate === null || net.rx_rate === undefined ? '—' : `${fmtBytes(net.rx_rate)}/s`;
$('#net-tx').textContent = net.tx_rate === null || net.tx_rate === undefined ? '—' : `${fmtBytes(net.tx_rate)}/s`;
drawSpark('#spark-rx', samples.rx);
drawSpark('#spark-tx', samples.tx);
drawSpark('#spark-cpu', samples.cpu, 100);
const span = samples.cpu.length * 3;
$('#cpu-detail').textContent = s.available
? `Échelle 0–100 %. ${samples.cpu.length} mesure(s) sur ${
span < 60 ? `${span} secondes` : `${Math.round(span / 60)} minutes`
}.`
: "Ce système n'expose pas /proc : les mesures matérielles ne sont pas disponibles ici. Elles le seront dans le conteneur.";
$('#sys-dot').className = `sys-dot ${s.available ? 'live' : 'off'}`;
$('#sys-status').textContent = s.available
? `Mesure en direct · ${new Date(s.at).toLocaleTimeString('fr-FR')}`
: 'Mesures matérielles indisponibles sur ce système';
}
async function pollSystem() {
if (systemPaused || document.hidden) return;
try {
renderSystem(await api('/api/admin/system'));
} catch (err) {
$('#sys-status').textContent = err.message;
}
}
function startSystem() {
stopSystem();
pollSystem();
systemTimer = setInterval(pollSystem, 3000);
}
function stopSystem() {
if (systemTimer) clearInterval(systemTimer);
systemTimer = null;
}
$('#btn-sys-toggle').addEventListener('click', () => {
systemPaused = !systemPaused;
$('#btn-sys-toggle').textContent = systemPaused ? 'Reprendre' : 'Suspendre';
if (!systemPaused) pollSystem();
});
/* ------------------------------------------------------------------ */
/* Bac à sable */
/* ------------------------------------------------------------------ */
const sandbox = { groups: [], users: [], current: null };
async function loadPlayground() {
try {
const res = await api('/api/admin/playground');
sandbox.groups = res.groups;
sandbox.users = res.users;
$('#playground-groups').innerHTML = res.groups.length
? res.groups
.map(
(g) => `
${escapeHtml(initials(g.name))}
${escapeHtml(g.name)}
${g.member_count} membre(s) · ${g.message_count} message(s)
Ouvrir
`,
)
.join('')
: `Bac à sable vide. Il est créé au démarrage du conteneur
quand SEED_DEMO=1.
`;
$('#playground-users').innerHTML = res.users.length
? res.users
.map(
(u) => `
${escapeHtml(initials(u.display_name))}
${escapeHtml(u.display_name)}
${u.is_group_admin ? 'Admin ' : ''}
${escapeHtml(u.phone_display)} · ${u.groups.length} groupe(s)
`,
)
.join('')
: 'Aucun compte fictif.
';
$('#playground-groups')
.querySelectorAll('[data-sandbox-group]')
.forEach((row) =>
row.addEventListener('click', () => openSandbox(Number(row.dataset.sandboxGroup))),
);
} catch (err) {
toast(err.message, 'error');
}
}
async function openSandbox(groupId) {
const group = sandbox.groups.find((g) => g.id === groupId);
if (!group) return;
sandbox.current = group;
$('#sandbox-title').textContent = group.name;
$('#sandbox-sub').textContent = `${group.member_count} membre(s) fictif(s)`;
// Le sélecteur d'auteur : soi-même, ou n'importe quel compte fictif du groupe.
const members = sandbox.users.filter((u) => u.groups.some((g) => g.group_id === groupId));
$('#sandbox-as').innerHTML =
`Moi (${escapeHtml(state.me.display_name)}) ` +
members
.map((u) => `${escapeHtml(u.display_name)} `)
.join('');
openOverlay('screen-sandbox');
await loadSandboxThread();
}
async function loadSandboxThread() {
if (!sandbox.current) return;
try {
const res = await api(`/api/admin/playground/groups/${sandbox.current.id}/messages`);
const thread = $('#sandbox-thread');
thread.innerHTML = res.messages.length
? res.messages
.map(
(m) => `
${escapeHtml(
initials(m.author_label),
)}
${escapeHtml(m.author_label)}
${enrich(escapeHtml(m.body))}
${fmtTime(m.created_at)}
`,
)
.join('')
: `Conversation vide
Écrivez un message, en changeant d'auteur pour simuler un échange.
`;
thread.scrollTop = thread.scrollHeight;
} catch (err) {
toast(err.message, 'error');
}
}
$('#btn-sandbox-send').addEventListener('click', async () => {
const input = $('#sandbox-message');
const body = input.value.trim();
if (!body || !sandbox.current) return;
try {
const res = await api('/api/admin/playground/messages', {
method: 'POST',
body: { group_id: sandbox.current.id, phone: $('#sandbox-as').value, body },
});
input.value = '';
if (res.message.warning) toast(res.message.warning);
loadSandboxThread();
loadModeration();
} catch (err) {
toast(err.message, 'error');
}
});
$('#sandbox-message').addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
$('#btn-sandbox-send').click();
}
});
$('#btn-sandbox-menu').addEventListener('click', () => {
if (!sandbox.current) return;
openSheet(`
${escapeHtml(sandbox.current.name)}
Simuler la lecture par tous
Vider la conversation
`);
$('#sb-read').addEventListener('click', async () => {
try {
const res = await api(`/api/admin/playground/groups/${sandbox.current.id}/read`, {
method: 'POST',
});
closeSheet();
toast(`${res.readers} compte(s) ont lu la conversation.`, 'ok');
} catch (err) {
toast(err.message, 'error');
}
});
$('#sb-clear').addEventListener('click', async () => {
try {
await api(`/api/admin/playground/groups/${sandbox.current.id}/clear`, { method: 'POST' });
closeSheet();
loadSandboxThread();
loadPlayground();
} catch (err) {
toast(err.message, 'error');
}
});
});
let userSearchTimer = null;
$('#in-user-search').addEventListener('input', () => {
clearTimeout(userSearchTimer);
userSearchTimer = setTimeout(loadAdminUsers, 260);
});
async function loadAdminUsers() {
try {
const term = encodeURIComponent($('#in-user-search').value.trim());
const res = await api(`/api/admin/users?search=${term}&limit=200`);
$('#admin-users').innerHTML = res.users.length
? res.users
.map(
(u) => `
${escapeHtml(initials(u.display_name))}
${escapeHtml(u.display_name)}
${u.is_superadmin ? 'Super ' : ''}
${u.flagged ? 'Signalé ' : ''}
${
u.self_registered && !u.group_count
? 'à affecter '
: ''
}
${escapeHtml(u.phone_display)} · ${u.group_count} groupe(s)${
u.status === 'invited' ? ' · en attente' : u.status === 'blocked' ? ' · bloqué' : ''
}${u.flagged && u.flag_reason ? ` · ${escapeHtml(u.flag_reason)}` : ''}
Gérer
`,
)
.join('')
: 'Aucun compte.
';
$('#admin-users')
.querySelectorAll('[data-user]')
.forEach((b) =>
b.addEventListener('click', () =>
openUserSheet(res.users.find((u) => u.phone === b.dataset.user)),
),
);
} catch (err) {
toast(err.message, 'error');
}
}
async function openUserSheet(user) {
if (!user) return;
const [allGroups, mine] = await Promise.all([
api('/api/admin/groups').then((r) => r.groups),
api(`/api/admin/users/${user.phone}/groups`).then((r) => r.groups),
]);
const mineById = new Map(mine.map((g) => [g.id, g]));
// Les sous-groupes s'affichent sous leur parent pour rester lisibles.
const ordered = [];
for (const g of allGroups.filter((x) => !x.parent_id)) {
ordered.push(g);
ordered.push(...allGroups.filter((x) => x.parent_id === g.id));
}
ordered.push(...allGroups.filter((x) => x.parent_id && !allGroups.some((p) => p.id === x.parent_id)));
openSheet(`
${escapeHtml(user.display_name)}
${escapeHtml(user.phone_display)} · ${
user.status === 'active' ? 'compte actif' : user.status === 'invited' ? 'en attente d’activation' : 'bloqué'
}
Groupes actuels (${mine.length})
${
mine.length
? mine
.map(
(g) => `
${escapeHtml(g.name)}${g.role === 'admin' ? ' · admin' : ''}
×
`,
)
.join('')
: 'Aucun groupe. '
}
Affecter à plusieurs groupes
${
ordered.length
? ordered
.map(
(g) => `
${escapeHtml(g.name)}
${mineById.has(g.id) ? 'déjà membre ' : ''}
`,
)
.join('')
: '
Aucun groupe à proposer.
'
}
Membre
Administrateur
Ajouter aux groupes sélectionnés
${user.is_superadmin ? 'Retirer le rôle de superadmin' : 'Promouvoir superadmin'}
Réinitialiser le code PIN
${user.flagged ? 'Lever le signalement' : 'Signaler ce compte'}
${user.status === 'blocked' ? 'Débloquer le compte' : 'Bloquer le compte'}
Supprimer le compte
`);
let role = 'member';
$('#us-role')
.querySelectorAll('button')
.forEach((b) =>
b.addEventListener('click', () => {
role = b.dataset.role;
$('#us-role')
.querySelectorAll('button')
.forEach((x) => x.classList.toggle('active', x === b));
}),
);
const patch = async (body, message) => {
try {
await api(`/api/admin/users/${user.phone}`, { method: 'PATCH', body });
closeSheet();
toast(message, 'ok');
loadAdminUsers();
} catch (err) {
toast(err.message, 'error');
}
};
// Sélection multiple : un numéro peut appartenir à autant de groupes qu'on veut.
$('#us-picker')
.querySelectorAll('.pick-row')
.forEach((row) => row.addEventListener('click', () => row.classList.toggle('on')));
$('#us-current')
.querySelectorAll('[data-unassign]')
.forEach((b) =>
b.addEventListener('click', async () => {
try {
await api(`/api/admin/users/${user.phone}/groups/${b.dataset.unassign}`, {
method: 'DELETE',
});
closeSheet();
toast('Retiré du groupe.', 'ok');
loadAdminUsers();
loadAdminGroups();
} catch (err) {
toast(err.message, 'error');
}
}),
);
$('#us-assign').addEventListener('click', async () => {
const ids = Array.from($('#us-picker').querySelectorAll('.pick-row.on')).map((r) =>
Number(r.dataset.gid),
);
if (!ids.length) return toast('Sélectionnez au moins un groupe.', 'error');
try {
const res = await api(`/api/admin/users/${user.phone}/groups`, {
method: 'POST',
body: { group_ids: ids, role },
});
closeSheet();
toast(`Ajouté à ${res.added.length} groupe(s).`, 'ok');
loadAdminGroups();
loadAdminUsers();
loadGroups();
} catch (err) {
toast(err.message, 'error');
}
});
$('#us-super').addEventListener('click', () =>
patch(
{ is_superadmin: !user.is_superadmin },
user.is_superadmin ? 'Rôle superadmin retiré.' : 'Superadmin promu.',
),
);
// Signaler ne coupe pas l'accès : le compte remonte simplement en tête
// d'annuaire avec son motif, en attendant qu'on tranche.
$('#us-flag').addEventListener('click', async () => {
let reason = '';
if (!user.flagged) {
reason = prompt('Motif du signalement (visible par les superadmins) :', '') || '';
if (reason === null) return;
}
try {
await api(`/api/admin/users/${user.phone}/flag`, {
method: 'POST',
body: { flagged: !user.flagged, reason },
});
closeSheet();
toast(user.flagged ? 'Signalement levé.' : 'Compte signalé.', 'ok');
loadAdminUsers();
} catch (err) {
toast(err.message, 'error');
}
});
$('#us-block').addEventListener('click', () =>
patch(
{ status: user.status === 'blocked' ? 'active' : 'blocked' },
user.status === 'blocked' ? 'Compte débloqué.' : 'Compte bloqué.',
),
);
$('#us-reset').addEventListener('click', async () => {
if (!confirm('Réinitialiser le code PIN ? La personne devra en choisir un nouveau.')) return;
try {
const res = await api(`/api/admin/users/${user.phone}/reset-pin`, { method: 'POST' });
closeSheet();
toast(res.message, 'ok');
loadAdminUsers();
} catch (err) {
toast(err.message, 'error');
}
});
$('#us-delete').addEventListener('click', async () => {
if (!confirm(`Supprimer définitivement le compte ${user.phone_display} ?`)) return;
try {
await api(`/api/admin/users/${user.phone}`, { method: 'DELETE' });
closeSheet();
toast('Compte supprimé.', 'ok');
loadAdminUsers();
loadStats();
} catch (err) {
toast(err.message, 'error');
}
});
}
async function loadStats() {
try {
const s = await api('/api/admin/stats');
$('#stat-grid').innerHTML = [
['Comptes', s.users.total],
['Actifs', s.users.active],
['En attente', s.users.invited],
['En ligne', s.users.online],
['Groupes', s.groups.total],
['Sous-groupes', s.groups.subgroups],
['Messages', s.messages.total],
['Dernières 24 h', s.messages.last_24h],
]
.map(([k, v]) => ``)
.join('');
const st = s.storage;
$('#storage-bar').style.width = `${Math.min(st.percent, 100)}%`;
$('#storage-text').innerHTML = `
${st.megabytes} Mo utilisés sur ${st.hard_limit_mb} Mo (${st.percent} %).
Rétention : les ${st.keep_per_group} derniers messages par groupe,
purgés au-delà de ${st.retention_days} jours .`;
} catch (err) {
toast(err.message, 'error');
}
}
$('#btn-purge').addEventListener('click', async () => {
if (!confirm('Lancer la purge de rétention maintenant ?')) return;
try {
const r = await api('/api/admin/purge', { method: 'POST' });
toast(
`${r.deleted_age + r.deleted_overflow} message(s) supprimé(s) · ${(r.size_after / 1e6).toFixed(1)} Mo`,
'ok',
);
loadStats();
} catch (err) {
toast(err.message, 'error');
}
});
/* ------------------------------------------------------------------ */
/* Tableau de bord — graphiques */
/* ------------------------------------------------------------------ */
/* Toutes les séries sont mono-série (une magnitude dans le temps ou un
classement) : une seule teinte, pas de légende, base à zéro. */
const tip = document.createElement('div');
tip.id = 'chart-tip';
document.body.appendChild(tip);
function showTip(event, label, value, unit) {
tip.innerHTML = `${escapeHtml(label)} · ${value} ${escapeHtml(unit)}`;
tip.style.display = 'block';
const rect = tip.getBoundingClientRect();
let x = event.clientX - rect.width / 2;
x = Math.max(8, Math.min(x, window.innerWidth - rect.width - 8));
tip.style.left = `${x}px`;
tip.style.top = `${Math.max(8, event.clientY - rect.height - 12)}px`;
}
function hideTip() {
tip.style.display = 'none';
}
document.addEventListener('scroll', hideTip, true);
// Barre à extrémité haute arrondie, ancrée sur la ligne de base.
function barPath(x, y, w, h, r) {
const rr = Math.max(0, Math.min(r, w / 2, h));
return (
`M${x},${y + h}` +
`L${x},${y + rr}` +
`Q${x},${y} ${x + rr},${y}` +
`L${x + w - rr},${y}` +
`Q${x + w},${y} ${x + w},${y + rr}` +
`L${x + w},${y + h}Z`
);
}
/**
* Histogramme vertical mono-série.
* @param {HTMLElement} host conteneur
* @param {Array<{label:string,count:number}>} data
*/
function barChart(host, data, { barW = 18, gap = 4, height = 120, labelEvery = 1, unit = 'messages' } = {}) {
if (!data.length) {
host.innerHTML = 'Aucune donnée.
';
return;
}
const padLeft = 26;
const padBottom = 18;
const padTop = 12;
const plotW = data.length * (barW + gap) - gap;
const width = padLeft + plotW + 6;
const max = Math.max(1, ...data.map((d) => d.count));
const scale = (v) => (v / max) * height;
// Trois repères suffisent : 0, milieu, maximum.
const ticks = [0, Math.round(max / 2), max].filter((v, i, a) => a.indexOf(v) === i);
const parts = [``];
for (const t of ticks) {
const y = padTop + height - scale(t);
parts.push(
` `,
`${t} `,
);
}
const maxIndex = data.findIndex((d) => d.count === max);
data.forEach((d, i) => {
const x = padLeft + i * (barW + gap);
const h = scale(d.count);
const y = padTop + height - h;
if (d.count > 0) {
parts.push(` `);
}
// Zone de survol pleine hauteur : plus facile à viser qu'une barre fine.
parts.push(
` `,
);
// Label direct sur le seul maximum, jamais sur chaque barre.
if (i === maxIndex && max > 0) {
parts.push(
`${max} `,
);
}
if (i % labelEvery === 0) {
parts.push(
`${escapeHtml(d.label)} `,
);
}
});
parts.push(' ');
host.className = 'chart';
host.innerHTML = parts.join('');
host.querySelectorAll('.bar-hit').forEach((hit) => {
const show = (e) => showTip(e, hit.dataset.label, hit.dataset.value, unit);
hit.addEventListener('pointerenter', show);
hit.addEventListener('pointermove', show);
hit.addEventListener('pointerdown', show);
hit.addEventListener('pointerleave', hideTip);
});
}
/** Classement en barres horizontales (lisible sur mobile, pas de rotation de texte). */
function rankList(host, items, { unit = 'messages' } = {}) {
if (!items.length) {
host.innerHTML = 'Aucune donnée sur la période.
';
return;
}
const max = Math.max(1, ...items.map((i) => i.value));
host.innerHTML = items
.map(
(i) => `
${i.dot ? ` ` : ''}${escapeHtml(i.label)}
${i.value} ${escapeHtml(unit)}
`,
)
.join('');
}
function fmtDuration(seconds) {
const d = Math.floor(seconds / 86400);
const h = Math.floor((seconds % 86400) / 3600);
const m = Math.floor((seconds % 3600) / 60);
if (d) return `${d} j ${h} h`;
if (h) return `${h} h ${m} min`;
return `${m} min`;
}
/* --- Journal d'audit ------------------------------------------------- */
const AUDIT_LABELS = {
login: 'Connexion',
login_failed: 'Échec de connexion',
account_locked: 'Compte verrouillé',
account_activated: 'Compte activé',
rate_limit: 'Rate limit atteint',
ws_flood: 'Flood WebSocket',
help_request: "Demande d'assistance",
help_request_unknown: "Demande d'assistance (numéro inconnu)",
help_unlock: 'Déverrouillage',
help_reset_pin: 'PIN réinitialisé',
help_dismiss: 'Demande classée',
user_whitelisted: 'Numéro enregistré',
users_bulk_import: 'Import de numéros',
user_groups_assigned: 'Affectation de groupes',
user_group_removed: 'Retrait de groupe',
server_start: 'Démarrage du serveur',
};
const AUDIT_SEVERITY = {
login_failed: 'sev-warn',
account_locked: 'sev-bad',
rate_limit: 'sev-warn',
ws_flood: 'sev-bad',
help_request: 'sev-warn',
help_request_unknown: 'sev-warn',
login: 'sev-ok',
account_activated: 'sev-ok',
help_unlock: 'sev-ok',
};
function renderAudit(host, entries) {
if (!entries.length) {
host.innerHTML = 'Aucun événement.
';
return;
}
host.innerHTML = entries
.map((e) => {
const bits = [];
if (e.actor) bits.push(e.actor);
if (e.target && e.target !== e.actor) bits.push(`→ ${e.target}`);
if (e.detail) bits.push(e.detail);
if (e.ip && e.ip !== 'inconnu') bits.push(e.ip);
return `
${fmtRelative(e.at)}
${escapeHtml(AUDIT_LABELS[e.action] || e.action)}
${escapeHtml(bits.join(' · '))}
`;
})
.join('');
}
/* --- Chargement du tableau de bord ----------------------------------- */
async function loadDashboard() {
try {
const d = await api('/api/admin/dashboard');
const k = d.kpis;
$('#dash-kpis').innerHTML = [
{ v: k.users_online, k: 'En ligne', cls: 'accent' },
{ v: k.weekly_active, k: 'Actifs 7 j', cls: 'accent' },
{ v: k.users_active, k: 'Comptes actifs' },
{ v: k.users_invited, k: 'En attente', cls: k.users_invited ? 'warn' : '' },
{ v: k.users_blocked, k: 'Bloqués', cls: k.users_blocked ? 'alert' : '' },
{ v: k.groups_total, k: 'Groupes' },
{ v: k.messages_24h, k: 'Msg 24 h' },
{ v: k.messages_7d, k: 'Msg 7 j' },
{ v: k.images_30d, k: 'Photos 30 j' },
{ v: `${k.activation_rate} %`, k: 'Activation' },
{ v: k.push_subscriptions, k: 'Abonnés push' },
{ v: fmtDuration(k.uptime_seconds), k: 'Uptime' },
]
.map(
(x) =>
`${escapeHtml(String(x.v))}
${x.k}
`,
)
.join('');
const totalDaily = d.daily.reduce((s, x) => s + x.count, 0);
$('#daily-note').textContent = `${d.daily.length} jours · ${totalDaily} au total`;
barChart($('#chart-daily'), d.daily, { barW: 18, gap: 4, labelEvery: 2 });
const totalHourly = d.hourly.reduce((s, x) => s + x.count, 0);
$('#hourly-note').textContent = `${totalHourly} message(s)`;
barChart($('#chart-hourly'), d.hourly, { barW: 11, gap: 3, height: 90, labelEvery: 3 });
rankList(
$('#chart-groups'),
d.top_groups.map((g) => ({ label: g.name, value: g.messages, sub: g.is_subgroup })),
);
rankList(
$('#chart-members'),
d.top_members.map((m) => ({
label: m.name,
value: m.messages,
dot: m.online ? 'online' : '',
})),
);
renderAudit($('#audit-list'), d.audit.slice(0, 12));
} catch (err) {
toast(err.message, 'error');
}
}
$('#btn-audit-all').addEventListener('click', async () => {
try {
const res = await api('/api/admin/audit?limit=200');
openSheet('Journal d\'audit
');
renderAudit($('#audit-full'), res.entries);
} catch (err) {
toast(err.message, 'error');
}
});
/* ------------------------------------------------------------------ */
/* Demandes d'assistance (comptes verrouillés) */
/* ------------------------------------------------------------------ */
const HELP_REASONS = {
locked: 'Compte verrouillé',
blocked: 'Compte bloqué',
forgot_pin: 'Code PIN oublié',
other: 'Autre demande',
};
async function loadHelpRequests() {
// Deux points d'affichage : le tableau de bord (superadmin) et la liste
// des groupes (tout admin de groupe).
const hosts = [$('#help-requests-card'), $('#help-requests-inline')].filter(Boolean);
if (!hosts.length) return;
const host = { set innerHTML(v) { hosts.forEach((h) => (h.innerHTML = v)); },
querySelectorAll: (s) => hosts.flatMap((h) => Array.from(h.querySelectorAll(s))) };
try {
const res = await api('/api/help-requests');
const pip = $('#btn-admin').querySelector('.pip');
if (res.count) {
if (pip) pip.textContent = res.count;
else {
const el = document.createElement('span');
el.className = 'pip';
el.textContent = res.count;
$('#btn-admin').appendChild(el);
}
} else if (pip) {
pip.remove();
}
if (!res.count) {
host.innerHTML = '';
return;
}
host.innerHTML = `
${res.count} demande${res.count > 1 ? 's' : ''} d'assistance
${res.requests
.map(
(r) => `
${escapeHtml(r.name)}
${escapeHtml(r.phone_display)} · ${escapeHtml(
HELP_REASONS[r.reason] || r.reason,
)} · ${fmtRelative(r.created_at)}
${r.message ? `
« ${escapeHtml(r.message)} »
` : ''}
Déverrouiller
Réinit. PIN
Ignorer
`,
)
.join('')}
`;
host.querySelectorAll('[data-act]').forEach((b) =>
b.addEventListener('click', async () => {
const action = b.dataset.act;
if (action === 'reset_pin' && !confirm('Réinitialiser le code PIN de cette personne ?')) {
return;
}
try {
const r = await api(`/api/help-requests/${b.dataset.id}/resolve`, {
method: 'POST',
body: { action },
});
toast(r.message, 'ok');
loadHelpRequests();
loadAdminUsers();
} catch (err) {
toast(err.message, 'error');
}
}),
);
} catch (err) {
// Un membre sans rôle d'admin n'a rien à voir ici : échec silencieux.
host.innerHTML = '';
}
}
/* --- Côté utilisateur : demander de l'aide depuis l'écran d'accueil --- */
$$('[data-open-help]').forEach((b) =>
b.addEventListener('click', () => {
showAuthStep('step-help');
// Pré-sélectionne le motif le plus probable d'après le message d'erreur.
const err = $('#auth-error').textContent;
$('#in-help-reason').value = /bloqué/i.test(err)
? 'blocked'
: /tentatives|verrouill/i.test(err)
? 'locked'
: 'forgot_pin';
}),
);
$('#btn-help-send').addEventListener('click', async () => {
const btn = $('#btn-help-send');
btn.disabled = true;
try {
const res = await api('/api/auth/help', {
method: 'POST',
body: {
phone: state.pendingPhone,
reason: $('#in-help-reason').value,
message: $('#in-help-msg').value.trim(),
},
});
$('#in-help-msg').value = '';
showAuthStep('step-phone');
authError('');
toast(res.message, 'ok');
} catch (err) {
authError(err.message);
} finally {
btn.disabled = false;
}
});
/* ------------------------------------------------------------------ */
/* Service worker et cycle de vie */
/* ------------------------------------------------------------------ */
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/sw.js').catch(() => {
/* hors ligne au premier chargement */
});
});
navigator.serviceWorker.addEventListener('message', (event) => {
if (event.data && event.data.type === 'open-group' && event.data.group_id) {
openChat(Number(event.data.group_id));
}
});
}
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') {
connectWS();
loadGroups();
if (navigator.clearAppBadge) {
try {
navigator.clearAppBadge();
} catch (e) {
/* ignoré */
}
}
}
});
boot();
})();