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:
+31
-1
@@ -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é
|
||||
|
||||
+3
-3
@@ -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.
|
||||
|
||||
[]()
|
||||
[]()
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
[](https://www.docker.com/)
|
||||
[](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*
|
||||
|
||||
@@ -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.
|
||||
|
||||
[]()
|
||||
[]()
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
[](https://www.docker.com/)
|
||||
[](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*
|
||||
|
||||
Generated
+1
-1
@@ -2626,7 +2626,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "obsigate-desktop"
|
||||
version = "2.61.0"
|
||||
version = "2.62.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"env_logger",
|
||||
|
||||
+1
-1
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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/<uuid>`) : 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/<sha1>.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) |
|
||||
|
||||
---
|
||||
|
||||
|
||||
+1
-1
@@ -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)**
|
||||
|
||||
+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;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
+1
-1
@@ -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": {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user