diff --git a/CHANGELOG.md b/CHANGELOG.md
index 478d78f..9114242 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.58.0**.
+> [Unreleased](#unreleased). La dernière version livrée est **2.60.0**.
---
@@ -14,6 +14,35 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
---
+## [2.60.0] — 2026-10-08
+
+---
+
+## [2.59.0] — 2026-10-08
+
+### Ajouté
+
+- **#191 - Chat : suppression de post, messages privés, boîte compacte, link preview**
+ - **Suppression d'un post** : bouton 🗑 sur ses propres messages (et sur
+ tout le monde pour un admin), confirmation, `DELETE /api/chat/{id}` et
+ `/api/chat/dm/{user}/{id}`, retrait en direct chez tous les clients
+ (SSE `chat_deleted`).
+ - **Messages privés 2 utilisateurs** : rangée de canaux au-dessus du fil
+ (« Général » + un bouton par utilisateur), conversations isolées
+ (vault `__dm__`, chemin `alice|bob` trié), placeholder dédié,
+ compteurs de non-lus par canal, `GET /api/chat/users` pour la liste
+ des destinataires (sans hash/mot de passe, sans soi-même).
+ - **Boîte d'édition compacte** : bouton d'envoi réduit à une icône
+ (panneau document + sidebar), la saisie garde toute la largeur.
+ - **Link preview** : métadonnées OpenGraph récupérées à l'envoi
+ (titre, description, image, site), carte cliquable esthétique,
+ garde SSRF réutilisée (host privé/loopback rejeté), cache borné
+ 200 entrées, échec silencieux (URL morte → le message passe sans carte).
+ - Tests : pytest +13 (45 au total file_chat), JSDOM `filechat.test.mjs`
+ +5 (16).
+
+---
+
## [2.58.0] — 2026-10-08
### Ajouté
diff --git a/README.fr.md b/README.fr.md
index 3de0626..1c81a1d 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.
-[]()
+[]()
[](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.58.0).
+Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.60.0).
---
-*Projet : ObsiGate | Version : 2.58.0 | Dernière mise à jour : Septembre 2026*
+*Projet : ObsiGate | Version : 2.60.0 | Dernière mise à jour : Septembre 2026*
diff --git a/README.md b/README.md
index 7e36f2c..9a36880 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.
-[]()
+[]()
[](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.58.0).
+See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.60.0).
---
-*Project: ObsiGate | Version: 2.58.0 | Last updated: September 2026*
+*Project: ObsiGate | Version: 2.60.0 | Last updated: September 2026*
diff --git a/VERSION b/VERSION
index 1401b92..54bbd78 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-2.58.0
+2.60.0
diff --git a/backend/file_chat.py b/backend/file_chat.py
index 63be38e..3390813 100644
--- a/backend/file_chat.py
+++ b/backend/file_chat.py
@@ -15,14 +15,18 @@ uploaded under ``data/chat_uploads/``.
from __future__ import annotations
import hashlib
+import html
import json
import logging
+import re
import shutil
import time
import uuid
from pathlib import Path
from typing import Any
+import httpx
+
logger = logging.getLogger("obsigate.file_chat")
CHAT_DIR = Path("data/chats")
@@ -33,6 +37,11 @@ MAX_TEXT = 4000 # characters per message
GLOBAL_VAULT = "__global__"
GLOBAL_PATH = "general"
+# #191 — private (2 users) conversations reuse the same store: the vault is
+# the reserved sentinel and the path is the sorted username pair, so the
+# storage key never depends on who asks.
+DM_VAULT = "__dm__"
+
# #190 — attachments (image/video) live outside the vaults.
UPLOAD_DIR = Path("data/chat_uploads")
MAX_UPLOAD_BYTES = 25 * 1024 * 1024 # 25 MB per attachment
@@ -41,6 +50,10 @@ ALLOWED_ATTACH_EXT = {
".mp4", ".webm", ".ogg", ".mov", ".m4v",
}
+# #191 — link preview fetch budget
+PREVIEW_TIMEOUT = 5.0 # seconds
+PREVIEW_MAX_BYTES = 512 * 1024 # only the head of the page is parsed
+
def _chat_file(vault: str, path: str) -> Path:
"""Return the chat file for *(vault, path)* (hashed, traversal-proof)."""
@@ -85,11 +98,13 @@ def add_message(
user: str,
text: str,
attachment: dict[str, Any] | None = None,
+ preview: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""Append a message and persist it. Returns the stored message.
The list is capped at :data:`MAX_MESSAGES` (oldest dropped first).
- *attachment* (#190) is ``{name, url, mime, kind}`` for image/video/url.
+ *attachment* (#190) is ``{name, url, mime, kind}`` for image/video/url;
+ *preview* (#191) is the OpenGraph card of the first URL in *text*.
"""
text = (text or "").strip()[:MAX_TEXT]
msg: dict[str, Any] = {
@@ -100,6 +115,8 @@ def add_message(
}
if attachment:
msg["attachment"] = attachment
+ if preview:
+ msg["preview"] = preview
doc = _read(vault, path)
return _append(vault, path, doc, msg)
@@ -120,6 +137,47 @@ def _append(
return msg
+# --- #191 : messages privés (2 utilisateurs) -------------------------------
+
+def dm_path(user_a: str, user_b: str) -> str:
+ """Storage path for the private conversation between two users.
+
+ The pair is sorted so both participants address the same document.
+ """
+ return "|".join(sorted([user_a, user_b]))
+
+
+def get_dm_messages(user_a: str, user_b: str) -> list[dict[str, Any]]:
+ """Return the private history between two users (chronological)."""
+ return get_messages(DM_VAULT, dm_path(user_a, user_b))
+
+
+def add_dm_message(
+ user_a: str,
+ user_b: str,
+ author: str,
+ text: str,
+ attachment: dict[str, Any] | None = None,
+ preview: dict[str, Any] | None = None,
+) -> dict[str, Any]:
+ """Append a private message. Returns the stored message."""
+ return add_message(DM_VAULT, dm_path(user_a, user_b), author, text, attachment, preview)
+
+
+# --- #191 : suppression -----------------------------------------------------
+
+def delete_message(vault: str, path: str, message_id: str) -> bool:
+ """Remove one message from a conversation. True when it existed."""
+ doc = _read(vault, path)
+ messages = list(doc.get("messages", []))
+ kept = [m for m in messages if m.get("id") != message_id]
+ if len(kept) == len(messages):
+ return False
+ doc["messages"] = kept
+ _write(_chat_file(vault, path), doc)
+ return True
+
+
# --- #190 : chat général (conversation centrale, hors fichier) -------------
def get_global_messages() -> list[dict[str, Any]]:
@@ -131,9 +189,10 @@ def add_global_message(
user: str,
text: str,
attachment: dict[str, Any] | None = None,
+ preview: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""Append a message to the general chat. Returns the stored message."""
- return add_message(GLOBAL_VAULT, GLOBAL_PATH, user, text, attachment)
+ return add_message(GLOBAL_VAULT, GLOBAL_PATH, user, text, attachment, preview)
def save_attachment(filename: str, data: bytes) -> dict[str, Any]:
@@ -166,6 +225,75 @@ def save_attachment(filename: str, data: bytes) -> dict[str, Any]:
}
+# --- #191 : link preview ----------------------------------------------------
+
+_URL_RE = re.compile(r"https?://[^\s<>\"']+")
+_PREVIEW_CACHE: dict[str, dict[str, Any] | None] = {}
+PREVIEW_CACHE_MAX = 200
+
+
+def _og(content: str, prop: str) -> str:
+ """Extract one OpenGraph/``
`` value from an HTML head (regex)."""
+ for pattern in (
+ rf']+(?:property|name)="{prop}"[^>]+content="([^"]*)"',
+ rf']+content="([^"]*)"[^>]+(?:property|name)="{prop}"',
+ ):
+ m = re.search(pattern, content, re.IGNORECASE)
+ if m:
+ return html.unescape(m.group(1)).strip()[:300]
+ if prop == "og:title":
+ m = re.search(r"]*>([^<]*)", content, re.IGNORECASE)
+ if m:
+ return html.unescape(m.group(1)).strip()[:300]
+ return ""
+
+
+def build_preview(text: str) -> dict[str, Any] | None:
+ """Fetch OpenGraph metadata for the first URL in *text* (#191).
+
+ SSRF-guarded (reuses the web-tool guard), size/time capped, cached in a
+ bounded dict. Returns ``{url, title, description, image, site}`` or
+ ``None`` when there is no URL / the fetch fails (never raises: a dead
+ link must not block the message).
+ """
+ m = _URL_RE.search(text or "")
+ if not m:
+ return None
+ url = m.group(0).rstrip(".,;:!?)")
+ if url in _PREVIEW_CACHE:
+ cached = _PREVIEW_CACHE[url]
+ return dict(cached) if cached else None
+ try:
+ from backend.tools.web import USER_AGENT, _assert_public_http_url
+
+ _assert_public_http_url(url)
+ resp = httpx.get(
+ url,
+ headers={"User-Agent": USER_AGENT, "Accept": "text/html,*/*"},
+ timeout=PREVIEW_TIMEOUT,
+ follow_redirects=True,
+ )
+ if resp.status_code >= 400:
+ raise ValueError(f"HTTP {resp.status_code}")
+ body = resp.text[:PREVIEW_MAX_BYTES]
+ preview = {
+ "url": url,
+ "title": _og(body, "og:title") or _og(body, "og:site_name"),
+ "description": _og(body, "og:description"),
+ "image": _og(body, "og:image"),
+ "site": _og(body, "og:site_name") or (url.split("/")[2] if "/" in url[8:] else url),
+ }
+ if not preview["title"]:
+ raise ValueError("pas de titre")
+ except Exception as e:
+ logger.debug("link preview failed for %s: %s", url, e)
+ preview = None
+ if len(_PREVIEW_CACHE) >= PREVIEW_CACHE_MAX:
+ _PREVIEW_CACHE.pop(next(iter(_PREVIEW_CACHE))) # oldest first (dict order)
+ _PREVIEW_CACHE[url] = preview
+ return dict(preview) if preview else None
+
+
def attachment_path(name: str) -> Path | None:
"""Resolve an attachment by its stored name (UUID+ext only, no traversal)."""
p = Path(name)
diff --git a/backend/routers/file_chat.py b/backend/routers/file_chat.py
index 6039086..b785960 100644
--- a/backend/routers/file_chat.py
+++ b/backend/routers/file_chat.py
@@ -22,8 +22,9 @@ from fastapi.responses import FileResponse
from backend import file_chat as _store
from backend.auth.middleware import check_vault_access, require_auth
+from backend.auth.user_store import get_all_users, get_user
from backend.indexer import get_vault_data
-from backend.schemas import ChatHistoryResponse, ChatMessageResponse
+from backend.schemas import ChatHistoryResponse, ChatMessageResponse, StatusResponse
from backend.services.paths import resolve_safe_path
from backend.sse import sse_manager
@@ -104,8 +105,10 @@ async def api_chat_post(
text = str(body.get("text") or "")
if not text.strip():
raise HTTPException(400, "text is required")
+ # #191 — best-effort link preview: a dead/slow URL never blocks the post.
msg = _store.add_global_message(
- current_user.get("username", ""), text, _attachment(body)
+ current_user.get("username", ""), text, _attachment(body),
+ _store.build_preview(text),
)
await sse_manager.broadcast(
"chat_message",
@@ -136,3 +139,105 @@ async def api_chat_attachment(name: str, current_user: dict[str, Any] = Depends(
raise HTTPException(404, "Attachment not found")
info = _store._MIME_BY_EXT.get(path.suffix.lower(), "application/octet-stream")
return FileResponse(str(path), media_type=info)
+
+
+# --- #191 : suppression + messages privés -----------------------------------
+
+def _owner_or_admin(msg_user: str, current_user: dict[str, Any]) -> None:
+ """A post may be deleted by its author or by an admin."""
+ if current_user.get("role") != "admin" and current_user.get("username") != msg_user:
+ raise HTTPException(403, "Seul l'auteur ou un administrateur peut supprimer ce message")
+
+
+def _find_and_authorize(vault: str, path: str, message_id: str, current_user: dict[str, Any]) -> dict[str, Any]:
+ """Locate *message_id* in the conversation and check the delete right."""
+ for m in _store.get_messages(vault, path):
+ if m.get("id") == message_id:
+ _owner_or_admin(m.get("user", ""), current_user)
+ return m
+ raise HTTPException(404, "Message not found")
+
+
+@router.delete("/api/chat/{message_id}", response_model=StatusResponse)
+async def api_chat_delete(message_id: str, current_user: dict[str, Any] = Depends(require_auth)):
+ """Delete a message from the general chat (author or admin, #191)."""
+ _find_and_authorize(_store.GLOBAL_VAULT, _store.GLOBAL_PATH, message_id, current_user)
+ if not _store.delete_message(_store.GLOBAL_VAULT, _store.GLOBAL_PATH, message_id):
+ raise HTTPException(404, "Message not found")
+ await sse_manager.broadcast(
+ "chat_deleted",
+ {"vault": _store.GLOBAL_VAULT, "path": _store.GLOBAL_PATH, "id": message_id},
+ )
+ return {"status": "deleted"}
+
+
+@router.get("/api/chat/users", response_model=list[dict[str, Any]])
+async def api_chat_users(current_user: dict[str, Any] = Depends(require_auth)):
+ """Usernames available for a private conversation (chat DM picker, #191).
+
+ Every authenticated member may see who else is around — this is a
+ self-hosted portal, not a directory that needs hiding.
+ """
+ me = current_user.get("username", "")
+ return [
+ {"username": u.get("username", ""), "display_name": u.get("display_name") or u.get("username", "")}
+ for u in get_all_users()
+ if u.get("username") and u.get("username") != me
+ ]
+
+
+def _dm_peer(username: str, current_user: dict[str, Any]) -> str:
+ """Validate the DM peer exists and is not ourselves."""
+ if not username or username == current_user.get("username"):
+ raise HTTPException(400, "Destinataire invalide")
+ if not get_user(username):
+ raise HTTPException(404, "Utilisateur inconnu")
+ return username
+
+
+@router.get("/api/chat/dm/{username}", response_model=ChatHistoryResponse)
+async def api_chat_dm_history(username: str, current_user: dict[str, Any] = Depends(require_auth)):
+ """Private history with *username* (#191)."""
+ peer = _dm_peer(username, current_user)
+ return {"messages": _store.get_dm_messages(current_user["username"], peer)}
+
+
+@router.post("/api/chat/dm/{username}", response_model=ChatMessageResponse)
+async def api_chat_dm_post(
+ username: str,
+ body: dict[str, Any] = Body(...),
+ current_user: dict[str, Any] = Depends(require_auth),
+):
+ """Post a private message and broadcast it to both participants (#191)."""
+ peer = _dm_peer(username, current_user)
+ text = str(body.get("text") or "")
+ if not text.strip():
+ raise HTTPException(400, "text is required")
+ msg = _store.add_dm_message(
+ current_user["username"], peer, current_user.get("username", ""), text,
+ _attachment(body), _store.build_preview(text),
+ )
+ # Same shape as the general chat so the client routes on vault/path.
+ await sse_manager.broadcast(
+ "chat_message",
+ {"vault": _store.DM_VAULT, "path": _store.dm_path(current_user["username"], peer), "message": msg},
+ )
+ return {"message": msg, "status": "ok"}
+
+
+@router.delete("/api/chat/dm/{username}/{message_id}", response_model=StatusResponse)
+async def api_chat_dm_delete(
+ username: str,
+ message_id: str,
+ current_user: dict[str, Any] = Depends(require_auth),
+):
+ """Delete one private message (author or admin, #191)."""
+ peer = _dm_peer(username, current_user)
+ vault, path = _store.DM_VAULT, _store.dm_path(current_user["username"], peer)
+ _find_and_authorize(vault, path, message_id, current_user)
+ if not _store.delete_message(vault, path, message_id):
+ raise HTTPException(404, "Message not found")
+ await sse_manager.broadcast(
+ "chat_deleted", {"vault": vault, "path": path, "id": message_id}
+ )
+ return {"status": "deleted"}
diff --git a/backend/schemas.py b/backend/schemas.py
index 942561d..98c7c91 100644
--- a/backend/schemas.py
+++ b/backend/schemas.py
@@ -162,6 +162,10 @@ class ChatMessageItem(BaseModel):
default=None,
description="Optional image/video/url attachment {name, url, mime, kind}",
)
+ preview: dict[str, Any] | None = Field(
+ default=None,
+ description="Link preview card {url, title, description, image, site} (#191)",
+ )
class ChatHistoryResponse(BaseModel):
diff --git a/desktop/Cargo.lock b/desktop/Cargo.lock
index e780a0b..b96ab3c 100644
--- a/desktop/Cargo.lock
+++ b/desktop/Cargo.lock
@@ -2626,7 +2626,7 @@ dependencies = [
[[package]]
name = "obsigate-desktop"
-version = "2.58.0"
+version = "2.60.0"
dependencies = [
"chrono",
"env_logger",
diff --git a/desktop/Cargo.toml b/desktop/Cargo.toml
index b25a35f..6be4377 100644
--- a/desktop/Cargo.toml
+++ b/desktop/Cargo.toml
@@ -1,6 +1,6 @@
[package]
name = "obsigate-desktop"
-version = "2.58.0"
+version = "2.60.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 4b916dd..c32669d 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.58.0",
+ "version": "2.60.0",
"identifier": "com.obsigate.desktop",
"build": {
"frontendDist": "../frontend",
diff --git a/docs/GUIDES/PRISE_EN_MAIN.md b/docs/GUIDES/PRISE_EN_MAIN.md
index e54695f..d1a03f2 100644
--- a/docs/GUIDES/PRISE_EN_MAIN.md
+++ b/docs/GUIDES/PRISE_EN_MAIN.md
@@ -194,6 +194,14 @@ des **onglets** (avec possibilité de vue multi-panneaux / split view).
- **📎** attache une image ou une vidéo (25 MB max) ; les URL sont cliquables.
- La **barre de filtre** sert à chercher dans le chat (texte ou auteur) quand
cet onglet est actif.
+- **🗑** (survol d'un message) : supprime votre message — un administrateur
+ peut supprimer celui de n'importe qui. Confirmation demandée, le retrait
+ est immédiat pour tout le monde.
+- **Messages privés** : la rangée de pastilles au-dessus du fil propose
+ **Général** puis chaque utilisateur ; choisissez un destinataire pour une
+ conversation à deux, avec son propre compteur de non-lus.
+- **Link preview** : coller une URL suffit — titre, description, image et
+ site s'affichent en carte cliquable sous le message (si le site répond).
---
diff --git a/docs/ISSUES_TODOLIST.md b/docs/ISSUES_TODOLIST.md
index 59f4aa4..6f8c3cd 100644
--- a/docs/ISSUES_TODOLIST.md
+++ b/docs/ISSUES_TODOLIST.md
@@ -329,6 +329,7 @@ Avant de corriger quoi que ce soit, un agent IA doit :
| 2026-10-02 | BUG-107 | Correction | `desktop/tauri.conf.json`, `desktop/src/main.rs` | **BUG-107 — drag & drop de fichiers inutilisable sur desktop** : Tauri/wry installe son propre `IDropTarget` par-dessus de celui du WebView2 (source wry : « Enumerate child windows to find the WebView2 window and override! ») et aucun événement natif n'était écouté → les glisser-déposer depuis l'Explorateur n'atteignaient jamais les gestionnaires HTML5 de `dragdrop.js` (#89 marchait donc uniquement sur le web). Correctif : la doc Tauri est explicite — `disable_drag_drop_handler()` « is required to use HTML5 drag and drop APIs on the frontend on Windows » ; la fenêtre est créée en code (`create: false` dans `tauri.conf.json`, boucle Tauri qui saute les fenêtres non auto-créées) via `WebviewWindowBuilder::from_config(...).disable_drag_drop_handler()`. Vérifié : `cargo test` 26 passed (nouveau garde-fou `test_window_created_without_tauri_drag_drop_handler` : les deux marqueurs obligatoires), lancement de l'app OK (fenêtre unique, boot log normal) ; glisser-déposer réel à valider par l'utilisateur. | 🟢 corrigé |
| 2026-10-07 | BUG-108 | Correction | `frontend/js/xlsx/context-menu.js` | **BUG-108 — « Coller » du menu contextuel du tableur muet** : `closeContextMenu()` faisait `menu.remove()` **avant** `_menu = null` ; retirer le menu portant le focus émet `focusout` synchrone qui rappelle `closeContextMenu()`, le second `remove()` sur un nœud démonté lève `NotFoundError` et avorte le handler de l'action avant son exécution (introduit par la fermeture au focus #179). Reproduit par l'E2E `xlsx-viewer.spec.js` « coller une plage » (CI #850→#854). Correctif : déférencer avant de retirer. | 🟢 corrigé |
| 2026-10-08 | #189 | Fonctionnalité | `frontend/js/viewer.js`, `frontend/style.css`, `frontend/locales/{fr,en}.json`, `tests/frontend/xlsx-viewer.test.mjs`, `tests/e2e/xlsx-viewer.spec.js`, `docs/ROADMAP.md`, `docs/features/xlsx-mobile-layout-189.md`, `docs/GUIDES/RECHERCHE_PDF_EXCALIDRAW.md`, `README.md`, `README.fr.md`, `CHANGELOG.md` | **#189 — mise en page mobile du tableur** : sur ≤ 768 px les barres menus/ruban/Recherche sont repliées par défaut (la barre des feuilles + barre formule fx restent visibles, la grille récupère la hauteur perdue), bascule unique `#xlsx-tools-toggle` (☰, `aria-expanded`), cibles tactiles ≥ 44 px, groupes du ruban qui s'enroulent, onglets de feuilles défilants, inputs 16 px (anti-zoom iOS). E2E mobile ajouté + helper `openXlsx()` (déplie le ruban). 10 échecs E2E mobile **préexistants** prouvés par `git stash` (hors périmètre, CI ne lance que desktop). Vérifié : xlsx-viewer.test.mjs 165/165, E2E desktop 19 passed/1 skipped, suite 1628 passed, ruff/mypy 0, CI run #1937 success | ✅ livré (en attente vérif utilisateur) |
+| 2026-10-08 | #191 | Fonctionnalité | `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` | **#191 — chat : suppression de post, messages privés, boîte compacte, link preview** : (A) `DELETE /api/chat/{id}` + `/api/chat/dm/{peer}/{id}` (auteur ou admin, 403 sinon) avec diffusion SSE `chat_deleted` et bouton 🗑 au survol côté client (`confirm()` natif) ; (B) DM 2 utilisateurs : vault `__dm__` + `dm_path(a,b)` trié, `GET /api/chat/users` (destinataires sans soi-même, aucun hash exposé), routes `GET/POST /api/chat/dm/{user}` (404 inconnu, 400 DM à soi), rangée de canaux `.chat-channels` dans la sidebar, non-lus par canal (compteurs localStorage, anciens timestamps/compteurs mélangés unifiés) ; (C) bouton d'envoi réduit à une icône dans les deux formulaires ; (D) `build_preview(text)` : 1ʳᵉ URL → garde SSRF `_assert_public_http_url` → `httpx.get` 5 s/512 Ko → OG (titre/desc/image/site) en carte cliquable, best-effort (échec → message sans carte), cache 200 entrées. Bug trouvé en cours de route : `max_redirects` n'existe pas sur `httpx.get` → TypeError avalé par le `try` = previews jamais générées en prod, invisible des tests (ils mockaient `build_preview`) ; arg retiré + `test_build_preview_happy_path_parses_og` (httpx mocké) ajouté. Tests : pytest 45 file_chat (44→45, +13 sur #191), JSDOM filechat 16 (+5) |
| 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) |
diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md
index c2eb7c2..e75959e 100644
--- a/docs/ROADMAP.md
+++ b/docs/ROADMAP.md
@@ -1,6 +1,6 @@
# ObsiGate — Roadmap
-> **Version :** 2.58.0 | **Dernière mise à jour :** 2026-10-08
+> **Version :** 2.60.0 | **Dernière mise à jour :** 2026-10-08
> **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)**
@@ -731,7 +731,8 @@
| 188 | Secrets — détection universelle des clés API & mots de passe, masque cliquable (clic = copie) | Unreleased | [features/secret-mask-188.md](./features/secret-mask-188.md) |
| 189 | Tableur — mise en page mobile : barres repliées par défaut, bascule unique, cibles tactiles 44 px | 2.56.0 | [features/xlsx-mobile-layout-189.md](./features/xlsx-mobile-layout-189.md) |
| 169 | Chat intégré par fichier (panneau latéral, historique, temps réel SSE, toast de notification) | 2.57.0 | [features/file-chat-169.md](./features/file-chat-169.md) |
-| 190 | Chat — suivi du document, chat général en onglet sidebar (pièces jointes, non lus, recherche), onglets au-dessus du filtre | Unreleased | [features/file-chat-169.md](./features/file-chat-169.md) |
+| 190 | Chat — suivi du document, chat général en onglet sidebar (pièces jointes, non lus, recherche), onglets au-dessus du filtre | 2.58.0 | [features/file-chat-169.md](./features/file-chat-169.md) |
+| 191 | Chat — suppression de post, messages privés 2 utilisateurs (canaux), boîte d'édition compacte, link preview OG | 2.60.0 | [features/file-chat-169.md](./features/file-chat-169.md) |
| 159 | Desktop — gestion des vaults & dossiers : retrait par menu contextuel + section Configuration (ajout vault/dossier racine) | 2.50.0 | [features/desktop-tauri.md](./features/desktop-tauri.md) |
| 160 | Desktop — premier lancement professionnel (répertoire `%USERPROFILE%\ObsiGate` + `Prise en main.md`) et section Configuration harmonisée | 2.51.0 | [features/desktop-tauri.md](./features/desktop-tauri.md) |
| BUG-047 | Versionnage — source unique `VERSION` + bump SemVer automatique au commit (hooks + tag) | 2.3.0 | [DEVELOPMENT_AND_RELEASES.md](./DEVELOPMENT_AND_RELEASES.md) |
diff --git a/docs/features/file-chat-169.md b/docs/features/file-chat-169.md
index 5725b2f..cc5ac6f 100644
--- a/docs/features/file-chat-169.md
+++ b/docs/features/file-chat-169.md
@@ -90,6 +90,67 @@
- `initSidebarFilter` (`sidebar.js`) : `routeFilter`/`routeClear` routent
vers `filterChatMessages()` (texte **et** auteur, insensible à la casse).
+## #191 — Suppression, messages privés, boîte compacte, link preview (2026-10-08)
+
+### A. Suppression d'un post
+
+- `DELETE /api/chat/{id}` et `DELETE /api/chat/dm/{peer}/{id}` :
+ **auteur du message ou admin** (403 sinon), 404 si l'id n'existe pas.
+- Store : `delete_message(vault, path, id)` — écriture atomique,
+ `False` si absent ; broadcast SSE `chat_deleted` `{vault, path, id}`.
+- Frontend : bouton 🗑 (`.file-chat-del`, visible au survol) rendu dans
+ `_messageEl` uniquement si l'URL de suppression est fournie **et**
+ que l'utilisateur est auteur/admin ; `confirm()` natif ; retrait local
+ + retrait en direct chez tous via `onChatDeleted` (relais `sync.js`).
+
+### B. Messages privés 2 utilisateurs
+
+- Store : `DM_VAULT = "__dm__"`, `dm_path(a, b)` = paire **triée** → les
+ deux participants lisent/écrivent le même document, indépendamment de
+ l'ordre.
+- Routes : `GET /api/chat/users` (destinataires — usernames +
+ display_names, **sans soi-même**, aucun hash/mot de passe exposé),
+ `GET/POST /api/chat/dm/{username}` (404 utilisateur inconnu, 400 DM à
+ soi), `DELETE /api/chat/dm/{username}/{id}`.
+- Frontend : rangée de pastilles `.chat-channels` (« Général » + un
+ bouton par utilisateur) au-dessus du fil ; `_channel` état courant,
+ `_channelKey`/`_channelUrl`/`_channelDelUrl` ; placeholder dédié
+ `chat.placeholder_dm` ; non-lus **par canal** (compteurs dans
+ `localStorage`, l'ancien mélange timestamp/compteur est devenu un
+ compteur unique `{"general": n, "dm:x": n}`, remis à zéro à l'ouverture,
+ pastille = total) ; les DM SSE routés par `_pairOf(path)`.
+
+### C. Boîte d'édition compacte
+
+- Bouton d'envoi réduit à une icône `send` (`.file-chat-send-icon`) dans
+ les deux formulaires (panneau document + sidebar) ; la saisie garde
+ toute la largeur. `safeCreateIcons()` à la fin du `_renderShell`.
+
+### D. Link preview
+
+- `build_preview(text)` (store) : 1ʳᵉ URL du texte → garde SSRF
+ `_assert_public_http_url` (host privé/loopback rejeté, schémas non
+ http refusés) → `httpx.get` 5 s, 512 Ko, `follow_redirects` (limite
+ httpx = 20) → parse OG par regex (`og:title` avec repli ``,
+ `og:description`, `og:image`, `og:site_name`) →
+ `{url, title, description, image, site}`.
+- **Best-effort** : n'importe quelle erreur (URL morte, timeout, garde)
+ → `None`, le message passe quand même ; cache borné **200 entrées**.
+- Frontend : `_previewEl` rend une carte cliquable (vignette lazy
+ `no-referrer`, site en capitales, titre/description tronqués 2 lignes)
+ sous le corps du message.
+- Schéma : `ChatMessageItem.preview`.
+
+### Tests #191
+
+- pytest `TestDelete` (3), `TestPrivateChat` (8), `TestLinkPreview` (6,
+ dont le happy path avec `httpx.get` mocké — un `max_redirects`
+ inexistante sur `httpx.get` levait un TypeError silencieusement avalé
+ par le `try`, testé par `test_build_preview_happy_path_parses_og`).
+- JSDOM `filechat.test.mjs` +5 : carte preview, bouton supprimer
+ (droit auteur/admin), `onChatDeleted`, compteurs non-lus,
+ routage DM isolé du général (16 au total).
+
## Décisions
- **SSE plutôt qu'un second WebSocket** : le transport de #62 (EventSource
diff --git a/frontend/index.html b/frontend/index.html
index 9a522f9..6a1b2ff 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -1072,6 +1072,13 @@
role="tabpanel"
aria-labelledby="sidebar-tab-chat"
>
+
+
diff --git a/frontend/js/filechat.js b/frontend/js/filechat.js
index 6dc285c..7797e59 100644
--- a/frontend/js/filechat.js
+++ b/frontend/js/filechat.js
@@ -12,20 +12,23 @@
import { api, AuthManager } from "./auth.js";
import { t } from "./i18n.js";
import { showToast } from "./ui.js";
-import { escapeHtml } from "./utils.js";
+import { escapeHtml, safeCreateIcons } from "./utils.js";
const PANEL_ID = "file-chat-panel";
const GLOBAL_VAULT = "__global__";
-const UNREAD_KEY = "obsigate-chat-unread"; // {general: ts}
+const DM_VAULT = "__dm__";
+const UNREAD_KEY = "obsigate-chat-unread"; // {general: ts, "dm:": ts}
let _vault = null;
let _path = null;
let _title = "";
let _sending = false;
-// État du chat général (onglet sidebar)
-let _generalMessages = [];
-let _generalLoaded = false;
+// État du chat sidebar (#190 B + #191 : canaux général / privé)
+let _channel = { type: "general" }; // ou {type: "dm", user: "bob"}
+let _channelMessages = [];
+let _channelLoaded = false;
+let _usersCache = null; // liste des destinataires DM (une seule requête)
let _pendingAttach = null;
// ---------------------------------------------------------------------------
@@ -90,7 +93,65 @@ function _attachEl(att) {
return null;
}
-export function _messageEl(m, currentUser) {
+// #191 — suppression : auteur ou admin, avec confirmation native.
+function _canDelete(m, currentUser) {
+ const u = AuthManager.getUser();
+ if (!u || !u.username) return false;
+ 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");
+ }
+}
+
+// #191 — carte de lien : site, titre, description, vignette.
+function _previewEl(p) {
+ const card = document.createElement("a");
+ card.className = "file-chat-preview";
+ card.href = p.url;
+ card.target = "_blank";
+ card.rel = "noopener noreferrer";
+ if (p.image) {
+ const img = document.createElement("img");
+ img.className = "file-chat-preview-img";
+ img.src = p.image;
+ img.alt = "";
+ img.loading = "lazy";
+ img.referrerPolicy = "no-referrer";
+ card.appendChild(img);
+ }
+ const meta = document.createElement("div");
+ meta.className = "file-chat-preview-meta";
+ const site = document.createElement("div");
+ site.className = "file-chat-preview-site";
+ site.textContent = p.site || "";
+ const title = document.createElement("div");
+ title.className = "file-chat-preview-title";
+ title.textContent = p.title || p.url;
+ meta.appendChild(site);
+ meta.appendChild(title);
+ if (p.description) {
+ const desc = document.createElement("div");
+ desc.className = "file-chat-preview-desc";
+ desc.textContent = p.description;
+ meta.appendChild(desc);
+ }
+ card.appendChild(meta);
+ return card;
+}
+
+/**
+ * Render one message.
+ * *delUrl* (#191) is the DELETE endpoint base for this conversation; when
+ * provided the author/admin sees a trash button on their own messages.
+ */
+export function _messageEl(m, currentUser, delUrl) {
const mine = currentUser && m.user === currentUser;
const wrap = document.createElement("div");
wrap.className = "file-chat-msg" + (mine ? " mine" : "");
@@ -100,6 +161,16 @@ export function _messageEl(m, currentUser) {
meta.innerHTML =
`${_esc(m.user)}` +
`${_esc(_formatTime(m.ts))}`;
+ if (delUrl && _canDelete(m, currentUser)) {
+ const del = document.createElement("button");
+ del.type = "button";
+ del.className = "file-chat-del";
+ del.title = t("chat.delete");
+ del.setAttribute("aria-label", t("chat.delete"));
+ del.textContent = "🗑";
+ del.addEventListener("click", () => _deleteMessage(delUrl, m.id, wrap));
+ meta.appendChild(del);
+ }
const body = document.createElement("div");
body.className = "file-chat-body";
_fillBody(body, m.text);
@@ -107,10 +178,11 @@ export function _messageEl(m, currentUser) {
if (m.text) wrap.appendChild(body);
const att = _attachEl(m.attachment);
if (att) wrap.appendChild(att);
+ if (m.preview) wrap.appendChild(_previewEl(m.preview));
return wrap;
}
-export function renderChatMessages(container, messages, currentUser) {
+export function renderChatMessages(container, messages, currentUser, delUrl) {
container.innerHTML = "";
if (!messages || messages.length === 0) {
const empty = document.createElement("div");
@@ -119,16 +191,16 @@ export function renderChatMessages(container, messages, currentUser) {
container.appendChild(empty);
return;
}
- messages.forEach((m) => container.appendChild(_messageEl(m, currentUser)));
+ messages.forEach((m) => container.appendChild(_messageEl(m, currentUser, delUrl)));
container.scrollTop = container.scrollHeight;
}
-function _appendMessage(list, m) {
+function _appendMessage(list, m, delUrl) {
const empty = list.querySelector(".file-chat-empty");
if (empty) empty.remove();
const id = m.id;
if (id && list.querySelector(`[data-id="${CSS.escape(id)}"]`)) return false;
- list.appendChild(_messageEl(m, _currentUser()));
+ list.appendChild(_messageEl(m, _currentUser(), delUrl));
list.scrollTop = list.scrollHeight;
return true;
}
@@ -203,8 +275,11 @@ function _renderShell(panel) {
input.setAttribute("aria-label", t("chat.placeholder"));
const send = document.createElement("button");
send.type = "submit";
- send.className = "file-chat-send";
- send.textContent = t("chat.send");
+ send.className = "file-chat-send file-chat-send-icon";
+ send.title = t("chat.send");
+ send.setAttribute("aria-label", t("chat.send"));
+ // #191 : icône compacte — la saisie garde toute la largeur
+ send.innerHTML = '';
form.appendChild(input);
form.appendChild(send);
form.addEventListener("submit", (e) => {
@@ -215,6 +290,7 @@ function _renderShell(panel) {
panel.appendChild(header);
panel.appendChild(list);
panel.appendChild(form);
+ safeCreateIcons(); // #191 — hydrate l'icône d'envoi
}
export async function openFileChat(vault, path, title) {
@@ -272,21 +348,76 @@ async function _send(input) {
}
// ---------------------------------------------------------------------------
-// 2. Chat général, onglet sidebar (#190 B)
+// 2. Chat sidebar (#190 B) + canaux privé #191
// ---------------------------------------------------------------------------
function _sidebarList() {
return document.getElementById("sidebar-panel-chat-list");
}
-/** Charge (ou recharge) l'historique du chat général dans la sidebar. */
+/** Clé de la conversation courante (unread + sélection). */
+function _channelKey() {
+ return _channel.type === "dm" ? `dm:${_channel.user}` : "general";
+}
+
+/** URL d'API de base du canal courant. */
+function _channelUrl() {
+ return _channel.type === "dm"
+ ? `/api/chat/dm/${encodeURIComponent(_channel.user)}`
+ : "/api/chat";
+}
+
+/** URL de base de suppression du canal courant. */
+function _channelDelUrl() {
+ return _channel.type === "dm"
+ ? `/api/chat/dm/${encodeURIComponent(_channel.user)}`
+ : "/api/chat";
+}
+
+/** Rend la rangée de canaux : Général + un bouton par utilisateur. */
+function _renderChannels(users) {
+ const holder = document.getElementById("sidebar-panel-chat-channels");
+ if (!holder) return;
+ holder.innerHTML = "";
+ const mk = (label, active, onClick, title) => {
+ const b = document.createElement("button");
+ b.type = "button";
+ b.className = "chat-channel" + (active ? " active" : "");
+ b.textContent = label;
+ if (title) b.title = title;
+ b.addEventListener("click", onClick);
+ holder.appendChild(b);
+ return b;
+ };
+ mk(t("chat.channel_general"), _channel.type === "general", () => switchChannel(null));
+ (users || []).forEach((u) => {
+ mk(u.display_name || u.username, _channel.type === "dm" && _channel.user === u.username,
+ () => switchChannel(u.username), `@${u.username}`);
+ });
+}
+
+/** Charge la liste des utilisateurs (une fois) puis rend les canaux. */
+async function _loadUsers() {
+ if (_usersCache) return _usersCache;
+ try {
+ const data = await api("/api/chat/users");
+ _usersCache = Array.isArray(data) ? data : [];
+ } catch (_) {
+ _usersCache = [];
+ }
+ return _usersCache;
+}
+
+/** Charge (ou recharge) l'historique du canal courant dans la sidebar. */
export async function openSidebarChat() {
const list = _sidebarList();
if (!list) return;
+ const users = await _loadUsers();
+ _renderChannels(users);
try {
- const data = await api("/api/chat");
- _generalMessages = (data.messages || []).slice();
- _generalLoaded = true;
- renderChatMessages(list, _generalMessages, _currentUser());
+ const data = await api(_channelUrl());
+ _channelMessages = (data.messages || []).slice();
+ _channelLoaded = true;
+ renderChatMessages(list, _channelMessages, _currentUser(), _channelDelUrl());
_clearUnread();
} catch (_) {
list.innerHTML = "";
@@ -297,17 +428,27 @@ export async function openSidebarChat() {
}
}
-/** Recherche dans le chat général (barre de filtre sidebar) — #190 C. */
+/** Change de canal (#191) : null = général, sinon l'username du pair. */
+export function switchChannel(user) {
+ _channel = user ? { type: "dm", user } : { type: "general" };
+ const input = _sendInput();
+ if (input) {
+ input.placeholder = user ? t("chat.placeholder_dm", { user }) : t("chat.placeholder");
+ }
+ openSidebarChat();
+}
+
+/** Recherche dans le canal courant (barre de filtre sidebar) — #190 C. */
export function filterChatMessages(query) {
const list = _sidebarList();
- if (!list || !_generalLoaded) return;
+ if (!list || !_channelLoaded) return;
const q = String(query || "").toLowerCase().trim();
const shown = q
- ? _generalMessages.filter((m) =>
+ ? _channelMessages.filter((m) =>
((m.text || "") + " " + (m.user || "")).toLowerCase().includes(q),
)
- : _generalMessages;
- renderChatMessages(list, shown, _currentUser());
+ : _channelMessages;
+ renderChatMessages(list, shown, _currentUser(), _channelDelUrl());
}
function _sendInput() {
@@ -344,14 +485,14 @@ export async function sendGeneralMessage() {
const body = { text };
if (_pendingAttach) body.attachment = _pendingAttach;
try {
- const data = await api("/api/chat", { method: "POST", body: JSON.stringify(body) });
+ const data = await api(_channelUrl(), { method: "POST", body: JSON.stringify(body) });
input.value = "";
_pendingAttach = null;
_renderPending();
const list = _sidebarList();
if (list) {
- _generalMessages.push(data.message);
- _appendMessage(list, data.message);
+ _channelMessages.push(data.message);
+ _appendMessage(list, data.message, _channelDelUrl());
}
input.focus();
} catch (_) {
@@ -387,7 +528,9 @@ export async function pickAttachment() {
});
}
-// --- Messages non lus (#190 B) --------------------------------------------
+// --- Messages non lus (#190 B / #191) --------------------------------------
+// Compteurs par clé de canal : {"general": 2, "dm:bob": 1}. Ouverture du
+// canal → remise à zéro de sa clé.
function _readUnread() {
try {
return JSON.parse(localStorage.getItem(UNREAD_KEY) || "{}") || {};
@@ -396,13 +539,23 @@ function _readUnread() {
}
}
-function _clearUnread() {
- const u = _readUnread();
- u.general = Math.floor(Date.now() / 1000);
+function _writeUnread(u) {
try {
localStorage.setItem(UNREAD_KEY, JSON.stringify(u));
} catch (_) { /* quota */ }
- _renderBadge(0);
+}
+
+function _clearUnread() {
+ const u = _readUnread();
+ u[_channelKey()] = 0;
+ _writeUnread(u);
+ _renderBadge(_totalUnread());
+ _renderChannels(_usersCache);
+}
+
+function _totalUnread() {
+ const u = _readUnread();
+ return Object.values(u).reduce((n, v) => n + (typeof v === "number" ? v : 0), 0);
}
function _renderBadge(count) {
@@ -421,13 +574,9 @@ function _renderBadge(count) {
badge.textContent = count > 99 ? "99+" : String(count);
}
-/** Compte les messages reçus depuis la dernière visite. */
-export function refreshUnreadBadge(messages) {
- const list = messages || _generalMessages;
- if (!list.length) return _renderBadge(0);
- const seen = _readUnread().general || 0;
- const count = list.filter((m) => (m.ts || 0) > seen && m.user !== _currentUser()).length;
- _renderBadge(count);
+/** Affiche le total des messages non lus (tous canaux confondus). */
+export function refreshUnreadBadge() {
+ _renderBadge(_totalUnread());
}
/** Initialisation : formulaire + raccourcis clavier. */
@@ -456,18 +605,20 @@ export function initSidebarChat() {
// ---------------------------------------------------------------------------
export function onChatMessage(data) {
if (!data || !data.message) return;
- const isGeneral = data.vault === GLOBAL_VAULT;
- if (isGeneral) {
- _generalMessages.push(data.message);
- const list = _sidebarList();
+ // --- Canaux sidebar : général (#190) ou privé (#191) ---
+ if (data.vault === GLOBAL_VAULT || data.vault === DM_VAULT) {
+ const key = data.vault === DM_VAULT ? `dm:${_pairOf(data.path)}` : "general";
+ const isActive = key === _channelKey();
const panel = document.getElementById("sidebar-panel-chat");
const visible = panel && panel.classList.contains("active");
- if (list && visible) {
- _appendMessage(list, data.message);
+ if (isActive) _channelMessages.push(data.message);
+ if (visible && isActive) {
+ _appendMessage(_sidebarList(), data.message, _channelDelUrl());
_clearUnread();
} else if (data.message.user !== _currentUser()) {
- refreshUnreadBadge(_generalMessages);
+ _bumpUnread(key);
+ refreshUnreadBadge();
showToast(t("chat.new_message", { user: data.message.user }), "info");
}
return;
@@ -486,3 +637,23 @@ export function onChatMessage(data) {
if (data.message.user === _currentUser()) return; // son propre message
showToast(t("chat.new_message", { user: data.message.user }), "info");
}
+
+/** Le path DM est « alice|bob » trié : on en déduit l'interlocuteur. */
+function _pairOf(path) {
+ const me = _currentUser();
+ const parts = String(path || "").split("|");
+ return parts[0] === me ? parts[1] : parts[0];
+}
+
+function _bumpUnread(key) {
+ const u = _readUnread();
+ u[key] = (u[key] || 0) + 1;
+ _writeUnread(u);
+}
+
+/** Suppression d'un post (#191) — relayée par sync.js. */
+export function onChatDeleted(data) {
+ if (!data || !data.id) return;
+ const sel = `[data-id="${CSS.escape(data.id)}"]`;
+ document.querySelectorAll(sel).forEach((el) => el.remove());
+}
diff --git a/frontend/js/sync.js b/frontend/js/sync.js
index b2810ac..bf5109b 100644
--- a/frontend/js/sync.js
+++ b/frontend/js/sync.js
@@ -120,7 +120,7 @@ export const IndexUpdateManager = (() => {
}
});
- // #169 — Chat par fichier : relais du broadcast SSE vers le panneau
+ // #169/#190 — Chat : relais des broadcasts SSE vers les panneaux
eventSource.addEventListener("chat_message", (e) => {
try {
const data = JSON.parse(e.data);
@@ -130,6 +130,16 @@ export const IndexUpdateManager = (() => {
}
});
+ // #191 — suppression d'un post
+ eventSource.addEventListener("chat_deleted", (e) => {
+ try {
+ const data = JSON.parse(e.data);
+ import("./filechat.js").then((m) => m.onChatDeleted(data)).catch(() => {});
+ } catch (err) {
+ console.error("SSE parse error:", err);
+ }
+ });
+
eventSource.onerror = () => {
connectionState = "disconnected";
_updateBadge();
diff --git a/frontend/locales/en.json b/frontend/locales/en.json
index c2b7418..886dc47 100644
--- a/frontend/locales/en.json
+++ b/frontend/locales/en.json
@@ -320,6 +320,10 @@
"backups.title": "Backups",
"button.cancel": "Cancel",
"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.attach_remove": "Remove attachment",
"chat.button": "Chat",
"chat.button_title": "Discuss this file",
@@ -328,6 +332,7 @@
"chat.load_error": "Could not load the discussion",
"chat.new_message": "New message from {user}",
"chat.placeholder": "Write a message…",
+ "chat.placeholder_dm": "Private message to {user}…",
"chat.send": "Send",
"chat.send_error": "Failed to send the message",
"chat.tab": "General chat",
diff --git a/frontend/locales/fr.json b/frontend/locales/fr.json
index 411d7b5..5297a63 100644
--- a/frontend/locales/fr.json
+++ b/frontend/locales/fr.json
@@ -320,6 +320,10 @@
"backups.title": "Backups",
"button.cancel": "Annuler",
"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.attach_remove": "Retirer la pièce jointe",
"chat.button": "Chat",
"chat.button_title": "Discuter de ce fichier",
@@ -328,6 +332,7 @@
"chat.load_error": "Impossible de charger la discussion",
"chat.new_message": "Nouveau message de {user}",
"chat.placeholder": "Écrire un message…",
+ "chat.placeholder_dm": "Message privé à {user}…",
"chat.send": "Envoyer",
"chat.send_error": "Échec de l'envoi du message",
"chat.tab": "Chat général",
diff --git a/frontend/style.css b/frontend/style.css
index 09181aa..4776b47 100644
--- a/frontend/style.css
+++ b/frontend/style.css
@@ -13887,6 +13887,116 @@ body.reading-mode .file-actions {
}
}
+/* ---------------------------------------------------------------------------
+ #191 — Chat : canaux, suppression, bouton compact, link preview
+--------------------------------------------------------------------------- */
+.chat-channels {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 6px;
+ padding: 8px 10px 0;
+}
+.chat-channel {
+ border: 1px solid var(--border, #21262d);
+ border-radius: 999px;
+ background: var(--bg-secondary, #161b22);
+ color: var(--text-muted, #8b949e);
+ font-size: 0.78rem;
+ padding: 4px 10px;
+ cursor: pointer;
+ max-width: 100%;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+.chat-channel:hover {
+ color: var(--text-primary, #e6edf3);
+ border-color: var(--accent, #58a6ff);
+}
+.chat-channel.active {
+ background: var(--accent, #58a6ff);
+ border-color: var(--accent, #58a6ff);
+ color: #fff;
+}
+/* Bouton d'envoi compact : une icône, la saisie garde la largeur */
+.file-chat-send-icon {
+ padding: 6px 8px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+/* Suppression d'un post */
+.file-chat-del {
+ border: none;
+ background: transparent;
+ cursor: pointer;
+ font-size: 0.75rem;
+ padding: 0 2px;
+ opacity: 0;
+ transition: opacity 0.12s;
+}
+.file-chat-msg:hover .file-chat-del {
+ opacity: 0.7;
+}
+.file-chat-del:hover {
+ opacity: 1 !important;
+}
+/* Link preview (#191) */
+.file-chat-preview {
+ display: flex;
+ gap: 10px;
+ margin-top: 8px;
+ padding: 8px;
+ border: 1px solid var(--border, #21262d);
+ border-left: 3px solid var(--accent, #58a6ff);
+ border-radius: 8px;
+ background: var(--bg-secondary, #161b22);
+ text-decoration: none;
+ color: inherit;
+ max-width: 100%;
+}
+.file-chat-preview:hover {
+ border-color: var(--accent, #58a6ff);
+}
+.file-chat-preview-img {
+ width: 72px;
+ height: 72px;
+ object-fit: cover;
+ border-radius: 6px;
+ flex-shrink: 0;
+ background: var(--bg-hover, rgba(128, 128, 128, 0.12));
+}
+.file-chat-preview-meta {
+ min-width: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+}
+.file-chat-preview-site {
+ font-size: 0.68rem;
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+ color: var(--accent, #58a6ff);
+}
+.file-chat-preview-title {
+ font-size: 0.85rem;
+ font-weight: 600;
+ color: var(--text-primary, #e6edf3);
+ overflow: hidden;
+ display: -webkit-box;
+ -webkit-line-clamp: 2;
+ -webkit-box-orient: vertical;
+}
+.file-chat-preview-desc {
+ font-size: 0.75rem;
+ color: var(--text-muted, #8b949e);
+ overflow: hidden;
+ display: -webkit-box;
+ -webkit-line-clamp: 2;
+ -webkit-box-orient: vertical;
+}
+
+
/* ---------------------------------------------------------------------------
#190 — Chat : en-tête ciblé, onglet sidebar, pièces jointes, badge
--------------------------------------------------------------------------- */
diff --git a/package.json b/package.json
index 726ca0b..9bd38ef 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "obsigate",
- "version": "2.58.0",
+ "version": "2.60.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 a315c32..2522387 100644
--- a/tests/frontend/filechat.test.mjs
+++ b/tests/frontend/filechat.test.mjs
@@ -69,10 +69,15 @@ globalThis.fetch = (url) => {
const { initI18n } = await import(pathToFileURL(path.join(JS_DIR, "i18n.js")).href);
await initI18n();
+// #191 — the delete right needs a logged-in identity
+const { AuthManager } = await import(pathToFileURL(path.join(JS_DIR, "auth.js")).href);
+AuthManager.getUser = () => ({ username: "bruno", role: "user" });
+
const mod = await import("../../frontend/js/filechat.js");
const {
renderChatMessages, onChatMessage, openFileChat, closeFileChat, toggleFileChat,
followFileChat, openSidebarChat, filterChatMessages, refreshUnreadBadge,
+ onChatDeleted,
} = mod;
let pass = 0;
@@ -211,6 +216,68 @@ test("#190 — URL rendered as a link, surrounding text stays plain", async () =
assert.ok(msg.textContent.includes("du texte"));
});
+test("#191 — preview card rendered with site/title/description", () => {
+ const list = document.createElement("div");
+ renderChatMessages(list, [{
+ id: "p1", user: "alice", text: "voir https://example.com", ts: 40,
+ preview: { url: "https://example.com", title: "Mon site", description: "Le desc", image: "", site: "example.com" },
+ }], "bruno");
+ const card = list.querySelector("a.file-chat-preview");
+ assert.ok(card, "carte preview présente");
+ assert.strictEqual(card.href, "https://example.com/");
+ assert.strictEqual(card.target, "_blank");
+ assert.strictEqual(card.querySelector(".file-chat-preview-title").textContent, "Mon site");
+ assert.strictEqual(card.querySelector(".file-chat-preview-desc").textContent, "Le desc");
+ assert.strictEqual(card.querySelector(".file-chat-preview-site").textContent, "example.com");
+ renderChatMessages(list, [{ id: "p2", user: "alice", text: "https://x.test", ts: 41 }], "bruno");
+ assert.strictEqual(list.querySelector("a.file-chat-preview"), null);
+});
+
+test("#191 — delete button hidden without delUrl, shown for own post", () => {
+ const list = document.createElement("div");
+ renderChatMessages(list, [{ id: "d1", user: "bruno", text: "x", ts: 50 }], "bruno");
+ assert.strictEqual(list.querySelector(".file-chat-del"), null);
+ renderChatMessages(list, [{ id: "d2", user: "bruno", text: "x", ts: 51 }], "bruno", "/api/chat");
+ assert.ok(list.querySelector(".file-chat-del"), "bouton suppression visible");
+ renderChatMessages(list, [{ id: "d3", user: "alice", text: "x", ts: 52 }], "bruno", "/api/chat");
+ assert.strictEqual(list.querySelector(".file-chat-del"), null);
+});
+
+test("#191 — onChatDeleted removes the message from any list", async () => {
+ await openSidebarChat();
+ const list = document.getElementById("sidebar-panel-chat-list");
+ onChatMessage({ vault: "__global__", path: "general", message: { id: "del1", user: "alice", text: "a bientot", ts: 60 } });
+ assert.strictEqual(list.querySelector('[data-id="del1"]') !== null, true);
+ onChatDeleted({ vault: "__global__", path: "general", id: "del1" });
+ assert.strictEqual(list.querySelector('[data-id="del1"]'), null);
+});
+
+test("#191 — unread counters accumulate then reset on open", async () => {
+ localStorage.removeItem("obsigate-chat-unread");
+ const panel = document.getElementById("sidebar-panel-chat");
+ panel.classList.remove("active");
+ onChatMessage({ vault: "__global__", path: "general", message: { id: "u-1", user: "bob", text: "1", ts: 70 } });
+ onChatMessage({ vault: "__global__", path: "general", message: { id: "u-2", user: "bob", text: "2", ts: 71 } });
+ let u = JSON.parse(localStorage.getItem("obsigate-chat-unread"));
+ assert.strictEqual(u.general, 2);
+ refreshUnreadBadge();
+ assert.strictEqual(document.querySelector("#sidebar-tab-chat .chat-unread-badge").textContent, "2");
+ panel.classList.add("active");
+ await openSidebarChat();
+ u = JSON.parse(localStorage.getItem("obsigate-chat-unread"));
+ assert.strictEqual(u.general, 0);
+ assert.strictEqual(document.querySelector("#sidebar-tab-chat .chat-unread-badge"), null);
+});
+
+test("#191 — DM routed by pair path, isolated from general", async () => {
+ const list = document.getElementById("sidebar-panel-chat-list");
+ const before = list.querySelectorAll(".file-chat-msg").length;
+ onChatMessage({ vault: "__dm__", path: "alice|bruno", message: { id: "dm-1", user: "alice", text: "prive", ts: 80 } });
+ const u = JSON.parse(localStorage.getItem("obsigate-chat-unread"));
+ assert.strictEqual(u["dm:alice"], 1);
+ assert.strictEqual(list.querySelectorAll(".file-chat-msg").length, before);
+});
+
// Sequential execution: each case depends on panel state left by the previous.
for (const [name, fn] of cases) {
try {
diff --git a/tests/test_file_chat.py b/tests/test_file_chat.py
index 27b2c4c..cb4dd9b 100644
--- a/tests/test_file_chat.py
+++ b/tests/test_file_chat.py
@@ -8,6 +8,13 @@ import pytest
from backend import file_chat as _store
+def _login(client, username="admin", password="chab30") -> dict:
+ """Log in against the auth-enabled ``admin_client`` fixture (#191)."""
+ resp = client.post("/api/auth/login", json={"username": username, "password": password})
+ assert resp.status_code == 200, resp.text
+ return {"Authorization": f"Bearer {resp.json()['access_token']}"}
+
+
@pytest.fixture(autouse=True)
def _isolated_chat_dir(tmp_path, monkeypatch):
"""Keep every test (store AND routes) out of the real ``data/chats``/
@@ -207,3 +214,153 @@ class TestAttachments:
r = client.post("/api/chat", json={"text": "voici", "attachment": att})
assert r.status_code == 200
assert r.json()["message"]["attachment"]["kind"] == "image"
+
+
+# --- #191 : suppression, messages privés, link preview ----------------------
+
+class TestDelete:
+ def test_author_deletes_own_post(self, client, test_vault_dir):
+ msg = client.post("/api/chat", json={"text": "a supprimer"}).json()["message"]
+ r = client.delete(f"/api/chat/{msg['id']}")
+ assert r.status_code == 200
+ assert client.get("/api/chat").json()["messages"] == []
+
+ def test_delete_unknown_404(self, client, test_vault_dir):
+ assert client.delete("/api/chat/nope").status_code == 404
+
+ def test_removed_message_cannot_be_deleted_twice(self, client, test_vault_dir):
+ msg = client.post("/api/chat", json={"text": "x"}).json()["message"]
+ client.delete(f"/api/chat/{msg['id']}")
+ assert client.delete(f"/api/chat/{msg['id']}").status_code == 404
+
+
+class TestPrivateChat:
+ """DM flows need a real logged-in identity (auth enabled)."""
+
+ def test_dm_roundtrip(self, admin_client, test_vault_dir):
+ h = _login(admin_client)
+ r = admin_client.post("/api/chat/dm/normaluser", json={"text": "salut"}, headers=h)
+ assert r.status_code == 200
+ assert r.json()["message"]["text"] == "salut"
+
+ def test_dm_unknown_user_404(self, admin_client, test_vault_dir):
+ h = _login(admin_client)
+ r = admin_client.post("/api/chat/dm/ghost", json={"text": "hi"}, headers=h)
+ assert r.status_code == 404
+
+ def test_dm_to_self_400(self, admin_client, test_vault_dir):
+ h = _login(admin_client)
+ r = admin_client.post("/api/chat/dm/admin", json={"text": "hi"}, headers=h)
+ assert r.status_code == 400
+
+ def test_dm_isolated_from_general(self, admin_client, test_vault_dir):
+ h = _login(admin_client)
+ admin_client.post("/api/chat", json={"text": "public"}, headers=h)
+ admin_client.post("/api/chat/dm/normaluser", json={"text": "prive"}, headers=h)
+ gen = admin_client.get("/api/chat", headers=h).json()["messages"]
+ assert [m["text"] for m in gen] == ["public"]
+ dm = admin_client.get("/api/chat/dm/normaluser", headers=h).json()["messages"]
+ assert [m["text"] for m in dm] == ["prive"]
+
+ def test_dm_history(self, admin_client, test_vault_dir):
+ h = _login(admin_client)
+ admin_client.post("/api/chat/dm/normaluser", json={"text": "hystorique"}, headers=h)
+ r = admin_client.get("/api/chat/dm/normaluser", headers=h)
+ assert [m["text"] for m in r.json()["messages"]] == ["hystorique"]
+
+ def test_dm_pair_is_shared_not_duplicated(self, admin_client, test_vault_dir):
+ h = _login(admin_client)
+ admin_client.post("/api/chat/dm/normaluser", json={"text": "a"}, headers=h)
+ assert _store.dm_path("admin", "normaluser") == _store.dm_path("normaluser", "admin")
+ assert len(admin_client.get("/api/chat/dm/normaluser", headers=h).json()["messages"]) == 1
+
+ def test_dm_delete(self, admin_client, test_vault_dir):
+ h = _login(admin_client)
+ msg = admin_client.post(
+ "/api/chat/dm/normaluser", json={"text": "adieu"}, headers=h
+ ).json()["message"]
+ r = admin_client.delete(f"/api/chat/dm/normaluser/{msg['id']}", headers=h)
+ assert r.status_code == 200
+ assert admin_client.get("/api/chat/dm/normaluser", headers=h).json()["messages"] == []
+
+ def test_requires_auth(self, admin_client, test_vault_dir):
+ assert admin_client.get("/api/chat/dm/normaluser").status_code == 401
+ assert admin_client.get("/api/chat/users").status_code == 401
+
+ def test_user_list_for_picker(self, admin_client, test_vault_dir):
+ h = _login(admin_client)
+ r = admin_client.get("/api/chat/users", headers=h)
+ assert r.status_code == 200
+ names = [u["username"] for u in r.json()]
+ assert "normaluser" in names
+ assert "admin" not in names # oneself is not a DM target
+ assert all("password" not in u and "hash" not in str(u) for u in r.json())
+
+
+class TestLinkPreview:
+ def test_no_url_no_preview(self, client, test_vault_dir, monkeypatch):
+ monkeypatch.setattr(_store, "build_preview", lambda text: None)
+ msg = client.post("/api/chat", json={"text": "pas d url"}).json()["message"]
+ # the schema always serialises the key (null) — it must stay falsy
+ assert not msg.get("preview")
+
+ def test_preview_attached_when_fetched(self, client, test_vault_dir, monkeypatch):
+ monkeypatch.setattr(
+ _store, "build_preview",
+ lambda text: {"url": "https://x.test", "title": "Titre", "description": "", "image": "", "site": "x.test"},
+ )
+ msg = client.post("/api/chat", json={"text": "voir https://x.test"}).json()["message"]
+ assert msg["preview"]["title"] == "Titre"
+
+ def test_preview_persisted_in_history(self, client, test_vault_dir, monkeypatch):
+ monkeypatch.setattr(
+ _store, "build_preview",
+ lambda text: {"url": "https://y.test", "title": "Y", "description": "", "image": "", "site": "y.test"},
+ )
+ client.post("/api/chat", json={"text": "https://y.test"})
+ got = client.get("/api/chat").json()["messages"]
+ assert got[-1]["preview"]["site"] == "y.test"
+
+ def test_build_preview_rejects_non_http(self):
+ assert _store.build_preview("javascript:alert(1)") is None
+ assert _store.build_preview("aucune URL") is None
+
+ def test_build_preview_blocks_private_hosts(self, monkeypatch):
+ # SSRF guard rejects loopback before any network round-trip
+ monkeypatch.setattr(_store, "_PREVIEW_CACHE", {})
+ assert _store.build_preview("http://127.0.0.1/x") is None
+
+ def test_build_preview_happy_path_parses_og(self, monkeypatch):
+ """The fetch itself (a bad kwarg here used to fail silently)."""
+ monkeypatch.setattr(_store, "_PREVIEW_CACHE", {})
+ # the SSRF guard resolves DNS for real — out of scope for this test
+ from backend.tools import web as _web
+
+ monkeypatch.setattr(_web, "_assert_public_http_url", lambda url: None)
+
+ class _Resp:
+ status_code = 200
+ text = (
+ "Fallback"
+ ''
+ ''
+ ''
+ ""
+ )
+
+ called = {}
+
+ def _fake_get(url, **kw):
+ called["url"] = url
+ return _Resp()
+
+ monkeypatch.setattr(_store.httpx, "get", _fake_get)
+ preview = _store.build_preview("voir https://example.test/page")
+ assert called["url"] == "https://example.test/page"
+ assert preview == {
+ "url": "https://example.test/page",
+ "title": "Titre OG",
+ "description": "Desc",
+ "image": "",
+ "site": "Site",
+ }