feat: chat — suppression de post, messages privés, boîte compacte, link preview #191
This commit is contained in:
+30
-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.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é
|
||||
|
||||
+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.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*
|
||||
|
||||
@@ -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*
|
||||
|
||||
+130
-2
@@ -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/``<title>`` value from an HTML head (regex)."""
|
||||
for pattern in (
|
||||
rf'<meta[^>]+(?:property|name)="{prop}"[^>]+content="([^"]*)"',
|
||||
rf'<meta[^>]+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"<title[^>]*>([^<]*)</title>", 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)
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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):
|
||||
|
||||
Generated
+1
-1
@@ -2626,7 +2626,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "obsigate-desktop"
|
||||
version = "2.58.0"
|
||||
version = "2.60.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"env_logger",
|
||||
|
||||
+1
-1
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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).
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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) |
|
||||
|
||||
|
||||
+3
-2
@@ -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) |
|
||||
|
||||
@@ -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 `<title>`,
|
||||
`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
|
||||
|
||||
+20
-2
@@ -1072,6 +1072,13 @@
|
||||
role="tabpanel"
|
||||
aria-labelledby="sidebar-tab-chat"
|
||||
>
|
||||
<!-- #191 : rangée de canaux (Général + utilisateurs) -->
|
||||
<div
|
||||
id="sidebar-panel-chat-channels"
|
||||
class="chat-channels"
|
||||
role="tablist"
|
||||
aria-label="Canaux de discussion"
|
||||
></div>
|
||||
<div
|
||||
id="sidebar-panel-chat-list"
|
||||
class="file-chat-list sidebar-chat-list"
|
||||
@@ -1090,7 +1097,7 @@
|
||||
>
|
||||
<i
|
||||
data-lucide="paperclip"
|
||||
style="width: 16px; height: 16px"
|
||||
style="width: 15px; height: 15px"
|
||||
></i>
|
||||
</button>
|
||||
<input
|
||||
@@ -1102,7 +1109,18 @@
|
||||
autocomplete="off"
|
||||
aria-label="Écrire un message"
|
||||
/>
|
||||
<button type="submit" class="file-chat-send">Envoyer</button>
|
||||
<!-- #191 : bouton compact (icône) pour laisser la place à la saisie -->
|
||||
<button
|
||||
type="submit"
|
||||
class="file-chat-send file-chat-send-icon"
|
||||
title="Envoyer"
|
||||
aria-label="Envoyer"
|
||||
>
|
||||
<i
|
||||
data-lucide="send"
|
||||
style="width: 15px; height: 15px"
|
||||
></i>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
|
||||
+216
-45
@@ -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:<user>": 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 =
|
||||
`<span class="file-chat-user">${_esc(m.user)}</span>` +
|
||||
`<span class="file-chat-ts">${_esc(_formatTime(m.ts))}</span>`;
|
||||
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 = '<i data-lucide="send" style="width:15px;height:15px"></i>';
|
||||
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());
|
||||
}
|
||||
|
||||
+11
-1
@@ -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();
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
--------------------------------------------------------------------------- */
|
||||
|
||||
+1
-1
@@ -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": {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 = (
|
||||
"<html><head><title>Fallback</title>"
|
||||
'<meta property="og:title" content="Titre OG">'
|
||||
'<meta property="og:description" content="Desc">'
|
||||
'<meta property="og:site_name" content="Site">'
|
||||
"</head></html>"
|
||||
)
|
||||
|
||||
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",
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user