// frontend/js/filechat.js — Chat fichier (#169) + chat général sidebar (#190) // // Deux surfaces, un module : // 1. Panneau latéral du document (#169) — historique + saisie, suivi du // document ouvert (followFileChat le re-cible à chaque renderFile). // 2. Onglet « Chat » de la sidebar (#190) — conversation générale, pièces // jointes (image/vidéo/URL), indicateur de messages non lus, recherche // via la barre de filtre de la sidebar. // // Les deux reçoivent les messages en direct par SSE (`chat_message`, relayé // depuis sync.js) : vault `__global__` = chat général, sinon chat fichier. import { api, AuthManager } from "./auth.js"; import { t } from "./i18n.js"; import { showToast } from "./ui.js"; import { escapeHtml, safeCreateIcons } from "./utils.js"; const PANEL_ID = "file-chat-panel"; const GLOBAL_VAULT = "__global__"; const DM_VAULT = "__dm__"; const UNREAD_KEY = "obsigate-chat-unread"; // {general: ts, "dm:": ts} let _vault = null; let _path = null; let _title = ""; let _sending = false; // État du chat sidebar (#190 B + #191 : canaux général / privé) let _channel = { type: "general" }; // ou {type: "dm", user: "bob"} let _channelMessages = []; let _channelLoaded = false; let _usersCache = null; // liste des destinataires DM (une seule requête) let _pendingAttach = null; // --------------------------------------------------------------------------- // Partagé // --------------------------------------------------------------------------- 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 ""; } } function _currentUser() { const u = AuthManager.getUser(); return (u && u.username) || ""; } // URL dans le texte → lien cliquable (partage d'URL #190), texte sinon. function _fillBody(body, text) { const parts = String(text || "").split(/(https?:\/\/[^\s<>"']+)/g); parts.forEach((part) => { if (!part) return; if (/^https?:\/\//.test(part)) { const a = document.createElement("a"); a.href = part; a.textContent = part; a.target = "_blank"; a.rel = "noopener noreferrer"; a.className = "file-chat-link"; body.appendChild(a); } else { body.appendChild(document.createTextNode(part)); } }); } function _attachEl(att) { if (!att || !att.url) return null; if (att.kind === "image") { const img = document.createElement("img"); img.src = att.url; img.alt = att.name || "image"; img.loading = "lazy"; img.className = "file-chat-image"; return img; } if (att.kind === "video") { const v = document.createElement("video"); v.src = att.url; v.controls = true; v.preload = "metadata"; v.className = "file-chat-video"; return v; } return null; } // #191 — suppression : auteur ou admin, avec confirmation native. function _canDelete(m, currentUser) { const u = AuthManager.getUser(); if (!u || !u.username) return false; return u.role === "admin" || m.user === u.username; } async function _deleteMessage(delUrl, id, wrap) { if (!window.confirm(t("chat.delete_confirm"))) return; try { await api(`${delUrl}/${encodeURIComponent(id)}`, { method: "DELETE" }); wrap.remove(); } catch (_) { showToast(t("chat.delete_error"), "error"); } } // #191 — carte de lien : site, titre, description, vignette. function _previewEl(p) { const card = document.createElement("a"); card.className = "file-chat-preview"; card.href = p.url; card.target = "_blank"; card.rel = "noopener noreferrer"; if (p.image) { const img = document.createElement("img"); img.className = "file-chat-preview-img"; img.src = p.image; img.alt = ""; img.loading = "lazy"; img.referrerPolicy = "no-referrer"; card.appendChild(img); } const meta = document.createElement("div"); meta.className = "file-chat-preview-meta"; const site = document.createElement("div"); site.className = "file-chat-preview-site"; site.textContent = p.site || ""; const title = document.createElement("div"); title.className = "file-chat-preview-title"; title.textContent = p.title || p.url; meta.appendChild(site); meta.appendChild(title); if (p.description) { const desc = document.createElement("div"); desc.className = "file-chat-preview-desc"; desc.textContent = p.description; meta.appendChild(desc); } card.appendChild(meta); return card; } /** * Render one message. * *delUrl* (#191) is the DELETE endpoint base for this conversation; when * provided the author/admin sees a trash button on their own messages. */ export function _messageEl(m, currentUser, delUrl) { 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))}`; if (delUrl && _canDelete(m, currentUser)) { const del = document.createElement("button"); del.type = "button"; del.className = "file-chat-del"; del.title = t("chat.delete"); del.setAttribute("aria-label", t("chat.delete")); del.textContent = "🗑"; del.addEventListener("click", () => _deleteMessage(delUrl, m.id, wrap)); meta.appendChild(del); } const body = document.createElement("div"); body.className = "file-chat-body"; _fillBody(body, m.text); wrap.appendChild(meta); if (m.text) wrap.appendChild(body); const att = _attachEl(m.attachment); if (att) wrap.appendChild(att); if (m.preview) wrap.appendChild(_previewEl(m.preview)); return wrap; } export function renderChatMessages(container, messages, currentUser, delUrl) { 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, delUrl))); container.scrollTop = container.scrollHeight; } function _appendMessage(list, m, delUrl) { const empty = list.querySelector(".file-chat-empty"); if (empty) empty.remove(); const id = m.id; if (id && list.querySelector(`[data-id="${CSS.escape(id)}"]`)) return false; list.appendChild(_messageEl(m, _currentUser(), delUrl)); list.scrollTop = list.scrollHeight; return true; } // --------------------------------------------------------------------------- // 1. Panneau du document (#169) // --------------------------------------------------------------------------- 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; } 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 _renderShell(panel) { panel.innerHTML = ""; const header = document.createElement("div"); header.className = "file-chat-header"; const titles = document.createElement("div"); titles.className = "file-chat-titles"; const title = document.createElement("h3"); title.textContent = t("chat.title"); titles.appendChild(title); // #190 A — le document ciblé est affiché dans l'en-tête const target = document.createElement("span"); target.className = "file-chat-target"; target.textContent = _title || _path || ""; target.title = _path || ""; titles.appendChild(target); 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(titles); 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 file-chat-send-icon"; send.title = t("chat.send"); send.setAttribute("aria-label", t("chat.send")); // #191 : icône compacte — la saisie garde toute la largeur send.innerHTML = ''; form.appendChild(input); form.appendChild(send); form.addEventListener("submit", (e) => { e.preventDefault(); _send(input); }); panel.appendChild(header); panel.appendChild(list); panel.appendChild(form); safeCreateIcons(); // #191 — hydrate l'icône d'envoi } export async function openFileChat(vault, path, title) { _vault = vault; _path = path; _title = title || 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, title) { const panel = document.getElementById(PANEL_ID); if (panel && !panel.classList.contains("hidden") && _vault === vault && _path === path) { closeFileChat(); return; } openFileChat(vault, path, title); } // #190 A — appelé à chaque renderFile : le panneau ouvert suit le document. export function followFileChat(vault, path, title) { const panel = document.getElementById(PANEL_ID); if (!panel || panel.classList.contains("hidden")) return; if (_vault === vault && _path === path) return; openFileChat(vault, path, title); } 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 = ""; _appendMessage(_panel().querySelector(".file-chat-list"), data.message); } catch (_) { showToast(t("chat.send_error"), "error"); } finally { _sending = false; input.focus(); } } // --------------------------------------------------------------------------- // 2. Chat sidebar (#190 B) + canaux privé #191 // --------------------------------------------------------------------------- function _sidebarList() { return document.getElementById("sidebar-panel-chat-list"); } /** Clé de la conversation courante (unread + sélection). */ function _channelKey() { return _channel.type === "dm" ? `dm:${_channel.user}` : "general"; } /** URL d'API de base du canal courant. */ function _channelUrl() { return _channel.type === "dm" ? `/api/chat/dm/${encodeURIComponent(_channel.user)}` : "/api/chat"; } /** URL de base de suppression du canal courant. */ function _channelDelUrl() { return _channel.type === "dm" ? `/api/chat/dm/${encodeURIComponent(_channel.user)}` : "/api/chat"; } /** Rend la rangée de canaux : Général + un bouton par utilisateur. */ function _renderChannels(users) { const holder = document.getElementById("sidebar-panel-chat-channels"); if (!holder) return; holder.innerHTML = ""; const mk = (label, active, onClick, title) => { const b = document.createElement("button"); b.type = "button"; b.className = "chat-channel" + (active ? " active" : ""); b.textContent = label; if (title) b.title = title; b.addEventListener("click", onClick); holder.appendChild(b); return b; }; mk(t("chat.channel_general"), _channel.type === "general", () => switchChannel(null)); (users || []).forEach((u) => { mk(u.display_name || u.username, _channel.type === "dm" && _channel.user === u.username, () => switchChannel(u.username), `@${u.username}`); }); } /** Charge la liste des utilisateurs (une fois) puis rend les canaux. */ async function _loadUsers() { if (_usersCache) return _usersCache; try { const data = await api("/api/chat/users"); _usersCache = Array.isArray(data) ? data : []; } catch (_) { _usersCache = []; } return _usersCache; } /** Charge (ou recharge) l'historique du canal courant dans la sidebar. */ export async function openSidebarChat() { const list = _sidebarList(); if (!list) return; const users = await _loadUsers(); _renderChannels(users); try { const data = await api(_channelUrl()); _channelMessages = (data.messages || []).slice(); _channelLoaded = true; renderChatMessages(list, _channelMessages, _currentUser(), _channelDelUrl()); _clearUnread(); } catch (_) { list.innerHTML = ""; const err = document.createElement("div"); err.className = "file-chat-empty"; err.textContent = t("chat.load_error"); list.appendChild(err); } } /** Change de canal (#191) : null = général, sinon l'username du pair. */ export function switchChannel(user) { _channel = user ? { type: "dm", user } : { type: "general" }; const input = _sendInput(); if (input) { input.placeholder = user ? t("chat.placeholder_dm", { user }) : t("chat.placeholder"); } openSidebarChat(); } /** Recherche dans le canal courant (barre de filtre sidebar) — #190 C. */ export function filterChatMessages(query) { const list = _sidebarList(); if (!list || !_channelLoaded) return; const q = String(query || "").toLowerCase().trim(); const shown = q ? _channelMessages.filter((m) => ((m.text || "") + " " + (m.user || "")).toLowerCase().includes(q), ) : _channelMessages; renderChatMessages(list, shown, _currentUser(), _channelDelUrl()); } function _sendInput() { return document.getElementById("sidebar-panel-chat-input"); } function _renderPending() { const holder = document.getElementById("sidebar-panel-chat-pending"); if (!holder) return; holder.innerHTML = ""; if (!_pendingAttach) return; const chip = document.createElement("span"); chip.className = "file-chat-pending"; chip.textContent = (_pendingAttach.kind === "video" ? "🎬 " : "🖼 ") + (_pendingAttach.name || "pièce jointe"); const x = document.createElement("button"); x.type = "button"; x.textContent = "✕"; x.setAttribute("aria-label", t("chat.attach_remove")); x.addEventListener("click", () => { _pendingAttach = null; _renderPending(); }); chip.appendChild(x); holder.appendChild(chip); } /** Envoi d'un message du chat général (texte + pièce jointe éventuelle). */ export async function sendGeneralMessage() { const input = _sendInput(); if (!input) return; const text = (input.value || "").trim(); if (!text && !_pendingAttach) return; const body = { text }; if (_pendingAttach) body.attachment = _pendingAttach; try { const data = await api(_channelUrl(), { method: "POST", body: JSON.stringify(body) }); input.value = ""; _pendingAttach = null; _renderPending(); const list = _sidebarList(); if (list) { _channelMessages.push(data.message); _appendMessage(list, data.message, _channelDelUrl()); } input.focus(); } catch (_) { showToast(t("chat.send_error"), "error"); } } /** Sélecteur de pièce jointe (image/vidéo) → upload → envoi libre. */ export async function pickAttachment() { return new Promise((resolve) => { const fileInput = document.createElement("input"); fileInput.type = "file"; fileInput.accept = "image/*,video/*"; fileInput.style.display = "none"; document.body.appendChild(fileInput); fileInput.addEventListener("change", async () => { const file = fileInput.files && fileInput.files[0]; fileInput.remove(); if (!file) return resolve(null); try { const form = new FormData(); form.append("file", file); const res = await api("/api/chat/upload", { method: "POST", body: form }); _pendingAttach = res.attachment; _renderPending(); resolve(_pendingAttach); } catch (_) { showToast(t("chat.attach_error"), "error"); resolve(null); } }); fileInput.click(); }); } // --- Messages non lus (#190 B / #191) -------------------------------------- // Compteurs par clé de canal : {"general": 2, "dm:bob": 1}. Ouverture du // canal → remise à zéro de sa clé. function _readUnread() { try { return JSON.parse(localStorage.getItem(UNREAD_KEY) || "{}") || {}; } catch (_) { return {}; } } function _writeUnread(u) { try { localStorage.setItem(UNREAD_KEY, JSON.stringify(u)); } catch (_) { /* quota */ } } function _clearUnread() { const u = _readUnread(); u[_channelKey()] = 0; _writeUnread(u); _renderBadge(_totalUnread()); _renderChannels(_usersCache); } function _totalUnread() { const u = _readUnread(); return Object.values(u).reduce((n, v) => n + (typeof v === "number" ? v : 0), 0); } function _renderBadge(count) { const tab = document.getElementById("sidebar-tab-chat"); if (!tab) return; let badge = tab.querySelector(".chat-unread-badge"); if (!count) { if (badge) badge.remove(); return; } if (!badge) { badge = document.createElement("span"); badge.className = "chat-unread-badge"; tab.appendChild(badge); } badge.textContent = count > 99 ? "99+" : String(count); } /** Affiche le total des messages non lus (tous canaux confondus). */ export function refreshUnreadBadge() { _renderBadge(_totalUnread()); } /** Initialisation : formulaire + raccourcis clavier. */ export function initSidebarChat() { const form = document.getElementById("sidebar-panel-chat-form"); if (!form) return; const input = document.getElementById("sidebar-panel-chat-input"); form.addEventListener("submit", (e) => { e.preventDefault(); sendGeneralMessage(); }); const attach = document.getElementById("sidebar-panel-chat-attach"); if (attach) attach.addEventListener("click", () => pickAttachment()); if (input) { input.addEventListener("keydown", (e) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); sendGeneralMessage(); } }); } } // --------------------------------------------------------------------------- // SSE relay (appelé par sync.js sur l'événement `chat_message`) // --------------------------------------------------------------------------- export function onChatMessage(data) { if (!data || !data.message) return; // --- Canaux sidebar : général (#190) ou privé (#191) --- if (data.vault === GLOBAL_VAULT || data.vault === DM_VAULT) { const key = data.vault === DM_VAULT ? `dm:${_pairOf(data.path)}` : "general"; const isActive = key === _channelKey(); const panel = document.getElementById("sidebar-panel-chat"); const visible = panel && panel.classList.contains("active"); if (isActive) _channelMessages.push(data.message); if (visible && isActive) { _appendMessage(_sidebarList(), data.message, _channelDelUrl()); _clearUnread(); } else if (data.message.user !== _currentUser()) { _bumpUnread(key); refreshUnreadBadge(); showToast(t("chat.new_message", { user: data.message.user }), "info"); } return; } // Chat d'un document (#169) 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) _appendMessage(list, data.message); return; } if (data.message.user === _currentUser()) return; // son propre message showToast(t("chat.new_message", { user: data.message.user }), "info"); } /** Le path DM est « alice|bob » trié : on en déduit l'interlocuteur. */ function _pairOf(path) { const me = _currentUser(); const parts = String(path || "").split("|"); return parts[0] === me ? parts[1] : parts[0]; } function _bumpUnread(key) { const u = _readUnread(); u[key] = (u[key] || 0) + 1; _writeUnread(u); } /** Suppression d'un post (#191) — relayée par sync.js. */ export function onChatDeleted(data) { if (!data || !data.id) return; const sel = `[data-id="${CSS.escape(data.id)}"]`; document.querySelectorAll(sel).forEach((el) => el.remove()); }