From 4fb7c43e06f9e13263b35cd5830c6d1e9e772489 Mon Sep 17 00:00:00 2001 From: Bruno Charest Date: Fri, 9 Oct 2026 11:53:07 -0400 Subject: [PATCH] =?UTF-8?q?feat:=20chat=20=E2=80=94=20boutons=20discrets?= =?UTF-8?q?=20sous=20l'image,=20image=20en=20onglet=20d'app,=20point=20de?= =?UTF-8?q?=20non-lus,=20notification=20OS,=20suppression=20annulable=20BU?= =?UTF-8?q?G-110?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 32 +++++++- README.fr.md | 6 +- README.md | 6 +- VERSION | 2 +- desktop/Cargo.lock | 2 +- desktop/Cargo.toml | 2 +- desktop/tauri.conf.json | 2 +- docs/ISSUES_TODOLIST.md | 4 +- docs/ROADMAP.md | 2 +- frontend/js/filechat.js | 124 ++++++++++++++++++++++++++++--- frontend/js/ui.js | 43 ++++++++++- frontend/js/viewer.js | 6 +- frontend/locales/en.json | 5 +- frontend/locales/fr.json | 5 +- frontend/style.css | 124 +++++++++++++++++++++++++++---- package.json | 2 +- tests/frontend/filechat.test.mjs | 99 +++++++++++++++++++++++- 17 files changed, 420 insertions(+), 46 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b54eb9a..21ee944 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ Format basé sur [Keep a Changelog](https://keepachangelog.com/fr/1.1.0/), et [Semantic Versioning](https://semver.org/spec/v2.0.0.html). > **En cours de développement** : les changements à venir sont listés dans la section -> [Unreleased](#unreleased). La dernière version livrée est **2.61.0**. +> [Unreleased](#unreleased). La dernière version livrée est **2.62.0**. --- @@ -14,6 +14,36 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html). --- +## [2.62.0] — 2026-10-09 + +### Modifié + +- **BUG-110 — Chat : refonte visuelle des posts et des indicateurs** + - **Pièces jointes image** : boutons **icônes seules** (24 px, `aria-label`) + rangés **sous l'image**, alignés à droite — télécharger, ouvrir. + - **Clic sur une image = onglet de l'app** : le clic (ou le bouton « ouvrir ») + affiche la pièce jointe dans un **onglet**, avec le même visualiseur que les + images de vault (zoom/pan/plein écran/lightbox) — plus d'ouverture dans un + onglet du navigateur. `TabManager.openChatImage()` + branche `chatImage` de + `activate()` ; `renderImageViewer()` accepte une `url` directe (une pièce + jointe de chat ne vit pas dans un vault). + - **Indicateur de non-lus** : point de couleur pulsant sur l'icône « Chat » de + la sidebar tant qu'il reste des messages non lus, en plus du badge chiffré. + - **Notification OS** : quand l'app n'est pas au premier plan (`document.hidden` + ou hors focus), un message entrant déclenche une notification navigateur + (titre « Nouveau message de {user} », aperçu du texte, clic → retour sur + l'app). Permission demandée au premier clic d'ouverture du chat. + - **Suppression réellement annulable** : le `DELETE` est **différé de 10 s** ; + la ligne passe à « Ce message a été supprimé » avec un bouton « Annuler » qui + réarme le message, et un échec réseau le rend. (L'ancien « Annuler » + restaurait une copie DOM d'un message déjà détruit côté serveur.) + - i18n FR/EN : `chat.deleted_message`, `chat.undo`, `chat.download_image`, + `chat.view_image` — clés mortes retirées (`chat.undo_success`, + `chat.undo_error`, `chat.new_messages_indicator`). + - Tests : `tests/frontend/filechat.test.mjs` 19 → **25** (6 tests #110). + +--- + ## [2.61.0] — 2026-10-08 ### Ajouté diff --git a/README.fr.md b/README.fr.md index 3535b1d..8563c8e 100644 --- a/README.fr.md +++ b/README.fr.md @@ -4,7 +4,7 @@ **Porte d'entrée web ultra-léger pour vos vaults Obsidian** — Accédez, naviguez et recherchez dans toutes vos notes Obsidian depuis n'importe quel appareil via une interface web moderne et responsive. -[![Version](https://img.shields.io/badge/Version-2.61.0-blue.svg)]() +[![Version](https://img.shields.io/badge/Version-2.62.0-blue.svg)]() [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![Docker](https://img.shields.io/badge/Docker-Ready-blue.svg)](https://www.docker.com/) [![Python](https://img.shields.io/badge/Python-3.11+-green.svg)](https://www.python.org/) @@ -976,8 +976,8 @@ Ce projet est sous licence **MIT** — voir le fichier [LICENSE](LICENSE) pour l ## 📝 Changelog -Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.61.0). +Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.62.0). --- -*Projet : ObsiGate | Version : 2.61.0 | Dernière mise à jour : Septembre 2026* +*Projet : ObsiGate | Version : 2.62.0 | Dernière mise à jour : Septembre 2026* diff --git a/README.md b/README.md index cdb1d81..0eb56ed 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ **Ultra-light web gateway for your Obsidian vaults** — Access, browse, and search all your Obsidian notes from any device via a modern, responsive web interface. -[![Version](https://img.shields.io/badge/Version-2.61.0-blue.svg)]() +[![Version](https://img.shields.io/badge/Version-2.62.0-blue.svg)]() [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![Docker](https://img.shields.io/badge/Docker-Ready-blue.svg)](https://www.docker.com/) [![Python](https://img.shields.io/badge/Python-3.11+-green.svg)](https://www.python.org/) @@ -1151,8 +1151,8 @@ This project is licensed under the **MIT License** - see the [LICENSE](LICENSE) ## 📝 Changelog -See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.61.0). +See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.62.0). --- -*Project: ObsiGate | Version: 2.61.0 | Last updated: September 2026* +*Project: ObsiGate | Version: 2.62.0 | Last updated: September 2026* diff --git a/VERSION b/VERSION index 26c0144..995366c 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.61.0 +2.62.0 diff --git a/desktop/Cargo.lock b/desktop/Cargo.lock index 8e5c106..2efe040 100644 --- a/desktop/Cargo.lock +++ b/desktop/Cargo.lock @@ -2626,7 +2626,7 @@ dependencies = [ [[package]] name = "obsigate-desktop" -version = "2.61.0" +version = "2.62.0" dependencies = [ "chrono", "env_logger", diff --git a/desktop/Cargo.toml b/desktop/Cargo.toml index f615318..c17a813 100644 --- a/desktop/Cargo.toml +++ b/desktop/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "obsigate-desktop" -version = "2.61.0" +version = "2.62.0" description = "ObsiGate Desktop — Porte d'entrée native pour vos vaults Obsidian" authors = ["Bruno Charest"] edition = "2021" diff --git a/desktop/tauri.conf.json b/desktop/tauri.conf.json index c41f768..faa273e 100644 --- a/desktop/tauri.conf.json +++ b/desktop/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://raw.githubusercontent.com/nicedoc/obsigate/main/desktop/tauri.conf.schema.json", "productName": "ObsiGate", - "version": "2.61.0", + "version": "2.62.0", "identifier": "com.obsigate.desktop", "build": { "frontendDist": "../frontend", diff --git a/docs/ISSUES_TODOLIST.md b/docs/ISSUES_TODOLIST.md index 3821488..cf8d610 100644 --- a/docs/ISSUES_TODOLIST.md +++ b/docs/ISSUES_TODOLIST.md @@ -217,6 +217,7 @@ Avant de corriger quoi que ce soit, un agent IA doit : | *BUG-107* | Drag & drop de fichiers depuis l'Explorateur inutilisable sur desktop : Tauri/wry pose son propre `IDropTarget` par-dessus de celui du WebView2 et aucun événement natif n'est écouté — #89 ne fonctionnait que sur le web | 🟢 corrigé | P1 | 🖥️ desktop | IA | `desktop/tauri.conf.json` (`create: false`), `desktop/src/main.rs` (`WebviewWindowBuilder::…disable_drag_drop_handler`) | App desktop : glisser un fichier de l'Explorateur sur la fenêtre → aucun survol, aucun dépôt — les gestionnaires HTML5 de `dragdrop.js` ne sont jamais déclenchés | Création manuelle de la fenêtre avec `disable_drag_drop_handler()` (doc Tauri : « required to use HTML5 drag and drop APIs on the frontend on Windows ») ; `create: false` évite la double création par la boucle Tauri | `cargo test` 26 passed (nouveau garde-fou `test_window_created_without_tauri_drag_drop_handler` : les deux marqueurs obligatoires) + lancement OK (fenêtre unique, boot normal) ; dépôt réel à valider par l'utilisateur | | *BUG-108* | Tableur : le « Coller » du menu contextuel ne fait rien (copier-coller par menu muet) | 🟢 corrigé | P1 | 📱 frontend | IA | `frontend/js/xlsx/context-menu.js` (`closeContextMenu`) | Ouvrir un `.xlsx`, clic droit « Copier » sur une plage, clic droit cible puis « Coller » → rien n'est collé (le menu se ferme sans action) | `closeContextMenu()` retirait le nœud du DOM **avant** de remettre `_menu` à `null` : retirer le menu qui tient le focus émet `focusout` en synchrone, qui rappelle `closeContextMenu()` → second `remove()` sur un nœud déjà démonté → `NotFoundError` qui avorte le handler de l'action « Coller » du viewer. Introduit par #179 (fermeture au focus). Correctif : `_menu = null` avant `menu.remove()` | Reproduit en local sur HEAD (e2e « coller une plage » : attendu `Date`, obtenu la valeur datée) puis vert après fix : E2E `-g "coller une plage"` 1/1, `xlsx-menus` 11/11, `ai-quick-actions` 13/13 | | *BUG-109* | Chat : dans la tuile d'une URL (link preview) l'image ne s'affiche jamais | 🟢 corrigé | P2 | ⚙️ backend | IA | `backend/file_chat.py` (`build_preview`, `_proxy_image`) | Poster une URL avec `og:image` dans le chat général → la carte s'affiche mais sans vignette (console : `Refused to load the image … Content Security Policy`) | La CSP de réponse (`img-src 'self' data: blob:`) interdit toute image externe, et les `og:image` **relatives** étaient résolues contre l'origine d'ObsiGate (404). La vignette est désormais **téléversée à l'envoi** dans `chat_uploads` et servie same-origin (`/api/chat/attachment/`) : résolution `urljoin` contre la page, garde SSRF réutilisée, plafond 2 Mo, allow-list d'extensions ; échec du téléchargement → carte conservée sans vignette (jamais de message perdu) | Tests `test_preview_image_is_proxied_same_origin` + `test_preview_image_failure_keeps_the_card` (contre-preuve : proxy neutralisé → `image == ""`). Cartes antérieures inchangées : leur URL distante était déjà bloquée | +| *BUG-110* | Chat : refonte visuelle des posts — boutons discrets sous l'image, image ouverte dans un onglet de l'app, point de non-lus sur l'icône chat, notification OS hors focus, annulation **réelle** d'une suppression | 🟢 corrigé | P2 | 📱 frontend | IA | `frontend/js/filechat.js`, `frontend/js/ui.js`, `frontend/js/viewer.js`, `frontend/style.css`, `frontend/locales/{fr,en}.json`, `tests/frontend/filechat.test.mjs` | Clic sur une image du chat → ouverture dans un onglet du navigateur au lieu du visualiseur de l'app ; boutons de pièce jointe bavards (icône + libellé) ; aucun repère visuel persistant sur l'icône chat ; aucune alerte OS quand l'app est en arrière-plan ; le bouton « Annuler » d'une suppression ne restaurait rien (copie DOM d'un message déjà supprimé côté serveur) | `TabManager.openChatImage()` (+ branche `chatImage` de `activate()`, `renderImageViewer()` accepte une `url` directe) ouvre la pièce jointe dans un **onglet** avec le visualiseur des images de vault (zoom/pan/plein écran) ; boutons **icônes seules** (24 px, `aria-label`) sous l'image, alignés à droite ; **point rouge pulsant** (`.chat-has-unread`) tant qu'il reste des non-lus, en plus du badge chiffré ; `_notifyNewMessage()` — notification OS si `document.hidden`/hors focus, permission demandée à l'ouverture du chat ; suppression **différée de 10 s** (ligne « Ce message a été supprimé » + « Annuler », DELETE envoyé à l'échéance seulement, échec réseau → le message revient) | Vérifié : `filechat.test.mjs` 25/25 (dont 6 tests #110), `validate-imports` 42 modules / 372 exports 0 erreur, `unit.test.mjs` 13/13 | | | | | | | | | | | | ### TODOs techniques (améliorations / nouvelles tâches) @@ -294,7 +295,7 @@ Avant de corriger quoi que ce soit, un agent IA doit : | 2026-09-17 | BUG-065, #78 (complément) | Correction + feature | `frontend/js/excalidraw-viewer.js`, `frontend/js/utils.js`, `frontend/excalidraw-editor.html`, `tests/frontend/excalidraw-viewer.test.mjs`, `docs/features/excalidraw.md`, `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md` | **BUG-065** : l'auto-save Excalidraw (débounce 2 s) déclenchait `PUT save` → SSE `index_updated` → `reloadExternalWrite` → `openFile` → recréation de l'iframe = refresh visible pendant le dessin. Auto-save retirée (`excalidraw-viewer.js` : plus de `requestSave`/`saveTimer`), sauvegarde explicite (bouton 💾 / Ctrl+S) ; `reloadExternalWrite` (utils.js) court-circuite le re-rendu si un iframe Excalidraw est ouvert sur ce fichier (attributs `data-excalidraw-vault`/`data-excalidraw-path`) ; le badge « Modified » suit désormais une signature des éléments (`id:versionNonce`) au lieu de tout `onChange` — resize/zoom/plein écran ne marquent plus le fichier modifié. **#78 (complément)** : bouton **plein écran** `#btn-fullscreen` dans la barre d'outils de l'éditeur (`requestFullscreen` sur le document de l'iframe) + iframe créée avec `allow="fullscreen" allowfullscreen`. Vérifié Playwright : bascule plein écran OK (`document.fullscreenElement` true→false), badge non modifié après bascule ; tests statiques `excalidraw-viewer.test.mjs` 12/12, validate-imports 38 modules, unit 9/9. | 🟢 corrigé (en attente vérif utilisateur) | | 2026-09-17 | #78 (complément) | UI | `frontend/excalidraw-editor.html`, `docs/features/excalidraw.md`, `CHANGELOG.md` | **#78 (complément)** : la barre d'outils de l'éditeur Excalidraw passe en **colonne d'icônes** (34×34 px, SVG seuls), **collée au bord droit** (`right: 0` ; `top: 45%` ; empilement vertical), avec `title`/`aria-label`. L'icône du bouton Save est remplacée par une coche pendant 1,2 s après une sauvegarde réussie. Badge « Modifié » réduit à une pastille. Vérifié Playwright : bord droit au bord de l'iframe, haut 45 %, 4 boutons empilés ; bascule plein écran OK, cycle d'icône Save + `PUT save` observés. | 🟢 livré (en attente vérif utilisateur) | | 2026-09-18 | BUG-066 | Correction | `frontend/locales/fr.json`, `frontend/locales/en.json`, `tests/frontend/unit.test.mjs`, `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md` | **BUG-066** : la table des matières de la page de configuration n'affichait aucune icône pour « Fichiers cachés » et « Partages publics ». Les libellés du sommaire proviennent de clés i18n (`config.section_hidden`, `config.section_shares`) distinctes des titres de section qui, eux, portaient déjà l'icône. Alignement : 🗂️ / 📤 en FR **et** EN. Test de non-régression : `unit.test.mjs` vérifie que **toutes** les entrées `.help-nav-link` du sommaire portent une icône dans les deux langues (17/17). Vérifié : `unit.test.mjs` 10/10, `validate-imports` 38 modules. | 🟢 corrigé (en attente vérif utilisateur) | -| 2026-09-18 | #105, BUG-067 | Documentation + correction | `frontend/index.html`, `frontend/js/config.js`, `frontend/style.css`, `frontend/locales/{fr,en}.json`, `backend/guide_export.py`, `backend/main.py`, `tests/test_guide.py`, `docs/features/guide-coverage-105.md`, `CHANGELOG.md`, `docs/ROADMAP.md`, `docs/ISSUES_TODOLIST.md` | **#105** : audit complet de couverture du Guide d'utilisation — 8 nouvelles sections (Architecture + diagramme Mermaid, API & intégrations, Diagrammes Mermaid & Excalidraw, Hors-ligne & synchronisation, Collaboration temps réel, Application desktop, Bibliothèque & signets, Multilingue) et compléments (recherche sémantique, MFA/WebAuthn, notifications push, exports HTML/ePub/ZIP, PDF, vue multi-panneaux, admin). Téléchargement du guide en Markdown et PDF (`GET /api/guide/download?format=md|pdf`, FR/EN, rendu par le moteur d'export existant). Guide plus large en desktop. **BUG-067** : ancre morte `#help-mobile-editor` → section dédiée créée. | 🟢 corrigé (en attente vérif utilisateur) +| 2026-09-18 | #105, BUG-067 | Documentation + correction | `frontend/index.html`, `frontend/js/config.js`, `frontend/style.css`, `frontend/locales/{fr,en}.json`, `backend/guide_export.py`, `backend/main.py`, `tests/test_guide.py`, `docs/features/guide-coverage-105.md`, `CHANGELOG.md`, `docs/ROADMAP.md`, `docs/ISSUES_TODOLIST.md` | **#105** : audit complet de couverture du Guide d'utilisation — 8 nouvelles sections (Architecture + diagramme Mermaid, API & intégrations, Diagrammes Mermaid & Excalidraw, Hors-ligne & synchronisation, Collaboration temps réel, Application desktop, Bibliothèque & signets, Multilingue) et compléments (recherche sémantique, MFA/WebAuthn, notifications push, exports HTML/ePub/ZIP, PDF, vue multi-panneaux, admin). Téléchargement du guide en Markdown et PDF (`GET /api/guide/download?format=md|pdf`, FR/EN, rendu par le moteur d'export existant). Guide plus large en desktop. **BUG-067** : ancre morte `#help-mobile-editor` → section dédiée créée. | 🟢 corrigé (en attente vérif utilisateur) | | 2026-09-18 | #105 (ajustements) | Amélioration | `frontend/index.html`, `frontend/js/config.js`, `frontend/sw.js`, `frontend/locales/{fr,en}.json`, `backend/guide_export.py`, `backend/pdf_export.py`, `Dockerfile`, `scripts/build_guide_diagrams.py`, `scripts/render_guide_diagram.mjs`, `scripts/guide_content.py`, `backend/assets/guide_diagrams/df7366a40db6a5a2.png`, `tests/test_guide.py`, `docs/features/guide-coverage-105.md`, `CHANGELOG.md` | **#105 (retour utilisateur)** : 1) boutons de téléchargement du guide passés en icônes seules (tooltips i18n conservés) ; 2) le diagramme Mermaid de la section Architecture est désormais rendu en **vraie image** dans le PDF (pipeline de pré-rendu PNG Chromium+mermaid v11, PNG commité sous `backend/assets/guide_diagrams/.png`, résolu par `diagram_png_for()` ; le Markdown garde le fenced mermaid) ; 3) emoji du PDF rendus **en couleur** au lieu de rectangles : `fonts-noto-color-emoji` ajouté au Dockerfile + `"Noto Color Emoji"` en fin de pile de polices PDF. Vérifié : pytest 1218 (test_guide ×13), ruff/mypy 0, validate-imports 38, unit 10/10 ; PDF live conteneur 2020 : 24 pages, 0 glyphes tofu, diagramme 3568x1174 embarqué. | 🟢 livré | 2026-09-22 | BUG-068 | Correction | `backend/auth/router.py`, `backend/requirements.txt`, `frontend/js/auth.js`, `frontend/style.css`, `frontend/locales/{fr,en}.json`, `tests/test_mfa.py`, `tests/frontend/mfa-settings.test.mjs` (nouveau), `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md` | **BUG-068** : section « 🔒 Sécurité du compte » finalisée. (1) Boutons hors thème : `config-btn-primary`/`config-btn-danger` n'existaient pas en CSS → définis depuis les variables du thème (+ états disabled). (2) QR invisible : l'image tierce était bloquée par la CSP (`img-src 'self' data: blob:`) et exposait le secret TOTP → QR SVG `data:` généré en local par le backend (`qr_data_url`, segno) avec repli saisie manuelle. (3) Codes de récupération perdus à la 1re activation WebAuthn → `_showRecoveryCodes(codes, targetId)` avec repli `webauthn-flow-area`. (4) Carte « Mot de passe » ajoutée (endpoint `change-password` existant, jusque-là sans UI) + échappement des libellés de clés WebAuthn. Vérifié : pytest 1241 passed / 6 skipped, ruff 0, mypy 0 (78 fichiers), `mfa-settings.test.mjs` 9/9, unit 10/10, validate-imports 39 modules. | 🟢 corrigé (en attente vérif utilisateur) | | 2026-09-23 | BUG-069 | Correction | `frontend/js/auth.js`, `frontend/locales/{fr,en}.json`, `tests/frontend/mfa-settings.test.mjs`, `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md` | **BUG-069** : login 2FA bloqué sans erreur — après user+pwd corrects, `showMfaChallenge` cherchait `.login-box` (inexistant dans `index.html`, marquage réel `#login-screen > .login-card`) et faisait un `return` silencieux : page de login figée, aucune erreur. Correctif : montage dans `.login-card` (repli `#login-screen`) + erreur visible `mfa.challenge_unavailable` (FR/EN) si le point de montage manque. **Reproduit au navigateur** (Playwright, instance Docker `obsigate-test`, compte jetable avec TOTP) : avant → challenge jamais affiché ; après → challenge affiché, code erroné → erreur, code valide (verify 200) → app. Tests : `mfa-settings.test.mjs` 11/11 (+2 ancrage DOM), unit 10/10, validate-imports 39 modules. | 🟢 corrigé (en attente vérif utilisateur) | @@ -334,6 +335,7 @@ Avant de corriger quoi que ce soit, un agent IA doit : | 2026-10-08 | #190 | Fonctionnalité | `backend/file_chat.py`, `backend/routers/file_chat.py`, `backend/schemas.py`, `frontend/js/filechat.js` (réécrit), `frontend/js/viewer.js`, `frontend/js/config.js`, `frontend/js/sidebar.js`, `frontend/js/app.js`, `frontend/index.html`, `frontend/style.css`, `frontend/locales/{fr,en}.json`, `tests/test_file_chat.py`, `tests/frontend/filechat.test.mjs`, `docs/ROADMAP.md`, `CHANGELOG.md` | **#190 — chat : suivi du document, chat général en onglet sidebar, onglets au-dessus du filtre** : (A) l'en-tête du panneau affiche le document ciblé (titre + chemin) et `followFileChat()` appelé par `renderFile()` re-cible le panneau ouvert à chaque changement de document ; (B) onglet **Chat** (dernier) → conversation générale stockée dans le même store #169 via les sentinelles `__global__/general` (`GET/POST /api/chat`), **pastille de messages non lus** (`localStorage` + badge sur l'onglet), **pièces jointes image/vidéo** (`POST /api/chat/upload` : allow-list d'extensions, 25 MB, nom UUID — jamais le nom client ; `GET /api/chat/attachment/{name}` résolu contre l'allow-list), **URL cliquables** dans le rendu (linkification `http(s)://`), date/heure d'envoi ; (C) barre de filtre **déplacée sous les onglets** et routée vers `filterChatMessages()` (texte + auteur) quand l'onglet Chat est actif. Transport : broadcast SSE `chat_message` réutilisé (vault `__global__` route vers le panneau sidebar, sinon panneau fichier) — pas de second WebSocket. Contre-preuves : `__global__` non routé → test « routes __global__ » échoue ; panneau sans classe `active` → le message part en toast au lieu du rendu (révélé par le stub JSDOM). Vérifié : pytest **1655 passed / 2 skipped** (27 tests chat dont 12 nouveaux), ruff 0, mypy 0 (113 fichiers), validate-imports 42 modules/368 exports, unit 13/13, filechat.test.mjs **11/11** (5 nouveaux), toolbar-order, sidebar-filters 8/8 | ✅ livré (en attente vérif utilisateur) | | 2026-10-08 | #169 | Fonctionnalité | `backend/file_chat.py` (nouveau), `backend/routers/file_chat.py` (nouveau), `backend/schemas.py`, `backend/main.py`, `frontend/js/filechat.js` (nouveau), `frontend/js/viewer.js`, `frontend/js/sync.js`, `frontend/style.css`, `frontend/locales/{fr,en}.json`, `tests/test_file_chat.py` (nouveau), `tests/frontend/filechat.test.mjs` (nouveau), `tests/frontend/toolbar-order.test.mjs`, `.gitea/workflows/ci.yml`, `docs/ROADMAP.md`, `docs/features/file-chat-169.md` (nouveau), `docs/GUIDES/PRISE_EN_MAIN.md`, `CHANGELOG.md` | **#169 — chat intégré par fichier** : store JSON par (vault, path) sous `data/chats/` (nom SHA-256 → traversal impossible, plafond 500 msgs, texte 4000 car., écriture atomique, fichier corrompu → vide) ; `GET/POST /api/file/{vault}/chat` (auth + `check_vault_access` + `resolve_safe_path`, 400/404/403, `response_model`) + broadcast SSE `chat_message` sur le transport #62 (**pas de 2ᵉ WebSocket**) ; panneau latéral `filechat.js` (bouton 💬 toolbar, rendu chronologique `textContent`, envoi optimiste + dédup par id, toast si panneau fermé/autre fichier, plein écran ≤ 768 px) ; relais SSE en import dynamique dans `sync.js` ; i18n FR/EN 10 clés `chat.*`. En route : tag OpenAPI maison `file-chat` non déclaré → `test_used_tags_are_declared` rouge (retiré, dérivé « Files »), regex navBtns de `toolbar-order.test.mjs` (échappements → `includes`), ruff UP012/TRY004/I001. Vérifié : `test_file_chat.py` 15 passed, `filechat.test.mjs` 6/6, suite 1643 passed, ruff/mypy 0, validate-imports 42 modules, CI run #1938 5/5 success (v2.57.0) | ✅ livré (en attente vérif utilisateur) | | 2026-10-08 | #192 + BUG-109 | Fonctionnalité + correction | `backend/file_chat.py`, `backend/routers/file_chat.py`, `backend/schemas.py`, `frontend/js/filechat.js`, `frontend/js/sync.js`, `frontend/index.html`, `frontend/style.css`, `frontend/locales/{fr,en}.json`, `tests/test_file_chat.py`, `tests/frontend/filechat.test.mjs`, `docs/ROADMAP.md`, `docs/features/file-chat-169.md`, `CHANGELOG.md` | **#192 — chat : saisie auto-agrandissante + accusé de réception.** La boîte d'envoi (panneau document **et** sidebar) devient un `textarea` dont la hauteur suit le contenu (plafond 160 px puis défilement, repasse à 1 ligne après l'envoi), `Entrée` envoie / `Maj+Entrée` saute une ligne ; placeholder sidebar désormais traduit à l'init. **Accusé de réception** : chaque document de conversation porte `read = {utilisateur: ts}` (verrou global sur le read-modify-write — une lecture ne peut plus faire disparaître un message), `GET` d'historique renvoie la carte, nouveau `POST /api/chat/read` (400 sans vault/path, 403 DM hors pair, ACL vault pour un chat de fichier) marque la lecture et diffuse `chat_read` en SSE ; côté client `✓` envoyé / `✓✓` lu sur **mes** messages (exclut ma propre carte de lecture), bascule en direct via `onChatRead()` + toast « {user} a lu votre message », accusé émis à l'ouverture d'une conversation et à la réception d'un message déjà affiché. **BUG-109** : la vignette des tuiles de lien ne s'affichait jamais (CSP `img-src 'self'` + `og:image` relative résolue contre ObsiGate) → `_proxy_image()` téléverse l'image dans `chat_uploads` (same-origin, `urljoin` contre la page, garde SSRF, 2 Mo max, allow-list) et la carte reste affichée si le téléchargement échoue. Tests : pytest `test_file_chat.py` 45→**54** (`TestReadReceipts` 7, proxy image 2, 2 assertions de réponse adaptées), JSDOM `filechat.test.mjs` 16→**19** ; ruff 0, mypy 0 (113 fichiers), validate-imports 42/371, unit 13/13 | ✅ livré (en attente vérif utilisateur) | +| 2026-10-09 | BUG-110 | Correction + refonte UI chat | `frontend/js/filechat.js`, `frontend/js/ui.js`, `frontend/js/viewer.js`, `frontend/style.css`, `frontend/locales/{fr,en}.json`, `tests/frontend/filechat.test.mjs`, `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md` | **BUG-110 — refonte visuelle du chat** : (1) les boutons de pièce jointe deviennent des **icônes seules** (24 px, `aria-label`) rangées **sous l'image, alignées à droite** — plus de gros boutons à libellé ; (2) le clic sur une image ouvre le **même visualiseur que pour une image de vault** (zoom/pan/plein écran) mais dans un **onglet de l'app** (`TabManager.openChatImage()`, branche `chatImage` de `activate()`, `renderImageViewer()` accepte désormais une `url` directe : une pièce jointe n'est pas dans un vault) au lieu d'un onglet du navigateur ; (3) **point rouge pulsant sur l'icône chat** de la sidebar tant qu'il reste des non-lus (classe `chat-has-unread`, en plus du badge chiffré) ; (4) **notification OS** (`Notification`) quand l'app n'est pas au premier plan (`document.hidden`/hors focus), permission demandée au premier clic d'ouverture du chat ; (5) la suppression n'est plus mensongère : le `DELETE` est **différé de 10 s**, la ligne passe à « Ce message a été supprimé » avec « Annuler » — annuler réarme le message (l'ancien code restaurait une copie DOM d'un message déjà détruit côté serveur en affichant « Message restauré ») ; échec réseau → le message revient. Vérifié : `filechat.test.mjs` 25/25 (dont 6 tests #110 : icônes seules, clic image → `openChatImage`, aucun `DELETE` pendant la fenêtre d'annulation, notification OS), `validate-imports` 42 modules / 372 exports 0 erreur, `unit.test.mjs` 13/13 | 🟢 corrigé (en attente vérif utilisateur) | --- diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index a69f1e6..8006884 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -1,6 +1,6 @@ # ObsiGate — Roadmap -> **Version :** 2.61.0 | **Dernière mise à jour :** 2026-10-08 +> **Version :** 2.62.0 | **Dernière mise à jour :** 2026-10-09 > **Ce fichier ne contient que le travail à venir** (🔵 En cours + ⚪ Backlog) et un index compact > vers les fonctionnalités livrées. > - **Méthode de livraison à appliquer pour toute tâche : [DELIVERY_WORKFLOW.md](./DELIVERY_WORKFLOW.md)** diff --git a/frontend/js/filechat.js b/frontend/js/filechat.js index 3563675..4967fd8 100644 --- a/frontend/js/filechat.js +++ b/frontend/js/filechat.js @@ -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 = + `
${t("chat.deleted_message")}` + + `
`; + 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 } /** diff --git a/frontend/js/ui.js b/frontend/js/ui.js index 688f283..c52d1b2 100644 --- a/frontend/js/ui.js +++ b/frontend/js/ui.js @@ -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; diff --git a/frontend/js/viewer.js b/frontend/js/viewer.js index 6aba95a..7b6c942 100644 --- a/frontend/js/viewer.js +++ b/frontend/js/viewer.js @@ -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"); diff --git a/frontend/locales/en.json b/frontend/locales/en.json index d674945..713c566 100644 --- a/frontend/locales/en.json +++ b/frontend/locales/en.json @@ -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", diff --git a/frontend/locales/fr.json b/frontend/locales/fr.json index 9bfddaf..128b872 100644 --- a/frontend/locales/fr.json +++ b/frontend/locales/fr.json @@ -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", diff --git a/frontend/style.css b/frontend/style.css index 1e49a1d..d1b3f4d 100644 --- a/frontend/style.css +++ b/frontend/style.css @@ -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; +} + + + + + diff --git a/package.json b/package.json index 465271f..c16d8c7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "obsigate", - "version": "2.61.0", + "version": "2.62.0", "description": "**Porte d'entrée web ultra-léger pour vos vaults Obsidian** — Accédez, naviguez et recherchez dans toutes vos notes Obsidian depuis n'importe quel appareil via une interface web moderne et responsive.", "main": "patch.js", "directories": { diff --git a/tests/frontend/filechat.test.mjs b/tests/frontend/filechat.test.mjs index ac4bd76..23c081d 100644 --- a/tests/frontend/filechat.test.mjs +++ b/tests/frontend/filechat.test.mjs @@ -46,8 +46,10 @@ const LOCALES_DIR = path.resolve(__dirname, "..", "..", "frontend", "locales"); // Route fetch: locales from disk, auth 401, API chat endpoints from a stub map. const apiStubs = new Map(); -globalThis.fetch = (url) => { +const fetchCalls = []; // #110 — journal des appels {url, method} +globalThis.fetch = (url, opts = {}) => { const u = String(url); + fetchCalls.push({ url: u, method: (opts.method || "GET").toUpperCase() }); if (u.includes("/static/locales/")) { const name = u.split("/").pop(); try { @@ -77,7 +79,7 @@ const mod = await import("../../frontend/js/filechat.js"); const { renderChatMessages, onChatMessage, openFileChat, closeFileChat, toggleFileChat, followFileChat, openSidebarChat, filterChatMessages, refreshUnreadBadge, - onChatDeleted, onChatRead, + onChatDeleted, onChatRead, _deleteMessage, } = mod; let pass = 0; @@ -338,6 +340,99 @@ test("#192 — saisie en textarea auto-agrandie, Enter envoie", async () => { closeFileChat(); }); +// --- #110 : boutons discrets, image en onglet, point non-lu, delete différé - + +test("#110 — bouton supprimer visible (pas opacity:0)", () => { + const list = document.createElement("div"); + renderChatMessages(list, [{ id: "d1", user: "bruno", text: "x", ts: 50 }], "bruno", "/api/chat"); + const del = list.querySelector(".file-chat-del"); + assert.ok(del, "bouton suppression présent"); + assert.strictEqual(del.style.opacity, "", "pas de opacity:0 — bouton visible"); +}); + +test("#110 — image : boutons icônes discrets sous l'image, à droite", () => { + const list = document.createElement("div"); + renderChatMessages(list, [{ + id: "img1", user: "alice", text: "", ts: 100, + attachment: { kind: "image", url: "/api/chat/attachment/photo.png", name: "photo.png", mime: "image/png" }, + }], "bruno"); + const container = list.querySelector(".file-chat-image-container"); + assert.ok(container, "conteneur image présent"); + const actions = container.querySelector(".file-chat-image-actions"); + assert.ok(actions, "rangée de boutons sous l'image"); + assert.ok(actions.querySelector(".file-chat-image-download"), "bouton téléchargement présent"); + assert.ok(actions.querySelector(".file-chat-open"), "bouton visualisation présent"); + actions.querySelectorAll("button").forEach((b) => { + assert.strictEqual(b.textContent.trim(), "", "bouton icône seule (sans libellé texte)"); + assert.ok(b.getAttribute("aria-label"), "aria-label présent"); + }); +}); + +test("#110 — clic sur l'image : ouverture dans un onglet de l'app", async () => { + const ui = await import(pathToFileURL(path.join(JS_DIR, "ui.js")).href); + const seen = []; + const orig = ui.TabManager.openChatImage; + ui.TabManager.openChatImage = (att) => seen.push(att); + try { + const list = document.createElement("div"); + const att = { kind: "image", url: "/api/chat/attachment/p2.png", name: "p2.png" }; + renderChatMessages(list, [{ id: "img2", user: "alice", text: "", ts: 101, attachment: att }], "bruno"); + list.querySelector(".file-chat-image").click(); + assert.strictEqual(seen.length, 1, "openChatImage appelé au clic sur l'image"); + assert.strictEqual(seen[0].url, att.url, "même pièce jointe transmise"); + list.querySelector(".file-chat-open").click(); + assert.strictEqual(seen.length, 2, "le bouton discret ouvre aussi l'onglet"); + } finally { + ui.TabManager.openChatImage = orig; + } +}); + +test("#110 — point de couleur sur l'icône chat tant qu'il reste des non-lus", () => { + const tab = document.getElementById("sidebar-tab-chat"); + assert.ok(tab, "onglet chat présent"); + localStorage.setItem("obsigate-chat-unread", JSON.stringify({ general: 3 })); + refreshUnreadBadge(); + assert.ok(tab.classList.contains("chat-has-unread"), "classe chat-has-unread posée"); + localStorage.setItem("obsigate-chat-unread", JSON.stringify({})); + refreshUnreadBadge(); + assert.ok(!tab.classList.contains("chat-has-unread"), "classe retirée quand tout est lu"); +}); + +test("#110 — suppression différée : Annuler remet le message, aucun DELETE", () => { + const list = document.createElement("div"); + renderChatMessages(list, [{ id: "del9", user: "bruno", text: "mon message", ts: 200 }], "bruno", "/api/chat"); + const before = fetchCalls.length; + list.querySelector(".file-chat-del").click(); + assert.ok(list.querySelector(".file-chat-deleted"), "ligne remplacée par « supprimé »"); + assert.ok(list.querySelector(".file-chat-undo"), "bouton Annuler présent"); + assert.ok(!fetchCalls.slice(before).some((c) => c.method === "DELETE"), "aucun DELETE parti pendant la fenêtre"); + list.querySelector(".file-chat-undo").click(); + assert.ok(list.querySelector("[data-id='del9']"), "message d'origine restauré"); + assert.strictEqual(list.querySelector(".file-chat-deleted"), null, "ligne « supprimé » retirée"); + assert.ok(!fetchCalls.slice(before).some((c) => c.method === "DELETE"), "toujours aucun DELETE"); +}); + +test("#110 — notification OS quand l'app est en arrière-plan", () => { + const fired = []; + class FakeNotification { + constructor(title, opts) { fired.push({ title, opts }); } + close() {} + } + FakeNotification.permission = "granted"; + window.Notification = FakeNotification; + Object.defineProperty(document, "hidden", { value: true, configurable: true }); + try { + const panel = document.getElementById("sidebar-panel-chat"); + panel.classList.remove("active"); + onChatMessage({ vault: "__global__", path: "general", message: { id: "n-1", user: "bob", text: "coucou", ts: 300 } }); + assert.strictEqual(fired.length, 1, "notification affichée"); + assert.ok(String(fired[0].opts.body).includes("coucou"), "aperçu du message dans la notification"); + } finally { + Object.defineProperty(document, "hidden", { value: false, configurable: true }); + window.Notification = { permission: "denied" }; + } +}); + // Sequential execution: each case depends on panel state left by the previous. for (const [name, fn] of cases) { try {