Files
ObsiGate/frontend/js/filechat.js
T
bruno f4c8504c8d
CI / lint (push) Successful in 2m50s
CI / security (push) Successful in 1m37s
CI / test (push) Successful in 4m34s
CI / build (push) Successful in 1m31s
CI / e2e (push) Successful in 17m36s
feat: chat — les posts affichent le markdown rendu comme un document, code coloré #193
2026-10-09 15:48:04 -04:00

921 lines
31 KiB
JavaScript

// 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:<user>": 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 =
`<div class="file-chat-body file-chat-deleted-text">${t("chat.deleted_message")}` +
`<button class="file-chat-undo" type="button">${t("chat.undo")}</button></div>`;
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 =
`<span class="file-chat-user">${_esc(m.user)}</span>` +
`<span class="file-chat-ts">${_esc(_formatTime(m.ts))}</span>`;
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 = '<i data-lucide="send" style="width:15px;height:15px"></i>';
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());
}