feat: chat — boutons discrets sous l'image, image en onglet d'app, point de non-lus, notification OS, suppression annulable BUG-110
This commit is contained in:
+112
-12
@@ -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
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user