// 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");
}