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");
+4 -1
View File
@@ -322,8 +322,11 @@
"chat.attach_error": "Attachment upload failed",
"chat.channel_general": "General",
"chat.delete": "Delete this message",
"chat.delete_confirm": "Delete this message?",
"chat.delete_error": "Delete failed",
"chat.deleted_message": "This message has been deleted",
"chat.undo": "Undo",
"chat.download_image": "Download image",
"chat.view_image": "View full size",
"chat.attach_remove": "Remove attachment",
"chat.button": "Chat",
"chat.button_title": "Discuss this file",
+4 -1
View File
@@ -322,8 +322,11 @@
"chat.attach_error": "Échec de l'envoi de la pièce jointe",
"chat.channel_general": "Général",
"chat.delete": "Supprimer ce message",
"chat.delete_confirm": "Supprimer ce message ?",
"chat.delete_error": "Échec de la suppression",
"chat.deleted_message": "Ce message a été supprimé",
"chat.undo": "Annuler",
"chat.download_image": "Télécharger l'image",
"chat.view_image": "Afficher en grand",
"chat.attach_remove": "Retirer la pièce jointe",
"chat.button": "Chat",
"chat.button_title": "Discuter de ce fichier",
+111 -13
View File
@@ -13828,8 +13828,8 @@ body.reading-mode .file-actions {
}
.file-chat-meta {
display: flex;
justify-content: space-between;
gap: 8px;
align-items: center;
gap: 6px;
font-size: 0.72rem;
color: var(--text-muted, #8b949e);
margin-bottom: 4px;
@@ -13947,18 +13947,23 @@ textarea.file-chat-input {
/* Suppression d'un post */
.file-chat-del {
border: none;
background: transparent;
background: var(--bg-hover, #1f2430);
cursor: pointer;
font-size: 0.75rem;
padding: 0 2px;
opacity: 0;
transition: opacity 0.12s;
font-size: 0.72rem;
padding: 2px 6px;
border-radius: 4px;
opacity: 0.6;
transition: opacity 0.15s, background 0.15s;
color: var(--danger, #ff7b72);
line-height: 1;
margin-left: auto; /* #110 — l'action se détache à droite de la ligne */
}
.file-chat-msg:hover .file-chat-del {
opacity: 0.7;
opacity: 1;
}
.file-chat-del:hover {
opacity: 1 !important;
background: var(--danger-bg, #3d1a18);
}
/* Link preview (#191) */
.file-chat-preview {
@@ -14035,6 +14040,25 @@ textarea.file-chat-input {
.sidebar-tab {
position: relative;
}
/* #110 — point de couleur sur l'icône chat tant qu'il reste des non-lus */
.sidebar-tab#sidebar-tab-chat.chat-has-unread::after {
content: "";
position: absolute;
top: 3px;
left: 6px;
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--danger, #e5534b);
animation: chat-dot-pulse 1.8s ease-out infinite;
}
@keyframes chat-dot-pulse {
0% { box-shadow: 0 0 0 0 rgba(229, 83, 75, 0.5); }
70% { box-shadow: 0 0 0 6px rgba(229, 83, 75, 0); }
100% { box-shadow: 0 0 0 0 rgba(229, 83, 75, 0); }
}
.chat-unread-badge {
position: absolute;
top: 2px;
@@ -14107,12 +14131,46 @@ textarea.file-chat-input {
color: inherit;
cursor: pointer;
}
.file-chat-image {
.file-chat-image-container {
max-width: 100%;
margin-top: 6px;
}
.file-chat-image-container .file-chat-image {
display: block;
max-width: 100%;
max-height: 220px;
border-radius: 8px;
margin-top: 6px;
display: block;
cursor: zoom-in;
}
/* Boutons discrets : icônes seules, sous l'image, alignés à droite */
.file-chat-image-actions {
display: flex;
justify-content: flex-end;
gap: 2px;
margin-top: 2px;
}
.file-chat-image-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
padding: 0;
border: none;
border-radius: 4px;
background: transparent;
color: var(--text-muted, #8b949e);
opacity: 0.55;
cursor: pointer;
transition: opacity 0.15s, color 0.15s, background 0.15s;
}
.file-chat-image-btn:hover {
opacity: 1;
color: var(--accent, #58a6ff);
background: var(--bg-hover, rgba(128, 128, 128, 0.12));
}
.file-chat-image-btn i {
flex-shrink: 0;
}
.file-chat-video {
max-width: 100%;
@@ -14120,10 +14178,50 @@ textarea.file-chat-input {
border-radius: 8px;
margin-top: 6px;
display: block;
}
.file-chat-link {
}.file-chat-link {
color: var(--accent, #58a6ff);
word-break: break-all;
}
/* #110 — message supprimé avec bouton Undo */
.file-chat-deleted {
opacity: 0.7;
background: var(--bg-hover, #1f2430);
border-left: 2px solid var(--text-muted, #484f58);
padding: 4px 8px;
border-radius: 4px;
margin: 4px 0;
}
.file-chat-deleted-text {
font-style: italic;
color: var(--text-muted, #8b949e);
font-size: 0.85rem;
}
.file-chat-undo {
background: var(--accent, #58a6ff);
color: #fff;
border: none;
border-radius: 4px;
padding: 2px 8px;
font-size: 0.72rem;
cursor: pointer;
margin-left: 6px;
transition: background 0.15s;
vertical-align: middle;
}
.file-chat-undo:hover {
background: var(--accent-hover, #4494e6);
}
.file-chat-undo i {
margin-right: 3px;
}