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
}
/**
+42 -1
View File
@@ -1,7 +1,7 @@
/* ObsiGate — UI module */
import { api, AuthManager, initLoginForm } from './auth.js';
import { state } from './state.js';
import { openFile, showWelcome, renderFile, el } from './viewer.js';
import { openFile, showWelcome, renderFile, renderImageViewer, el } from './viewer.js';
import { safeCreateIcons, getFileIcon, escapeHtml, detachInlineEditor } from './utils.js';
import { syncActiveFileTreeItem, refreshSidebarTreePreservingState } from './sidebar.js';
import { GraphViewManager } from './graph.js';
@@ -2122,6 +2122,28 @@ export const TabManager = {
this.activate(tabId);
},
/** #110 — ouvre une image jointe au chat dans un onglet, avec le même
* visualiseur que pour une image de vault (zoom/pan/plein écran). La pièce
* jointe est servie par ``/api/chat/attachment/`` : pas de couple
* (vault, path) derrière, donc onglet dédié qui rend le viewer directement
* depuis l'URL (aucun fetch de l'API fichiers).
* ponytail: l'onglet reste sur le TabManager singleton (les PaneTabManager
* n'ont pas cette branche) — suffisant pour un cas limite du mode split. */
async openChatImage(att) {
if (!att || !att.url) return;
const tabId = `chatimg::${att.url}`;
const name = att.name || "image";
const existing = this._tabs.find(t => t.id === tabId);
if (existing) {
this.activate(tabId);
return;
}
this._tabs.push({ id: tabId, vault: null, path: name, name, icon: "image", chatImage: true });
this._tabCache[tabId] = { chatImage: true, url: att.url, mime: att.mime, name, title: name };
this._renderTabs();
this.activate(tabId);
},
/** Open (or retarget) the navigation tab for a vault directory (#158).
* One navigation tab per vault: clicking another directory reuses it and
* updates its directory, so open file tabs are never displaced. */
@@ -2229,6 +2251,25 @@ export const TabManager = {
return;
}
// #110 — onglet image de chat : rendu direct depuis l'URL de la pièce
// jointe, sans passer par l'API fichiers (l'image n'est pas dans un vault).
if (cache.chatImage) {
state.currentVault = null;
state.currentPath = null;
const chatArea = document.getElementById("content-area");
if (!chatArea) return;
renderImageViewer(chatArea, {
vault: null,
path: cache.name,
title: cache.name,
url: cache.url,
image_mime: cache.mime,
});
const dash = document.getElementById("dashboard-home");
if (dash) dash.style.display = "none";
return;
}
// Update global state
state.currentVault = cache.vault;
state.currentPath = cache.path;
+4 -2
View File
@@ -727,7 +727,7 @@ export function renderImageViewer(area, data) {
// of re-rendering the whole viewer (which used to refetch the file + listing).
let currentPath = data.path;
let currentTitle = data.title || (currentPath || "").split("/").pop();
let imgUrl = buildImageUrl(vault, currentPath);
let imgUrl = data.url || buildImageUrl(vault, currentPath);
let currentMeta = {
image_mime: data.image_mime || imageMimeFor(currentPath),
size_bytes: data.size_bytes,
@@ -959,6 +959,7 @@ export function renderImageViewer(area, data) {
}, { passive: false });
(async () => {
if (!vault) return; // #110 — pièce jointe de chat : pas de listing voisin
try {
const dir = currentPath.includes("/") ? currentPath.slice(0, currentPath.lastIndexOf("/")) : "";
siblings = await listImageSiblings(vault, dir);
@@ -1023,7 +1024,8 @@ export function renderImageViewer(area, data) {
nextBtn.addEventListener("click", () => go(1));
originalBtn.addEventListener("click", () => window.open(imgUrl, "_blank"));
downloadBtn.addEventListener("click", () => {
const dlUrl = `/api/file/${encodeURIComponent(vault)}/download?path=${encodeURIComponent(currentPath)}`;
// #110 — une pièce jointe de chat n'est pas dans un vault : URL directe.
const dlUrl = data.url || `/api/file/${encodeURIComponent(vault)}/download?path=${encodeURIComponent(currentPath)}`;
window.open(dlUrl, "_blank");
});
metaBtn.setAttribute("aria-pressed", _imageViewerState.meta ? "true" : "false");