feat: chat — suppression de post, messages privés, boîte compacte, link preview #191
CI / lint (push) Successful in 2m50s
CI / security (push) Successful in 1m35s
CI / test (push) Successful in 4m28s
CI / build (push) Successful in 1m28s
CI / e2e (push) Successful in 17m11s

This commit is contained in:
2026-10-08 22:33:29 -04:00
parent 55a9fcc0f4
commit f00a8bea8f
23 changed files with 946 additions and 66 deletions
+216 -45
View File
@@ -12,20 +12,23 @@
import { api, AuthManager } from "./auth.js";
import { t } from "./i18n.js";
import { showToast } from "./ui.js";
import { escapeHtml } from "./utils.js";
import { escapeHtml, safeCreateIcons } from "./utils.js";
const PANEL_ID = "file-chat-panel";
const GLOBAL_VAULT = "__global__";
const UNREAD_KEY = "obsigate-chat-unread"; // {general: ts}
const DM_VAULT = "__dm__";
const UNREAD_KEY = "obsigate-chat-unread"; // {general: ts, "dm:<user>": ts}
let _vault = null;
let _path = null;
let _title = "";
let _sending = false;
// État du chat général (onglet sidebar)
let _generalMessages = [];
let _generalLoaded = 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;
// ---------------------------------------------------------------------------
@@ -90,7 +93,65 @@ function _attachEl(att) {
return null;
}
export function _messageEl(m, currentUser) {
// #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" : "");
@@ -100,6 +161,16 @@ export function _messageEl(m, currentUser) {
meta.innerHTML =
`<span class="file-chat-user">${_esc(m.user)}</span>` +
`<span class="file-chat-ts">${_esc(_formatTime(m.ts))}</span>`;
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);
@@ -107,10 +178,11 @@ export function _messageEl(m, currentUser) {
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) {
export function renderChatMessages(container, messages, currentUser, delUrl) {
container.innerHTML = "";
if (!messages || messages.length === 0) {
const empty = document.createElement("div");
@@ -119,16 +191,16 @@ export function renderChatMessages(container, messages, currentUser) {
container.appendChild(empty);
return;
}
messages.forEach((m) => container.appendChild(_messageEl(m, currentUser)));
messages.forEach((m) => container.appendChild(_messageEl(m, currentUser, delUrl)));
container.scrollTop = container.scrollHeight;
}
function _appendMessage(list, m) {
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()));
list.appendChild(_messageEl(m, _currentUser(), delUrl));
list.scrollTop = list.scrollHeight;
return true;
}
@@ -203,8 +275,11 @@ function _renderShell(panel) {
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");
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) => {
@@ -215,6 +290,7 @@ function _renderShell(panel) {
panel.appendChild(header);
panel.appendChild(list);
panel.appendChild(form);
safeCreateIcons(); // #191 — hydrate l'icône d'envoi
}
export async function openFileChat(vault, path, title) {
@@ -272,21 +348,76 @@ async function _send(input) {
}
// ---------------------------------------------------------------------------
// 2. Chat général, onglet sidebar (#190 B)
// 2. Chat sidebar (#190 B) + canaux privé #191
// ---------------------------------------------------------------------------
function _sidebarList() {
return document.getElementById("sidebar-panel-chat-list");
}
/** Charge (ou recharge) l'historique du chat général dans la sidebar. */
/** 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("/api/chat");
_generalMessages = (data.messages || []).slice();
_generalLoaded = true;
renderChatMessages(list, _generalMessages, _currentUser());
const data = await api(_channelUrl());
_channelMessages = (data.messages || []).slice();
_channelLoaded = true;
renderChatMessages(list, _channelMessages, _currentUser(), _channelDelUrl());
_clearUnread();
} catch (_) {
list.innerHTML = "";
@@ -297,17 +428,27 @@ export async function openSidebarChat() {
}
}
/** Recherche dans le chat général (barre de filtre sidebar) — #190 C. */
/** 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 || !_generalLoaded) return;
if (!list || !_channelLoaded) return;
const q = String(query || "").toLowerCase().trim();
const shown = q
? _generalMessages.filter((m) =>
? _channelMessages.filter((m) =>
((m.text || "") + " " + (m.user || "")).toLowerCase().includes(q),
)
: _generalMessages;
renderChatMessages(list, shown, _currentUser());
: _channelMessages;
renderChatMessages(list, shown, _currentUser(), _channelDelUrl());
}
function _sendInput() {
@@ -344,14 +485,14 @@ export async function sendGeneralMessage() {
const body = { text };
if (_pendingAttach) body.attachment = _pendingAttach;
try {
const data = await api("/api/chat", { method: "POST", body: JSON.stringify(body) });
const data = await api(_channelUrl(), { method: "POST", body: JSON.stringify(body) });
input.value = "";
_pendingAttach = null;
_renderPending();
const list = _sidebarList();
if (list) {
_generalMessages.push(data.message);
_appendMessage(list, data.message);
_channelMessages.push(data.message);
_appendMessage(list, data.message, _channelDelUrl());
}
input.focus();
} catch (_) {
@@ -387,7 +528,9 @@ export async function pickAttachment() {
});
}
// --- Messages non lus (#190 B) --------------------------------------------
// --- 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) || "{}") || {};
@@ -396,13 +539,23 @@ function _readUnread() {
}
}
function _clearUnread() {
const u = _readUnread();
u.general = Math.floor(Date.now() / 1000);
function _writeUnread(u) {
try {
localStorage.setItem(UNREAD_KEY, JSON.stringify(u));
} catch (_) { /* quota */ }
_renderBadge(0);
}
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) {
@@ -421,13 +574,9 @@ function _renderBadge(count) {
badge.textContent = count > 99 ? "99+" : String(count);
}
/** Compte les messages reçus depuis la dernière visite. */
export function refreshUnreadBadge(messages) {
const list = messages || _generalMessages;
if (!list.length) return _renderBadge(0);
const seen = _readUnread().general || 0;
const count = list.filter((m) => (m.ts || 0) > seen && m.user !== _currentUser()).length;
_renderBadge(count);
/** Affiche le total des messages non lus (tous canaux confondus). */
export function refreshUnreadBadge() {
_renderBadge(_totalUnread());
}
/** Initialisation : formulaire + raccourcis clavier. */
@@ -456,18 +605,20 @@ export function initSidebarChat() {
// ---------------------------------------------------------------------------
export function onChatMessage(data) {
if (!data || !data.message) return;
const isGeneral = data.vault === GLOBAL_VAULT;
if (isGeneral) {
_generalMessages.push(data.message);
const list = _sidebarList();
// --- 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 (list && visible) {
_appendMessage(list, data.message);
if (isActive) _channelMessages.push(data.message);
if (visible && isActive) {
_appendMessage(_sidebarList(), data.message, _channelDelUrl());
_clearUnread();
} else if (data.message.user !== _currentUser()) {
refreshUnreadBadge(_generalMessages);
_bumpUnread(key);
refreshUnreadBadge();
showToast(t("chat.new_message", { user: data.message.user }), "info");
}
return;
@@ -486,3 +637,23 @@ export function onChatMessage(data) {
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());
}
+11 -1
View File
@@ -120,7 +120,7 @@ export const IndexUpdateManager = (() => {
}
});
// #169 — Chat par fichier : relais du broadcast SSE vers le panneau
// #169/#190 — Chat : relais des broadcasts SSE vers les panneaux
eventSource.addEventListener("chat_message", (e) => {
try {
const data = JSON.parse(e.data);
@@ -130,6 +130,16 @@ export const IndexUpdateManager = (() => {
}
});
// #191 — suppression d'un post
eventSource.addEventListener("chat_deleted", (e) => {
try {
const data = JSON.parse(e.data);
import("./filechat.js").then((m) => m.onChatDeleted(data)).catch(() => {});
} catch (err) {
console.error("SSE parse error:", err);
}
});
eventSource.onerror = () => {
connectionState = "disconnected";
_updateBadge();