// frontend/js/filechat.js — Chat intégré par fichier (#169) // Panneau latéral : historique + saisie. Les messages entrants arrivent par // SSE (event `chat_message` relayé depuis sync.js) ; si le panneau est fermé // ou affiche un autre fichier, un toast notifie l'utilisateur. import { api, AuthManager } from "./auth.js"; import { t } from "./i18n.js"; import { showToast } from "./ui.js"; import { escapeHtml } from "./utils.js"; const PANEL_ID = "file-chat-panel"; let _vault = null; let _path = null; let _sending = false; function _panel() { let el = document.getElementById(PANEL_ID); if (!el) { el = document.createElement("aside"); el.id = PANEL_ID; el.className = "file-chat-panel hidden"; el.setAttribute("aria-label", t("chat.title")); document.body.appendChild(el); } return el; } function _esc(s) { return escapeHtml(String(s == null ? "" : s)); } function _formatTime(ts) { try { return new Date(ts * 1000).toLocaleString(undefined, { day: "2-digit", month: "2-digit", hour: "2-digit", minute: "2-digit", }); } catch (_) { return ""; } } export function renderChatMessages(container, messages, currentUser) { container.innerHTML = ""; if (!messages || messages.length === 0) { const empty = document.createElement("div"); empty.className = "file-chat-empty"; empty.textContent = t("chat.empty"); container.appendChild(empty); return; } messages.forEach((m) => container.appendChild(_messageEl(m, currentUser))); container.scrollTop = container.scrollHeight; } function _messageEl(m, currentUser) { const mine = currentUser && m.user === currentUser; const wrap = document.createElement("div"); wrap.className = "file-chat-msg" + (mine ? " mine" : ""); wrap.dataset.id = m.id || ""; const meta = document.createElement("div"); meta.className = "file-chat-meta"; meta.innerHTML = `${_esc(m.user)}` + `${_esc(_formatTime(m.ts))}`; const body = document.createElement("div"); body.className = "file-chat-body"; body.textContent = m.text || ""; wrap.appendChild(meta); wrap.appendChild(body); return wrap; } async function _load() { const panel = _panel(); const list = panel.querySelector(".file-chat-list"); try { const data = await api( `/api/file/${encodeURIComponent(_vault)}/chat?path=${encodeURIComponent(_path)}`, ); renderChatMessages(list, data.messages || [], _currentUser()); } catch (_) { list.innerHTML = ""; const err = document.createElement("div"); err.className = "file-chat-empty"; err.textContent = t("chat.load_error"); list.appendChild(err); } } function _currentUser() { const u = AuthManager.getUser(); return (u && u.username) || ""; } function _renderShell(panel) { panel.innerHTML = ""; const header = document.createElement("div"); header.className = "file-chat-header"; const title = document.createElement("h3"); title.textContent = t("chat.title"); const close = document.createElement("button"); close.type = "button"; close.className = "file-chat-close"; close.title = t("chat.close"); close.setAttribute("aria-label", t("chat.close")); close.textContent = "✕"; close.addEventListener("click", closeFileChat); header.appendChild(title); header.appendChild(close); const list = document.createElement("div"); list.className = "file-chat-list"; const form = document.createElement("form"); form.className = "file-chat-form"; const input = document.createElement("input"); input.type = "text"; input.className = "file-chat-input"; input.placeholder = t("chat.placeholder"); input.maxLength = 4000; input.setAttribute("aria-label", t("chat.placeholder")); const send = document.createElement("button"); send.type = "submit"; send.className = "file-chat-send"; send.textContent = t("chat.send"); form.appendChild(input); form.appendChild(send); form.addEventListener("submit", (e) => { e.preventDefault(); _send(input); }); panel.appendChild(header); panel.appendChild(list); panel.appendChild(form); } export async function openFileChat(vault, path) { _vault = vault; _path = path; const panel = _panel(); _renderShell(panel); panel.classList.remove("hidden"); await _load(); const input = panel.querySelector(".file-chat-input"); if (input) input.focus(); } export function closeFileChat() { const panel = document.getElementById(PANEL_ID); if (panel) panel.classList.add("hidden"); } export function toggleFileChat(vault, path) { const panel = document.getElementById(PANEL_ID); if (panel && !panel.classList.contains("hidden") && _vault === vault && _path === path) { closeFileChat(); return; } openFileChat(vault, path); } async function _send(input) { if (_sending) return; const text = (input.value || "").trim(); if (!text) return; _sending = true; try { const data = await api(`/api/file/${encodeURIComponent(_vault)}/chat`, { method: "POST", body: JSON.stringify({ path: _path, text }), }); input.value = ""; const list = _panel().querySelector(".file-chat-list"); const empty = list.querySelector(".file-chat-empty"); if (empty) empty.remove(); list.appendChild(_messageEl(data.message, _currentUser())); list.scrollTop = list.scrollHeight; } catch (_) { showToast(t("chat.send_error"), "error"); } finally { _sending = false; input.focus(); } } // --- SSE relay (appelé par sync.js sur l'événement `chat_message`) --------- export function onChatMessage(data) { if (!data || !data.message) return; const panel = document.getElementById(PANEL_ID); const isOpen = panel && !panel.classList.contains("hidden") && _vault === data.vault && _path === data.path; if (isOpen) { const list = panel.querySelector(".file-chat-list"); if (list) { const empty = list.querySelector(".file-chat-empty"); if (empty) empty.remove(); // Évite le doublon si l'envoi local a déjà rendu ce message const id = data.message.id; if (id && list.querySelector(`[data-id="${CSS.escape(id)}"]`)) return; list.appendChild(_messageEl(data.message, _currentUser())); list.scrollTop = list.scrollHeight; } return; } if (data.message.user === _currentUser()) return; // son propre message showToast(t("chat.new_message", { user: data.message.user }), "info"); }