// 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, TabManager } from "./ui.js"; import { escapeHtml, safeCreateIcons, safeHighlight } from "./utils.js"; import { icon } from "./viewer.js"; const PANEL_ID = "file-chat-panel"; const GLOBAL_VAULT = "__global__"; const GLOBAL_PATH = "general"; const DM_VAULT = "__dm__"; const UNREAD_KEY = "obsigate-chat-unread"; // {general: ts, "dm:": ts} const MAX_INPUT_H = 160; // #192 — plafond de la boîte de saisie auto-agrandie 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; // #192 — accusé de réception : {username: dernier_ts_de_lecture} par surface. let _fileRead = {}; let _channelRead = {}; // --------------------------------------------------------------------------- // 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) || ""; } // #192 — chemin de la conversation du canal courant (général ou privé). function _channelVaultPath() { return _channel.type === "dm" ? { vault: DM_VAULT, path: _dmPath(_channel.user) } : { vault: GLOBAL_VAULT, path: GLOBAL_PATH }; } /** Paire DM triée côté client — même règle que ``dm_path()`` serveur. */ function _dmPath(user) { return [_currentUser(), user].sort().join("|"); } /** * #192 — la boîte de saisie suit le contenu : hauteur auto, plafonné à * ``MAX_INPUT_H`` (au-delà la zone défile). */ function _autoGrow(el) { el.style.height = "auto"; el.style.height = `${Math.min(el.scrollHeight, MAX_INPUT_H)}px`; } /** #192 — un message est lu dès qu'un AUTRE participant l'a ouvert. */ function _isRead(ts, readMap, me) { return Object.keys(readMap || {}).some((u) => u !== me && Number(readMap[u]) >= Number(ts)); } /** * #192 — passe les indicateurs de mes messages (liste affichée) à ✓✓. * Retourne le nombre de messages qui venaient de passer à « lu ». */ function _applyRead(list, readMap, me) { if (!list || !readMap) return 0; let flipped = 0; list.querySelectorAll(".file-chat-msg.mine").forEach((el) => { if (el.classList.contains("read")) return; if (!_isRead(el.dataset.ts, readMap, me)) return; el.classList.add("read"); const st = el.querySelector(".file-chat-status"); if (st) { st.textContent = "✓✓"; st.title = t("chat.status_read"); } flipped++; }); return flipped; } /** * #192 — fusionne *readMap* dans la conversation affichée à l'écran et * actualise les indicateurs. Retourne le nombre de mes messages passés à lu. */ function _syncRead(vault, path, readMap) { if (!readMap) return 0; const me = _currentUser(); let flipped = 0; const panel = document.getElementById(PANEL_ID); if (panel && !panel.classList.contains("hidden") && _vault === vault && _path === path) { _fileRead = Object.assign({}, _fileRead, readMap); flipped += _applyRead(panel.querySelector(".file-chat-list"), _fileRead, me); } const key = vault === DM_VAULT ? `dm:${_pairOf(path)}` : vault === GLOBAL_VAULT ? "general" : null; const sidebar = document.getElementById("sidebar-panel-chat"); if (key && key === _channelKey() && sidebar && sidebar.classList.contains("active")) { _channelRead = Object.assign({}, _channelRead, readMap); flipped += _applyRead(_sidebarList(), _channelRead, me); } return flipped; } /** * #192 — signale au serveur que la conversation est affichée (accusé de * réception). Fire-and-forget : un réseau indisponible ne casse rien. */ async function _markRead(vault, path) { try { const data = await api("/api/chat/read", { method: "POST", body: JSON.stringify({ vault, path }), }); _syncRead(vault, path, data && data.read ? data.read : {}); } catch (_) { /* réseau indisponible : pas d'accusé de réception, ni d'erreur */ } } // URL dans le texte → lien cliquable (partage d'URL #190), texte sinon. /** * Remplit le corps d'un message (#193). * * Quand le serveur a fourni le rendu markdown (`html`, même pipeline que les * documents : mistune + sanitizer BUG-021), on l'injecte dans un `.md-content` * — titres, listes, tableaux, blocs de code — et on colore les blocs avec * highlight.js (`safeHighlight`), exactement comme dans le viewer. * * Sans `html` (message optimiste d'un autre client, cache ancien), on retombe * sur le texte brut avec liens cliquables, comme avant. */ function _fillBody(body, m) { const text = typeof m === "string" ? m : (m && m.text) || ""; const html = m && typeof m === "object" ? m.html || "" : ""; if (html) { body.classList.add("file-chat-md"); const div = document.createElement("div"); div.className = "md-content"; div.innerHTML = html; // rendu + assaini côté serveur body.appendChild(div); div.querySelectorAll("pre code").forEach((block) => { if (block.querySelector(".secret-mask")) return; safeHighlight(block); }); return; } 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)); } }); } // #110 — image jointe : clic = même visualiseur que pour une image de vault // (ouvert dans un onglet de l'app), boutons discrets (icônes seules) sous // l'image, alignés à droite. function _attachEl(att) { if (!att || !att.url) return null; if (att.kind === "image") { const container = document.createElement("div"); container.className = "file-chat-image-container"; const img = document.createElement("img"); img.src = att.url; img.alt = att.name || "image"; img.loading = "lazy"; img.className = "file-chat-image"; img.title = t("chat.view_image"); img.addEventListener("click", () => TabManager.openChatImage(att)); container.appendChild(img); const actions = document.createElement("div"); actions.className = "file-chat-image-actions"; const mkBtn = (iconName, label, cls, onClick) => { const b = document.createElement("button"); b.type = "button"; b.className = `file-chat-image-btn ${cls}`; b.title = label; b.setAttribute("aria-label", label); b.appendChild(icon(iconName, 14)); b.addEventListener("click", (e) => { e.stopPropagation(); onClick(); }); return b; }; actions.appendChild(mkBtn("download", t("chat.download_image"), "file-chat-image-download", () => { const a = document.createElement("a"); a.href = att.url; a.download = att.name || "image"; document.body.appendChild(a); a.click(); a.remove(); })); actions.appendChild(mkBtn("maximize", t("chat.view_image"), "file-chat-open", () => TabManager.openChatImage(att))); container.appendChild(actions); return container; } 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; } // #110 — suppression différée : la ligne passe tout de suite à « supprimé » // avec un bouton Annuler ; le DELETE n'est envoyé qu'au bout de 10 s. // ponytail: un rechargement complet pendant la fenêtre annule la suppression // (le message reste côté serveur) — connu, acceptable. const _pendingDeletes = new Map(); // id -> {timer} export function _deleteMessage(delUrl, id, wrap) { if (_pendingDeletes.has(id)) return; const parent = wrap.parentNode; if (!parent) return; const holder = document.createElement("div"); holder.className = "file-chat-msg file-chat-deleted"; holder.innerHTML = `
${t("chat.deleted_message")}` + `
`; parent.replaceChild(holder, wrap); const undo = () => { const entry = _pendingDeletes.get(id); if (!entry) return; clearTimeout(entry.timer); _pendingDeletes.delete(id); holder.replaceWith(wrap); }; const timer = setTimeout(async () => { _pendingDeletes.delete(id); try { await api(`${delUrl}/${encodeURIComponent(id)}`, { method: "DELETE" }); holder.remove(); } catch (_) { showToast(t("chat.delete_error"), "error"); holder.replaceWith(wrap); // suppression ratée : le message revient } }, 10000); _pendingDeletes.set(id, { timer }); holder.querySelector(".file-chat-undo").addEventListener("click", (e) => { e.stopPropagation(); undo(); }); } // #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. * *readMap* (#192) drives the ✓/✓✓ receipt shown on my own messages. */ export function _messageEl(m, currentUser, delUrl, readMap) { const mine = currentUser && m.user === currentUser; const wrap = document.createElement("div"); wrap.className = "file-chat-msg" + (mine ? " mine" : ""); wrap.dataset.id = m.id || ""; wrap.dataset.ts = m.ts || 0; const meta = document.createElement("div"); meta.className = "file-chat-meta"; meta.innerHTML = `${_esc(m.user)}` + `${_esc(_formatTime(m.ts))}`; if (mine) { const read = _isRead(m.ts, readMap, currentUser); const status = document.createElement("span"); status.className = "file-chat-status" + (read ? " read" : ""); status.textContent = read ? "✓✓" : "✓"; status.title = t(read ? "chat.status_read" : "chat.status_sent"); meta.appendChild(status); } 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); 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, readMap) { 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, readMap))); container.scrollTop = container.scrollHeight; safeCreateIcons(); // #110 — hydrate les icônes des boutons de pièce jointe } 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; safeCreateIcons(); 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)}`, ); _fileRead = data.read || {}; renderChatMessages(list, data.messages || [], _currentUser(), null, _fileRead); _markRead(_vault, _path); // #192 — accusé de réception } 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"; // #192 — textarea auto-agrandie : on lit le message entier avant l'envoi. const input = document.createElement("textarea"); input.className = "file-chat-input"; input.rows = 1; input.placeholder = t("chat.placeholder"); input.maxLength = 4000; input.setAttribute("aria-label", t("chat.placeholder")); input.addEventListener("input", () => _autoGrow(input)); input.addEventListener("keydown", (e) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); // Shift+Enter = retour à la ligne _send(input); } }); 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 || ""; _ensureNotifyPermission(); // #110 — geste utilisateur → permission notifications 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 = ""; _autoGrow(input); // #192 — repasse à la hauteur d'une ligne _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; _ensureNotifyPermission(); // #110 — geste utilisateur → permission notifications const users = await _loadUsers(); _renderChannels(users); try { const data = await api(_channelUrl()); _channelMessages = (data.messages || []).slice(); _channelLoaded = true; _channelRead = data.read || {}; renderChatMessages(list, _channelMessages, _currentUser(), _channelDelUrl(), _channelRead); _clearUnread(); const vp = _channelVaultPath(); _markRead(vp.vault, vp.path); // #192 — accusé de réception } 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(), _channelRead); } 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 = ""; _autoGrow(input); // #192 — repasse à la hauteur d'une ligne _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; // #110 — point de couleur sur l'icône chat tant qu'il reste des non-lus tab.classList.toggle("chat-has-unread", count > 0); 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()); } /** #110 — notification OS quand l'app n'est pas au premier plan. */ function _notifyNewMessage(m) { const Notif = window.Notification; if (!Notif) return; if (!document.hidden && document.hasFocus()) return; // visible : le toast suffit if (Notif.permission !== "granted") return; try { const n = new Notif(t("chat.new_message", { user: m.user }), { body: (m.text || "").slice(0, 140), icon: "/static/icons/icon-192x192.svg", tag: "obsigate-chat", }); n.onclick = () => { try { window.focus(); } catch (_) { /* ignore */ } n.close(); }; } catch (_) { /* notifications indisponibles */ } } /** Demande la permission au premier clic utilisateur (ouverture du chat). */ function _ensureNotifyPermission() { const Notif = window.Notification; if (!Notif || Notif.permission !== "default") return; try { Notif.requestPermission(); } catch (_) { /* ignoré */ } } /** 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"); if (input) { input.placeholder = t("chat.placeholder"); // le HTML embarque le FR input.addEventListener("input", () => _autoGrow(input)); // #192 } 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(); // #192 — le canal est affiché : on accuse réception aussitôt. if (data.message.user !== _currentUser()) _markRead(data.vault, data.path); } else if (data.message.user !== _currentUser()) { _bumpUnread(key); refreshUnreadBadge(); showToast(t("chat.new_message", { user: data.message.user }), "info"); _notifyNewMessage(data.message); // #110 — notification OS si app en arrière-plan } 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); if (data.message.user !== _currentUser()) { // #192 — panneau affiché : accusé de réception immédiat. _markRead(data.vault, data.path); _notifyNewMessage(data.message); // #110 — notification OS si app en arrière-plan } return; } if (data.message.user === _currentUser()) return; // son propre message showToast(t("chat.new_message", { user: data.message.user }), "info"); _notifyNewMessage(data.message); // #110 } /** * #192 — SSE `chat_read` : quelqu'un vient d'ouvrir la conversation. Les * indicateurs de MES messages passent à ✓✓ et, si un message change d'état, * l'expéditeur est notifié. */ export function onChatRead(data) { if (!data || !data.user || data.user === _currentUser()) return; // notre propre écho const flipped = _syncRead(data.vault, data.path, data.read || { [data.user]: data.ts }); if (flipped) showToast(t("chat.read_by", { user: data.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()); }