feat: chat — boutons discrets sous l'image, image en onglet d'app, point de non-lus, notification OS, suppression annulable BUG-110
CI / lint (push) Successful in 2m50s
CI / security (push) Successful in 1m36s
CI / test (push) Successful in 4m28s
CI / build (push) Successful in 1m31s
CI / e2e (push) Successful in 17m30s

This commit is contained in:
2026-10-09 11:53:26 -04:00
parent 54caa48d1f
commit 4fb7c43e06
17 changed files with 420 additions and 46 deletions
+112 -12
View File
@@ -11,8 +11,9 @@
// 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 { showToast, TabManager } from "./ui.js";
import { escapeHtml, safeCreateIcons } from "./utils.js";
import { icon } from "./viewer.js";
const PANEL_ID = "file-chat-panel";
const GLOBAL_VAULT = "__global__";
@@ -164,15 +165,48 @@ function _fillBody(body, text) {
});
}
// #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";
return img;
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");
@@ -192,14 +226,46 @@ function _canDelete(m, currentUser) {
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");
}
// #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.
@@ -295,6 +361,7 @@ export function renderChatMessages(container, messages, currentUser, delUrl, rea
}
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) {
@@ -304,6 +371,7 @@ function _appendMessage(list, m, delUrl) {
if (id && list.querySelector(`[data-id="${CSS.escape(id)}"]`)) return false;
list.appendChild(_messageEl(m, _currentUser(), delUrl));
list.scrollTop = list.scrollHeight;
safeCreateIcons();
return true;
}
@@ -409,6 +477,7 @@ 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");
@@ -524,6 +593,7 @@ async function _loadUsers() {
export async function openSidebarChat() {
const list = _sidebarList();
if (!list) return;
_ensureNotifyPermission(); // #110 — geste utilisateur → permission notifications
const users = await _loadUsers();
_renderChannels(users);
try {
@@ -678,6 +748,8 @@ function _totalUnread() {
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();
@@ -696,6 +768,29 @@ 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");
@@ -743,6 +838,7 @@ export function onChatMessage(data) {
_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;
}
@@ -755,12 +851,16 @@ export function onChatMessage(data) {
if (isOpen) {
const list = panel.querySelector(".file-chat-list");
if (list) _appendMessage(list, data.message);
// #192 — panneau affiché : accusé de réception immédiat.
if (data.message.user !== _currentUser()) _markRead(data.vault, data.path);
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
}
/**