feat: chat — saisie auto-agrandissante, accusé de réception ✓✓, vignette de tuile #192 (BUG-109)
CI / lint (push) Successful in 2m52s
CI / security (push) Successful in 1m35s
CI / test (push) Successful in 4m27s
CI / build (push) Successful in 1m29s
CI / e2e (push) Successful in 17m1s

This commit is contained in:
2026-10-08 23:28:08 -04:00
parent f00a8bea8f
commit 54caa48d1f
22 changed files with 645 additions and 52 deletions
+38 -1
View File
@@ -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). 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 > **En cours de développement** : les changements à venir sont listés dans la section
> [Unreleased](#unreleased). La dernière version livrée est **2.60.0**. > [Unreleased](#unreleased). La dernière version livrée est **2.61.0**.
--- ---
@@ -14,6 +14,43 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
--- ---
## [2.61.0] — 2026-10-08
### Ajouté
- **#192 - Chat : boîte de saisie auto-agrandissante + accusé de réception**
- **Saisie auto-agrandissante** : la boîte d'envoi (panneau document +
sidebar) est un `textarea` dont la hauteur suit le contenu (plafond
160 px puis défilement) — on relit son message entier avant l'envoi.
`Entrée` envoie, `Maj+Entrée` saute une ligne, la hauteur repasse à une
ligne après l'envoi.
- **Accusé de réception** : chaque conversation stocke
`{utilisateur: dernier_ts_de_lecture}` ; l'expéditeur voit **✓ envoyé**
puis **✓✓ lu** sur ses propres messages (un message est lu dès qu'un
*autre* participant ouvre la conversation). Le `GET` d'historique renvoie
la carte de lecture, `POST /api/chat/read` la met à jour et la diffuse
en SSE (`chat_read`) — les indicateurs basculent en direct.
- **Notification** : quand un message passe à ✓✓, l'expéditeur reçoit le
toast « {user} a lu votre message » (FR/EN) ; un message reçu pendant
l'affichage du canal accuse réception aussitôt.
- Verrou global sur le read-modify-write du store de chat (une lecture ne
peut plus faire disparaître un message).
- Tests : pytest `TestReadReceipts` (7) + JSDOM `filechat.test.mjs` +3
(19).
### Corrigé
- **BUG-109 - Tuile de lien : l'image de la carte ne s'affichait jamais** —
la CSP (`img-src 'self' data: blob:`) bloque toute `og:image` distante et
les images relatives étaient résolues contre l'origine d'ObsiGate (404).
La vignette est désormais téléversée à l'envoi dans `chat_uploads`
(`/api/chat/attachment/<uuid>`, même origine, garde SSRF + plafond 2 Mo +
allow-list d'extensions) ; échec du téléchargement → carte conservée sans
vignette. Les cartes déjà publiées gardent leur ancien lien (elles
étaient déjà muettes).
---
## [2.60.0] — 2026-10-08 ## [2.60.0] — 2026-10-08
--- ---
+3 -3
View File
@@ -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. **Porte d'entrée web ultra-léger pour vos vaults Obsidian** — Accédez, naviguez et recherchez dans toutes vos notes Obsidian depuis n'importe quel appareil via une interface web moderne et responsive.
[![Version](https://img.shields.io/badge/Version-2.60.0-blue.svg)]() [![Version](https://img.shields.io/badge/Version-2.61.0-blue.svg)]()
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![Docker](https://img.shields.io/badge/Docker-Ready-blue.svg)](https://www.docker.com/) [![Docker](https://img.shields.io/badge/Docker-Ready-blue.svg)](https://www.docker.com/)
[![Python](https://img.shields.io/badge/Python-3.11+-green.svg)](https://www.python.org/) [![Python](https://img.shields.io/badge/Python-3.11+-green.svg)](https://www.python.org/)
@@ -976,8 +976,8 @@ Ce projet est sous licence **MIT** — voir le fichier [LICENSE](LICENSE) pour l
## 📝 Changelog ## 📝 Changelog
Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.60.0). Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.61.0).
--- ---
*Projet : ObsiGate | Version : 2.60.0 | Dernière mise à jour : Septembre 2026* *Projet : ObsiGate | Version : 2.61.0 | Dernière mise à jour : Septembre 2026*
+3 -3
View File
@@ -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. **Ultra-light web gateway for your Obsidian vaults** — Access, browse, and search all your Obsidian notes from any device via a modern, responsive web interface.
[![Version](https://img.shields.io/badge/Version-2.60.0-blue.svg)]() [![Version](https://img.shields.io/badge/Version-2.61.0-blue.svg)]()
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![Docker](https://img.shields.io/badge/Docker-Ready-blue.svg)](https://www.docker.com/) [![Docker](https://img.shields.io/badge/Docker-Ready-blue.svg)](https://www.docker.com/)
[![Python](https://img.shields.io/badge/Python-3.11+-green.svg)](https://www.python.org/) [![Python](https://img.shields.io/badge/Python-3.11+-green.svg)](https://www.python.org/)
@@ -1151,8 +1151,8 @@ This project is licensed under the **MIT License** - see the [LICENSE](LICENSE)
## 📝 Changelog ## 📝 Changelog
See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.60.0). See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.61.0).
--- ---
*Project: ObsiGate | Version: 2.60.0 | Last updated: September 2026* *Project: ObsiGate | Version: 2.61.0 | Last updated: September 2026*
+1 -1
View File
@@ -1 +1 @@
2.60.0 2.61.0
+108 -19
View File
@@ -20,10 +20,12 @@ import json
import logging import logging
import re import re
import shutil import shutil
import threading
import time import time
import uuid import uuid
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
from urllib.parse import urljoin, urlparse
import httpx import httpx
@@ -33,6 +35,11 @@ CHAT_DIR = Path("data/chats")
MAX_MESSAGES = 500 # retention ceiling per file (oldest dropped first) MAX_MESSAGES = 500 # retention ceiling per file (oldest dropped first)
MAX_TEXT = 4000 # characters per message MAX_TEXT = 4000 # characters per message
# ponytail: global lock over read-modify-write — chat writes are HTTP-only and
# serialized anyway, this just makes losing a message to a future thread (or a
# watcher hook) impossible; per-file locks if it ever becomes contended.
_LOCK = threading.RLock()
# #190 — general (non file-bound) conversation, stored like any other one. # #190 — general (non file-bound) conversation, stored like any other one.
GLOBAL_VAULT = "__global__" GLOBAL_VAULT = "__global__"
GLOBAL_PATH = "general" GLOBAL_PATH = "general"
@@ -53,6 +60,7 @@ ALLOWED_ATTACH_EXT = {
# #191 — link preview fetch budget # #191 — link preview fetch budget
PREVIEW_TIMEOUT = 5.0 # seconds PREVIEW_TIMEOUT = 5.0 # seconds
PREVIEW_MAX_BYTES = 512 * 1024 # only the head of the page is parsed PREVIEW_MAX_BYTES = 512 * 1024 # only the head of the page is parsed
PREVIEW_IMAGE_MAX = 2 * 1024 * 1024 # ponytail: 2 MB ceiling on a thumbnail
def _chat_file(vault: str, path: str) -> Path: def _chat_file(vault: str, path: str) -> Path:
@@ -117,26 +125,56 @@ def add_message(
msg["attachment"] = attachment msg["attachment"] = attachment
if preview: if preview:
msg["preview"] = preview msg["preview"] = preview
doc = _read(vault, path) return _append(vault, path, msg)
return _append(vault, path, doc, msg)
def _append( def _append(
vault: str, vault: str,
path: str, path: str,
doc: dict[str, Any],
msg: dict[str, Any], msg: dict[str, Any],
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Cap, persist and return *msg* (shared by file and global chats).""" """Cap, persist and return *msg* (shared by file, global and DM chats).
messages = list(doc.get("messages", []))
messages.append(msg) The read-modify-write of the whole document happens under ``_LOCK`` so a
if len(messages) > MAX_MESSAGES: concurrent writer can never drop a message (same class of bug as
messages = messages[-MAX_MESSAGES:] BUG-029 on ``users.json``).
doc["messages"] = messages """
_write(_chat_file(vault, path), doc) with _LOCK:
doc = _read(vault, path)
messages = list(doc.get("messages", []))
messages.append(msg)
if len(messages) > MAX_MESSAGES:
messages = messages[-MAX_MESSAGES:]
doc["messages"] = messages
_write(_chat_file(vault, path), doc)
return msg return msg
# --- #192 : accusé de réception ---------------------------------------------
def get_read(vault: str, path: str) -> dict[str, float]:
"""Return ``{username: last_read_ts}`` for a conversation (#192)."""
return {str(u): float(ts) for u, ts in (_read(vault, path).get("read") or {}).items()}
def mark_read(vault: str, path: str, user: str) -> dict[str, float]:
"""Record that *user* has seen the conversation (#192).
Returns the whole read map so the caller can broadcast it on SSE: a
sender learns their messages were received as soon as the recipient
displays the conversation.
"""
if not user:
return get_read(vault, path)
with _LOCK:
doc = _read(vault, path)
read = {str(u): float(ts) for u, ts in (doc.get("read") or {}).items()}
read[user] = time.time()
doc["read"] = read
_write(_chat_file(vault, path), doc)
return read
# --- #191 : messages privés (2 utilisateurs) ------------------------------- # --- #191 : messages privés (2 utilisateurs) -------------------------------
def dm_path(user_a: str, user_b: str) -> str: def dm_path(user_a: str, user_b: str) -> str:
@@ -168,13 +206,14 @@ def add_dm_message(
def delete_message(vault: str, path: str, message_id: str) -> bool: def delete_message(vault: str, path: str, message_id: str) -> bool:
"""Remove one message from a conversation. True when it existed.""" """Remove one message from a conversation. True when it existed."""
doc = _read(vault, path) with _LOCK:
messages = list(doc.get("messages", [])) doc = _read(vault, path)
kept = [m for m in messages if m.get("id") != message_id] messages = list(doc.get("messages", []))
if len(kept) == len(messages): kept = [m for m in messages if m.get("id") != message_id]
return False if len(kept) == len(messages):
doc["messages"] = kept return False
_write(_chat_file(vault, path), doc) doc["messages"] = kept
_write(_chat_file(vault, path), doc)
return True return True
@@ -248,13 +287,55 @@ def _og(content: str, prop: str) -> str:
return "" return ""
# BUG-109 — content-type → extension (the attachment allow-list decides).
_IMG_EXT_BY_MIME = {
"image/png": ".png",
"image/jpeg": ".jpg",
"image/gif": ".gif",
"image/webp": ".webp",
"image/svg+xml": ".svg",
}
def _proxy_image(img_url: str, page_url: str) -> str:
"""Download *img_url* into ``chat_uploads`` and return a same-origin URL.
The response CSP is ``img-src 'self' data: blob:``: a remote ``og:image``
would be blocked by the browser (BUG-109). Relative and protocol-relative
values are resolved against *page_url* first. Raises ``ValueError`` /
``SSRFError`` on any failure — the caller keeps the card and drops only
the thumbnail.
"""
from backend.tools.web import USER_AGENT, _assert_public_http_url
full = urljoin(page_url, img_url)
_assert_public_http_url(full)
resp = httpx.get(
full,
headers={"User-Agent": USER_AGENT},
timeout=PREVIEW_TIMEOUT,
follow_redirects=True,
)
if resp.status_code >= 400:
raise ValueError(f"HTTP {resp.status_code}")
data = resp.content
if not data or len(data) > PREVIEW_IMAGE_MAX:
raise ValueError("image vide ou trop lourde")
mime = (resp.headers.get("content-type") or "").split(";")[0].strip().lower()
ext = _IMG_EXT_BY_MIME.get(mime) or Path(urlparse(full).path).suffix.lower()
# save_attachment(): allow-list d'extensions + nom UUID (jamais le nom distant)
return str(save_attachment(f"preview{ext}", data)["url"])
def build_preview(text: str) -> dict[str, Any] | None: def build_preview(text: str) -> dict[str, Any] | None:
"""Fetch OpenGraph metadata for the first URL in *text* (#191). """Fetch OpenGraph metadata for the first URL in *text* (#191).
SSRF-guarded (reuses the web-tool guard), size/time capped, cached in a SSRF-guarded (reuses the web-tool guard), size/time capped, cached in a
bounded dict. Returns ``{url, title, description, image, site}`` or bounded dict. Returns ``{url, title, description, image, site}`` or
``None`` when there is no URL / the fetch fails (never raises: a dead ``None`` when there is no URL / the fetch fails (never raises: a dead
link must not block the message). link must not block the message). ``image`` is a **same-origin**
``/api/chat/attachment/...`` URL (BUG-109), empty when the thumbnail
could not be fetched.
""" """
m = _URL_RE.search(text or "") m = _URL_RE.search(text or "")
if not m: if not m:
@@ -276,11 +357,19 @@ def build_preview(text: str) -> dict[str, Any] | None:
if resp.status_code >= 400: if resp.status_code >= 400:
raise ValueError(f"HTTP {resp.status_code}") raise ValueError(f"HTTP {resp.status_code}")
body = resp.text[:PREVIEW_MAX_BYTES] body = resp.text[:PREVIEW_MAX_BYTES]
# BUG-109 : vignette téléchargée côté serveur — une image distante
# échouerait à la CSP. Échec isolé = carte sans vignette.
image = _og(body, "og:image")
try:
image = _proxy_image(image, url) if image else ""
except Exception as ie:
logger.debug("preview image failed for %s: %s", url, ie)
image = ""
preview = { preview = {
"url": url, "url": url,
"title": _og(body, "og:title") or _og(body, "og:site_name"), "title": _og(body, "og:title") or _og(body, "og:site_name"),
"description": _og(body, "og:description"), "description": _og(body, "og:description"),
"image": _og(body, "og:image"), "image": image,
"site": _og(body, "og:site_name") or (url.split("/")[2] if "/" in url[8:] else url), "site": _og(body, "og:site_name") or (url.split("/")[2] if "/" in url[8:] else url),
} }
if not preview["title"]: if not preview["title"]:
+51 -4
View File
@@ -24,7 +24,7 @@ from backend import file_chat as _store
from backend.auth.middleware import check_vault_access, require_auth from backend.auth.middleware import check_vault_access, require_auth
from backend.auth.user_store import get_all_users, get_user from backend.auth.user_store import get_all_users, get_user
from backend.indexer import get_vault_data from backend.indexer import get_vault_data
from backend.schemas import ChatHistoryResponse, ChatMessageResponse, StatusResponse from backend.schemas import ChatHistoryResponse, ChatMessageResponse, ChatReadResponse, StatusResponse
from backend.services.paths import resolve_safe_path from backend.services.paths import resolve_safe_path
from backend.sse import sse_manager from backend.sse import sse_manager
@@ -49,7 +49,7 @@ async def api_file_chat_history(
): ):
"""Return the chat history for a file (chronological).""" """Return the chat history for a file (chronological)."""
_check(vault_name, path, current_user) _check(vault_name, path, current_user)
return {"messages": _store.get_messages(vault_name, path)} return {"messages": _store.get_messages(vault_name, path), "read": _store.get_read(vault_name, path)}
@router.post("/api/file/{vault_name}/chat", response_model=ChatMessageResponse) @router.post("/api/file/{vault_name}/chat", response_model=ChatMessageResponse)
@@ -93,7 +93,51 @@ def _attachment(body: dict[str, Any]) -> dict[str, Any] | None:
@router.get("/api/chat", response_model=ChatHistoryResponse) @router.get("/api/chat", response_model=ChatHistoryResponse)
async def api_chat_history(current_user: dict[str, Any] = Depends(require_auth)): async def api_chat_history(current_user: dict[str, Any] = Depends(require_auth)):
"""Return the general chat history (#190, chronological).""" """Return the general chat history (#190, chronological)."""
return {"messages": _store.get_global_messages()} return {
"messages": _store.get_global_messages(),
"read": _store.get_read(_store.GLOBAL_VAULT, _store.GLOBAL_PATH),
}
# --- #192 : accusé de réception ---------------------------------------------
def _check_read(vault: str, path: str, current_user: dict[str, Any]) -> None:
"""Authorization for marking a conversation read (#192).
General chat is open to any member, a DM only to the two participants,
a file chat follows the vault ACL.
"""
if vault == _store.GLOBAL_VAULT:
return
if vault == _store.DM_VAULT:
if current_user.get("username") not in str(path).split("|"):
raise HTTPException(403, "Accès refusé à cette conversation privée")
return
_check(vault, path, current_user)
@router.post("/api/chat/read", response_model=ChatReadResponse)
async def api_chat_read(
body: dict[str, Any] = Body(...),
current_user: dict[str, Any] = Depends(require_auth),
):
"""Record that the caller has seen a conversation (#192).
Broadcast on SSE (``chat_read``) so the sender's own messages flip to
✓✓ live on every connected client.
"""
vault = str(body.get("vault") or "")
path = str(body.get("path") or "")
if not vault or not path:
raise HTTPException(400, "vault and path are required")
_check_read(vault, path, current_user)
username = current_user.get("username", "")
read = _store.mark_read(vault, path, username)
await sse_manager.broadcast(
"chat_read",
{"vault": vault, "path": path, "user": username, "ts": read.get(username, 0.0), "read": read},
)
return {"read": read, "status": "ok"}
@router.post("/api/chat", response_model=ChatMessageResponse) @router.post("/api/chat", response_model=ChatMessageResponse)
@@ -199,7 +243,10 @@ def _dm_peer(username: str, current_user: dict[str, Any]) -> str:
async def api_chat_dm_history(username: str, current_user: dict[str, Any] = Depends(require_auth)): async def api_chat_dm_history(username: str, current_user: dict[str, Any] = Depends(require_auth)):
"""Private history with *username* (#191).""" """Private history with *username* (#191)."""
peer = _dm_peer(username, current_user) peer = _dm_peer(username, current_user)
return {"messages": _store.get_dm_messages(current_user["username"], peer)} return {
"messages": _store.get_dm_messages(current_user["username"], peer),
"read": _store.get_read(_store.DM_VAULT, _store.dm_path(current_user["username"], peer)),
}
@router.post("/api/chat/dm/{username}", response_model=ChatMessageResponse) @router.post("/api/chat/dm/{username}", response_model=ChatMessageResponse)
+11
View File
@@ -172,6 +172,17 @@ class ChatHistoryResponse(BaseModel):
"""Response for ``GET /api/file/{vault}/chat``.""" """Response for ``GET /api/file/{vault}/chat``."""
messages: list[ChatMessageItem] = Field(description="Messages, chronological") messages: list[ChatMessageItem] = Field(description="Messages, chronological")
read: dict[str, float] = Field(
default_factory=dict,
description="{username: last read unix ts} per participant (#192)",
)
class ChatReadResponse(BaseModel):
"""Response for ``POST /api/chat/read`` (#192 read receipts)."""
read: dict[str, float] = Field(description="{username: last read unix ts}")
status: str = Field(description="'ok'")
class ChatMessageResponse(BaseModel): class ChatMessageResponse(BaseModel):
+1 -1
View File
@@ -2626,7 +2626,7 @@ dependencies = [
[[package]] [[package]]
name = "obsigate-desktop" name = "obsigate-desktop"
version = "2.60.0" version = "2.61.0"
dependencies = [ dependencies = [
"chrono", "chrono",
"env_logger", "env_logger",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "obsigate-desktop" name = "obsigate-desktop"
version = "2.60.0" version = "2.61.0"
description = "ObsiGate Desktop — Porte d'entrée native pour vos vaults Obsidian" description = "ObsiGate Desktop — Porte d'entrée native pour vos vaults Obsidian"
authors = ["Bruno Charest"] authors = ["Bruno Charest"]
edition = "2021" edition = "2021"
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"$schema": "https://raw.githubusercontent.com/nicedoc/obsigate/main/desktop/tauri.conf.schema.json", "$schema": "https://raw.githubusercontent.com/nicedoc/obsigate/main/desktop/tauri.conf.schema.json",
"productName": "ObsiGate", "productName": "ObsiGate",
"version": "2.60.0", "version": "2.61.0",
"identifier": "com.obsigate.desktop", "identifier": "com.obsigate.desktop",
"build": { "build": {
"frontendDist": "../frontend", "frontendDist": "../frontend",
+2
View File
@@ -216,6 +216,7 @@ Avant de corriger quoi que ce soit, un agent IA doit :
| *BUG-106* | Page blanche sur `/docs` (Swagger UI) : la CSP `script-src` sans `unsafe-inline` (#87 T5c) refuse le script inline d'init de FastAPI, jamais noncé | 🟢 corrigé | P1 | ⚙️ backend | IA | `backend/main.py` (`SecurityHeadersMiddleware`) | Ouvrir `https://og.dracodev.net/docs` : HTML servi (200) mais aucune UI, console `Refused to execute inline script … Content Security Policy` | Injection du nonce dans le HTML de `/docs` et `/redoc` depuis le middleware, via le helper existant `inject_csp_nonce` ; corps décompressé/recompressé (GZipMiddleware est plus proche de la route) | `pytest tests/test_csp_nonce.py` (3 tests ajoutés : nonce sur `/docs`, `/redoc` sans injection, regex sur bundle externe) + 45 passed sur les 3 suites sécurité ; ruff/mypy 0 | | *BUG-106* | Page blanche sur `/docs` (Swagger UI) : la CSP `script-src` sans `unsafe-inline` (#87 T5c) refuse le script inline d'init de FastAPI, jamais noncé | 🟢 corrigé | P1 | ⚙️ backend | IA | `backend/main.py` (`SecurityHeadersMiddleware`) | Ouvrir `https://og.dracodev.net/docs` : HTML servi (200) mais aucune UI, console `Refused to execute inline script … Content Security Policy` | Injection du nonce dans le HTML de `/docs` et `/redoc` depuis le middleware, via le helper existant `inject_csp_nonce` ; corps décompressé/recompressé (GZipMiddleware est plus proche de la route) | `pytest tests/test_csp_nonce.py` (3 tests ajoutés : nonce sur `/docs`, `/redoc` sans injection, regex sur bundle externe) + 45 passed sur les 3 suites sécurité ; ruff/mypy 0 |
| *BUG-107* | Drag & drop de fichiers depuis l'Explorateur inutilisable sur desktop : Tauri/wry pose son propre `IDropTarget` par-dessus de celui du WebView2 et aucun événement natif n'est écouté — #89 ne fonctionnait que sur le web | 🟢 corrigé | P1 | 🖥️ desktop | IA | `desktop/tauri.conf.json` (`create: false`), `desktop/src/main.rs` (`WebviewWindowBuilder::…disable_drag_drop_handler`) | App desktop : glisser un fichier de l'Explorateur sur la fenêtre → aucun survol, aucun dépôt — les gestionnaires HTML5 de `dragdrop.js` ne sont jamais déclenchés | Création manuelle de la fenêtre avec `disable_drag_drop_handler()` (doc Tauri : « required to use HTML5 drag and drop APIs on the frontend on Windows ») ; `create: false` évite la double création par la boucle Tauri | `cargo test` 26 passed (nouveau garde-fou `test_window_created_without_tauri_drag_drop_handler` : les deux marqueurs obligatoires) + lancement OK (fenêtre unique, boot normal) ; dépôt réel à valider par l'utilisateur | | *BUG-107* | Drag & drop de fichiers depuis l'Explorateur inutilisable sur desktop : Tauri/wry pose son propre `IDropTarget` par-dessus de celui du WebView2 et aucun événement natif n'est écouté — #89 ne fonctionnait que sur le web | 🟢 corrigé | P1 | 🖥️ desktop | IA | `desktop/tauri.conf.json` (`create: false`), `desktop/src/main.rs` (`WebviewWindowBuilder::…disable_drag_drop_handler`) | App desktop : glisser un fichier de l'Explorateur sur la fenêtre → aucun survol, aucun dépôt — les gestionnaires HTML5 de `dragdrop.js` ne sont jamais déclenchés | Création manuelle de la fenêtre avec `disable_drag_drop_handler()` (doc Tauri : « required to use HTML5 drag and drop APIs on the frontend on Windows ») ; `create: false` évite la double création par la boucle Tauri | `cargo test` 26 passed (nouveau garde-fou `test_window_created_without_tauri_drag_drop_handler` : les deux marqueurs obligatoires) + lancement OK (fenêtre unique, boot normal) ; dépôt réel à valider par l'utilisateur |
| *BUG-108* | Tableur : le « Coller » du menu contextuel ne fait rien (copier-coller par menu muet) | 🟢 corrigé | P1 | 📱 frontend | IA | `frontend/js/xlsx/context-menu.js` (`closeContextMenu`) | Ouvrir un `.xlsx`, clic droit « Copier » sur une plage, clic droit cible puis « Coller » → rien n'est collé (le menu se ferme sans action) | `closeContextMenu()` retirait le nœud du DOM **avant** de remettre `_menu` à `null` : retirer le menu qui tient le focus émet `focusout` en synchrone, qui rappelle `closeContextMenu()` → second `remove()` sur un nœud déjà démonté → `NotFoundError` qui avorte le handler de l'action « Coller » du viewer. Introduit par #179 (fermeture au focus). Correctif : `_menu = null` avant `menu.remove()` | Reproduit en local sur HEAD (e2e « coller une plage » : attendu `Date`, obtenu la valeur datée) puis vert après fix : E2E `-g "coller une plage"` 1/1, `xlsx-menus` 11/11, `ai-quick-actions` 13/13 | | *BUG-108* | Tableur : le « Coller » du menu contextuel ne fait rien (copier-coller par menu muet) | 🟢 corrigé | P1 | 📱 frontend | IA | `frontend/js/xlsx/context-menu.js` (`closeContextMenu`) | Ouvrir un `.xlsx`, clic droit « Copier » sur une plage, clic droit cible puis « Coller » → rien n'est collé (le menu se ferme sans action) | `closeContextMenu()` retirait le nœud du DOM **avant** de remettre `_menu` à `null` : retirer le menu qui tient le focus émet `focusout` en synchrone, qui rappelle `closeContextMenu()` → second `remove()` sur un nœud déjà démonté → `NotFoundError` qui avorte le handler de l'action « Coller » du viewer. Introduit par #179 (fermeture au focus). Correctif : `_menu = null` avant `menu.remove()` | Reproduit en local sur HEAD (e2e « coller une plage » : attendu `Date`, obtenu la valeur datée) puis vert après fix : E2E `-g "coller une plage"` 1/1, `xlsx-menus` 11/11, `ai-quick-actions` 13/13 |
| *BUG-109* | Chat : dans la tuile d'une URL (link preview) l'image ne s'affiche jamais | 🟢 corrigé | P2 | ⚙️ backend | IA | `backend/file_chat.py` (`build_preview`, `_proxy_image`) | Poster une URL avec `og:image` dans le chat général → la carte s'affiche mais sans vignette (console : `Refused to load the image … Content Security Policy`) | La CSP de réponse (`img-src 'self' data: blob:`) interdit toute image externe, et les `og:image` **relatives** étaient résolues contre l'origine d'ObsiGate (404). La vignette est désormais **téléversée à l'envoi** dans `chat_uploads` et servie same-origin (`/api/chat/attachment/<uuid>`) : résolution `urljoin` contre la page, garde SSRF réutilisée, plafond 2 Mo, allow-list d'extensions ; échec du téléchargement → carte conservée sans vignette (jamais de message perdu) | Tests `test_preview_image_is_proxied_same_origin` + `test_preview_image_failure_keeps_the_card` (contre-preuve : proxy neutralisé → `image == ""`). Cartes antérieures inchangées : leur URL distante était déjà bloquée |
| | | | | | | | | | | | | | | | | | | | | |
### TODOs techniques (améliorations / nouvelles tâches) ### TODOs techniques (améliorations / nouvelles tâches)
@@ -332,6 +333,7 @@ Avant de corriger quoi que ce soit, un agent IA doit :
| 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 | #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 | #190 | Fonctionnalité | `backend/file_chat.py`, `backend/routers/file_chat.py`, `backend/schemas.py`, `frontend/js/filechat.js` (réécrit), `frontend/js/viewer.js`, `frontend/js/config.js`, `frontend/js/sidebar.js`, `frontend/js/app.js`, `frontend/index.html`, `frontend/style.css`, `frontend/locales/{fr,en}.json`, `tests/test_file_chat.py`, `tests/frontend/filechat.test.mjs`, `docs/ROADMAP.md`, `CHANGELOG.md` | **#190 — chat : suivi du document, chat général en onglet sidebar, onglets au-dessus du filtre** : (A) l'en-tête du panneau affiche le document ciblé (titre + chemin) et `followFileChat()` appelé par `renderFile()` re-cible le panneau ouvert à chaque changement de document ; (B) onglet **Chat** (dernier) → conversation générale stockée dans le même store #169 via les sentinelles `__global__/general` (`GET/POST /api/chat`), **pastille de messages non lus** (`localStorage` + badge sur l'onglet), **pièces jointes image/vidéo** (`POST /api/chat/upload` : allow-list d'extensions, 25 MB, nom UUID — jamais le nom client ; `GET /api/chat/attachment/{name}` résolu contre l'allow-list), **URL cliquables** dans le rendu (linkification `http(s)://`), date/heure d'envoi ; (C) barre de filtre **déplacée sous les onglets** et routée vers `filterChatMessages()` (texte + auteur) quand l'onglet Chat est actif. Transport : broadcast SSE `chat_message` réutilisé (vault `__global__` route vers le panneau sidebar, sinon panneau fichier) — pas de second WebSocket. Contre-preuves : `__global__` non routé → test « routes __global__ » échoue ; panneau sans classe `active` → le message part en toast au lieu du rendu (révélé par le stub JSDOM). Vérifié : pytest **1655 passed / 2 skipped** (27 tests chat dont 12 nouveaux), ruff 0, mypy 0 (113 fichiers), validate-imports 42 modules/368 exports, unit 13/13, filechat.test.mjs **11/11** (5 nouveaux), toolbar-order, sidebar-filters 8/8 | ✅ livré (en attente vérif utilisateur) |
| 2026-10-08 | #169 | Fonctionnalité | `backend/file_chat.py` (nouveau), `backend/routers/file_chat.py` (nouveau), `backend/schemas.py`, `backend/main.py`, `frontend/js/filechat.js` (nouveau), `frontend/js/viewer.js`, `frontend/js/sync.js`, `frontend/style.css`, `frontend/locales/{fr,en}.json`, `tests/test_file_chat.py` (nouveau), `tests/frontend/filechat.test.mjs` (nouveau), `tests/frontend/toolbar-order.test.mjs`, `.gitea/workflows/ci.yml`, `docs/ROADMAP.md`, `docs/features/file-chat-169.md` (nouveau), `docs/GUIDES/PRISE_EN_MAIN.md`, `CHANGELOG.md` | **#169 — chat intégré par fichier** : store JSON par (vault, path) sous `data/chats/` (nom SHA-256 → traversal impossible, plafond 500 msgs, texte 4000 car., écriture atomique, fichier corrompu → vide) ; `GET/POST /api/file/{vault}/chat` (auth + `check_vault_access` + `resolve_safe_path`, 400/404/403, `response_model`) + broadcast SSE `chat_message` sur le transport #62 (**pas de 2ᵉ WebSocket**) ; panneau latéral `filechat.js` (bouton 💬 toolbar, rendu chronologique `textContent`, envoi optimiste + dédup par id, toast si panneau fermé/autre fichier, plein écran ≤ 768 px) ; relais SSE en import dynamique dans `sync.js` ; i18n FR/EN 10 clés `chat.*`. En route : tag OpenAPI maison `file-chat` non déclaré → `test_used_tags_are_declared` rouge (retiré, dérivé « Files »), regex navBtns de `toolbar-order.test.mjs` (échappements → `includes`), ruff UP012/TRY004/I001. Vérifié : `test_file_chat.py` 15 passed, `filechat.test.mjs` 6/6, suite 1643 passed, ruff/mypy 0, validate-imports 42 modules, CI run #1938 5/5 success (v2.57.0) | ✅ livré (en attente vérif utilisateur) | | 2026-10-08 | #169 | Fonctionnalité | `backend/file_chat.py` (nouveau), `backend/routers/file_chat.py` (nouveau), `backend/schemas.py`, `backend/main.py`, `frontend/js/filechat.js` (nouveau), `frontend/js/viewer.js`, `frontend/js/sync.js`, `frontend/style.css`, `frontend/locales/{fr,en}.json`, `tests/test_file_chat.py` (nouveau), `tests/frontend/filechat.test.mjs` (nouveau), `tests/frontend/toolbar-order.test.mjs`, `.gitea/workflows/ci.yml`, `docs/ROADMAP.md`, `docs/features/file-chat-169.md` (nouveau), `docs/GUIDES/PRISE_EN_MAIN.md`, `CHANGELOG.md` | **#169 — chat intégré par fichier** : store JSON par (vault, path) sous `data/chats/` (nom SHA-256 → traversal impossible, plafond 500 msgs, texte 4000 car., écriture atomique, fichier corrompu → vide) ; `GET/POST /api/file/{vault}/chat` (auth + `check_vault_access` + `resolve_safe_path`, 400/404/403, `response_model`) + broadcast SSE `chat_message` sur le transport #62 (**pas de 2ᵉ WebSocket**) ; panneau latéral `filechat.js` (bouton 💬 toolbar, rendu chronologique `textContent`, envoi optimiste + dédup par id, toast si panneau fermé/autre fichier, plein écran ≤ 768 px) ; relais SSE en import dynamique dans `sync.js` ; i18n FR/EN 10 clés `chat.*`. En route : tag OpenAPI maison `file-chat` non déclaré → `test_used_tags_are_declared` rouge (retiré, dérivé « Files »), regex navBtns de `toolbar-order.test.mjs` (échappements → `includes`), ruff UP012/TRY004/I001. Vérifié : `test_file_chat.py` 15 passed, `filechat.test.mjs` 6/6, suite 1643 passed, ruff/mypy 0, validate-imports 42 modules, CI run #1938 5/5 success (v2.57.0) | ✅ livré (en attente vérif utilisateur) |
| 2026-10-08 | #192 + BUG-109 | Fonctionnalité + correction | `backend/file_chat.py`, `backend/routers/file_chat.py`, `backend/schemas.py`, `frontend/js/filechat.js`, `frontend/js/sync.js`, `frontend/index.html`, `frontend/style.css`, `frontend/locales/{fr,en}.json`, `tests/test_file_chat.py`, `tests/frontend/filechat.test.mjs`, `docs/ROADMAP.md`, `docs/features/file-chat-169.md`, `CHANGELOG.md` | **#192 — chat : saisie auto-agrandissante + accusé de réception.** La boîte d'envoi (panneau document **et** sidebar) devient un `textarea` dont la hauteur suit le contenu (plafond 160 px puis défilement, repasse à 1 ligne après l'envoi), `Entrée` envoie / `Maj+Entrée` saute une ligne ; placeholder sidebar désormais traduit à l'init. **Accusé de réception** : chaque document de conversation porte `read = {utilisateur: ts}` (verrou global sur le read-modify-write — une lecture ne peut plus faire disparaître un message), `GET` d'historique renvoie la carte, nouveau `POST /api/chat/read` (400 sans vault/path, 403 DM hors pair, ACL vault pour un chat de fichier) marque la lecture et diffuse `chat_read` en SSE ; côté client `✓` envoyé / `✓✓` lu sur **mes** messages (exclut ma propre carte de lecture), bascule en direct via `onChatRead()` + toast « {user} a lu votre message », accusé émis à l'ouverture d'une conversation et à la réception d'un message déjà affiché. **BUG-109** : la vignette des tuiles de lien ne s'affichait jamais (CSP `img-src 'self'` + `og:image` relative résolue contre ObsiGate) → `_proxy_image()` téléverse l'image dans `chat_uploads` (same-origin, `urljoin` contre la page, garde SSRF, 2 Mo max, allow-list) et la carte reste affichée si le téléchargement échoue. Tests : pytest `test_file_chat.py` 45→**54** (`TestReadReceipts` 7, proxy image 2, 2 assertions de réponse adaptées), JSDOM `filechat.test.mjs` 16→**19** ; ruff 0, mypy 0 (113 fichiers), validate-imports 42/371, unit 13/13 | ✅ livré (en attente vérif utilisateur) |
--- ---
+2 -1
View File
@@ -1,6 +1,6 @@
# ObsiGate — Roadmap # ObsiGate — Roadmap
> **Version :** 2.60.0 | **Dernière mise à jour :** 2026-10-08 > **Version :** 2.61.0 | **Dernière mise à jour :** 2026-10-08
> **Ce fichier ne contient que le travail à venir** (🔵 En cours + ⚪ Backlog) et un index compact > **Ce fichier ne contient que le travail à venir** (🔵 En cours + ⚪ Backlog) et un index compact
> vers les fonctionnalités livrées. > vers les fonctionnalités livrées.
> - **Méthode de livraison à appliquer pour toute tâche : [DELIVERY_WORKFLOW.md](./DELIVERY_WORKFLOW.md)** > - **Méthode de livraison à appliquer pour toute tâche : [DELIVERY_WORKFLOW.md](./DELIVERY_WORKFLOW.md)**
@@ -733,6 +733,7 @@
| 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) | | 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 | 2.58.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 | 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) | | 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) |
| 192 | Chat — boîte de saisie auto-agrandissante + accusé de réception ✓✓ (retour live SSE, notification de lecture) · BUG-109 (image de tuile de lien) | 2.61.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) | | 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) | | 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) | | BUG-047 | Versionnage — source unique `VERSION` + bump SemVer automatique au commit (hooks + tag) | 2.3.0 | [DEVELOPMENT_AND_RELEASES.md](./DEVELOPMENT_AND_RELEASES.md) |
+61 -1
View File
@@ -143,7 +143,7 @@
### Tests #191 ### Tests #191
- pytest `TestDelete` (3), `TestPrivateChat` (8), `TestLinkPreview` (6, - pytest `TestDelete` (3), `TestPrivateChat` (8), `TestLinkPreview` (8,
dont le happy path avec `httpx.get` mocké — un `max_redirects` dont le happy path avec `httpx.get` mocké — un `max_redirects`
inexistante sur `httpx.get` levait un TypeError silencieusement avalé inexistante sur `httpx.get` levait un TypeError silencieusement avalé
par le `try`, testé par `test_build_preview_happy_path_parses_og`). par le `try`, testé par `test_build_preview_happy_path_parses_og`).
@@ -151,6 +151,66 @@
(droit auteur/admin), `onChatDeleted`, compteurs non-lus, (droit auteur/admin), `onChatDeleted`, compteurs non-lus,
routage DM isolé du général (16 au total). routage DM isolé du général (16 au total).
## #192 — Saisie auto-agrandissante, accusé de réception, vignette de lien (2026-10-08)
### A. Boîte de saisie qui grandit avec le texte
`<input type="text">` → `<textarea rows="1">` (panneau document créé dans
`_renderShell()`, sidebar statique dans `index.html`). `_autoGrow(el)`
pose `height: auto` puis `min(scrollHeight, 160)px` à chaque `input`, la
règle CSS `textarea.file-chat-input` ajoute `resize: none` +
`overflow-y: auto` (au-delà du plafond la zone défile) et la hauteur repasse
à une ligne après l'envoi. `Entrée` (sans `Maj`) est interceptée dans les
deux formulaires → envoi, `Maj+Entrée` garde le retour à la ligne. Le
placeholder de la sidebar est aussi appliqué à l'init (`t()`), le HTML ne
portant que le FR.
### B. Accusé de réception (✓ envoyé / ✓✓ lu)
- **Store** (`backend/file_chat.py`) : le document de conversation gagne
`read = {username: dernier_ts}` (`get_read()` / `mark_read()`), sous le
`_LOCK` du read-modify-write partagé avec l'ajout et la suppression.
- **API** : les trois `GET` d'historique renvoient `read` (schéma
`ChatHistoryResponse.read`) ; nouveau `POST /api/chat/read`
(`ChatReadResponse`) avec `_check_read()` — général = tout membre, DM =
pair uniquement (403 sinon), fichier = ACL vault + `resolve_safe_path` —
qui marque la lecture et diffuse `chat_read`
`{vault, path, user, ts, read}` en SSE.
- **Client** (`filechat.js`) : carte de lecture par surface (`_fileRead`,
`_channelRead`). Un message est « lu » dès qu'un **autre** participant a
un `ts ≥` celui du message (ma propre lecture ne compte pas).
`_messageEl()` affiche `✓`/`✓✓` (`.file-chat-status`, bleu accent quand
lu) ; `_applyRead()` fait basculer les indicateurs affichés ;
`_syncRead()` fusionne une carte reçue ; `_markRead(vault, path)` (POST
fire-and-forget) est émis à l'ouverture d'une conversation **et** à la
réception d'un message pendant qu'elle est affichée. `onChatRead()`
(relais `chat_read` ajouté dans `sync.js`) actualise en direct et
notifie l'expéditeur par toast `chat.read_by` quand un de ses messages
passe à ✓✓ — jamais pour son propre écho.
### C. BUG-109 — vignette des tuiles de lien
`img-src 'self' data: blob:` interdit toute `og:image` distante, et une
`og:image` relative était résolue contre l'origine d'ObsiGate (404).
`_proxy_image(img_url, page_url)` téléverse la vignette à l'envoi dans
`chat_uploads` via `save_attachment()` (allow-list + nom UUID) et renvoie
`/api/chat/attachment/<uuid>` — résolution `urljoin` contre la page,
garde SSRF réutilisée, plafond `PREVIEW_IMAGE_MAX` (2 Mo), dérivation de
l'extension depuis le `content-type`. Échec isolé → `image = ""`, la
carte reste (jamais de message perdu).
### Tests #192
- pytest `TestReadReceipts` (7) : roundtrip store, messages préservés,
`POST /api/chat/read` (enregistrement + restitution par le `GET`),
403 DM hors pair, 400 champs manquants, ACL vault, broadcast `chat_read`
(spy sur `sse_manager`) ; `TestLinkPreview` +2 (proxy same-origin avec
fichier écrit sur disque, dégradation sans vignette).
- JSDOM `filechat.test.mjs` +3 (19) : indicateur ✓/✓✓ (ma propre carte de
lecture exclue, aucun indicateur sur les messages des autres), bascule en
direct + notification + pas d'écho, textarea (tagName/rows, hauteur
pilotée, `Entrée` intercepté / `Maj+Entrée` non).
## Décisions ## Décisions
- **SSE plutôt qu'un second WebSocket** : le transport de #62 (EventSource - **SSE plutôt qu'un second WebSocket** : le transport de #62 (EventSource
+3 -3
View File
@@ -1100,15 +1100,15 @@
style="width: 15px; height: 15px" style="width: 15px; height: 15px"
></i> ></i>
</button> </button>
<input <textarea
type="text"
id="sidebar-panel-chat-input" id="sidebar-panel-chat-input"
class="file-chat-input" class="file-chat-input"
rows="1"
maxlength="4000" maxlength="4000"
placeholder="Écrire un message…" placeholder="Écrire un message…"
autocomplete="off" autocomplete="off"
aria-label="Écrire un message" aria-label="Écrire un message"
/> ></textarea>
<!-- #191 : bouton compact (icône) pour laisser la place à la saisie --> <!-- #191 : bouton compact (icône) pour laisser la place à la saisie -->
<button <button
type="submit" type="submit"
+144 -8
View File
@@ -16,8 +16,10 @@ import { escapeHtml, safeCreateIcons } from "./utils.js";
const PANEL_ID = "file-chat-panel"; const PANEL_ID = "file-chat-panel";
const GLOBAL_VAULT = "__global__"; const GLOBAL_VAULT = "__global__";
const GLOBAL_PATH = "general";
const DM_VAULT = "__dm__"; const DM_VAULT = "__dm__";
const UNREAD_KEY = "obsigate-chat-unread"; // {general: ts, "dm:<user>": ts} const UNREAD_KEY = "obsigate-chat-unread"; // {general: ts, "dm:<user>": ts}
const MAX_INPUT_H = 160; // #192 — plafond de la boîte de saisie auto-agrandie
let _vault = null; let _vault = null;
let _path = null; let _path = null;
@@ -31,6 +33,10 @@ let _channelLoaded = false;
let _usersCache = null; // liste des destinataires DM (une seule requête) let _usersCache = null; // liste des destinataires DM (une seule requête)
let _pendingAttach = null; let _pendingAttach = null;
// #192 — accusé de réception : {username: dernier_ts_de_lecture} par surface.
let _fileRead = {};
let _channelRead = {};
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Partagé // Partagé
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -53,6 +59,92 @@ function _currentUser() {
return (u && u.username) || ""; return (u && u.username) || "";
} }
// #192 — chemin de la conversation du canal courant (général ou privé).
function _channelVaultPath() {
return _channel.type === "dm"
? { vault: DM_VAULT, path: _dmPath(_channel.user) }
: { vault: GLOBAL_VAULT, path: GLOBAL_PATH };
}
/** Paire DM triée côté client — même règle que ``dm_path()`` serveur. */
function _dmPath(user) {
return [_currentUser(), user].sort().join("|");
}
/**
* #192 — la boîte de saisie suit le contenu : hauteur auto, plafonné à
* ``MAX_INPUT_H`` (au-delà la zone défile).
*/
function _autoGrow(el) {
el.style.height = "auto";
el.style.height = `${Math.min(el.scrollHeight, MAX_INPUT_H)}px`;
}
/** #192 — un message est lu dès qu'un AUTRE participant l'a ouvert. */
function _isRead(ts, readMap, me) {
return Object.keys(readMap || {}).some((u) => u !== me && Number(readMap[u]) >= Number(ts));
}
/**
* #192 — passe les indicateurs de mes messages (liste affichée) à ✓✓.
* Retourne le nombre de messages qui venaient de passer à « lu ».
*/
function _applyRead(list, readMap, me) {
if (!list || !readMap) return 0;
let flipped = 0;
list.querySelectorAll(".file-chat-msg.mine").forEach((el) => {
if (el.classList.contains("read")) return;
if (!_isRead(el.dataset.ts, readMap, me)) return;
el.classList.add("read");
const st = el.querySelector(".file-chat-status");
if (st) {
st.textContent = "✓✓";
st.title = t("chat.status_read");
}
flipped++;
});
return flipped;
}
/**
* #192 — fusionne *readMap* dans la conversation affichée à l'écran et
* actualise les indicateurs. Retourne le nombre de mes messages passés à lu.
*/
function _syncRead(vault, path, readMap) {
if (!readMap) return 0;
const me = _currentUser();
let flipped = 0;
const panel = document.getElementById(PANEL_ID);
if (panel && !panel.classList.contains("hidden") && _vault === vault && _path === path) {
_fileRead = Object.assign({}, _fileRead, readMap);
flipped += _applyRead(panel.querySelector(".file-chat-list"), _fileRead, me);
}
const key =
vault === DM_VAULT ? `dm:${_pairOf(path)}` : vault === GLOBAL_VAULT ? "general" : null;
const sidebar = document.getElementById("sidebar-panel-chat");
if (key && key === _channelKey() && sidebar && sidebar.classList.contains("active")) {
_channelRead = Object.assign({}, _channelRead, readMap);
flipped += _applyRead(_sidebarList(), _channelRead, me);
}
return flipped;
}
/**
* #192 — signale au serveur que la conversation est affichée (accusé de
* réception). Fire-and-forget : un réseau indisponible ne casse rien.
*/
async function _markRead(vault, path) {
try {
const data = await api("/api/chat/read", {
method: "POST",
body: JSON.stringify({ vault, path }),
});
_syncRead(vault, path, data && data.read ? data.read : {});
} catch (_) {
/* réseau indisponible : pas d'accusé de réception, ni d'erreur */
}
}
// URL dans le texte → lien cliquable (partage d'URL #190), texte sinon. // URL dans le texte → lien cliquable (partage d'URL #190), texte sinon.
function _fillBody(body, text) { function _fillBody(body, text) {
const parts = String(text || "").split(/(https?:\/\/[^\s<>"']+)/g); const parts = String(text || "").split(/(https?:\/\/[^\s<>"']+)/g);
@@ -150,17 +242,27 @@ function _previewEl(p) {
* Render one message. * Render one message.
* *delUrl* (#191) is the DELETE endpoint base for this conversation; when * *delUrl* (#191) is the DELETE endpoint base for this conversation; when
* provided the author/admin sees a trash button on their own messages. * provided the author/admin sees a trash button on their own messages.
* *readMap* (#192) drives the ✓/✓✓ receipt shown on my own messages.
*/ */
export function _messageEl(m, currentUser, delUrl) { export function _messageEl(m, currentUser, delUrl, readMap) {
const mine = currentUser && m.user === currentUser; const mine = currentUser && m.user === currentUser;
const wrap = document.createElement("div"); const wrap = document.createElement("div");
wrap.className = "file-chat-msg" + (mine ? " mine" : ""); wrap.className = "file-chat-msg" + (mine ? " mine" : "");
wrap.dataset.id = m.id || ""; wrap.dataset.id = m.id || "";
wrap.dataset.ts = m.ts || 0;
const meta = document.createElement("div"); const meta = document.createElement("div");
meta.className = "file-chat-meta"; meta.className = "file-chat-meta";
meta.innerHTML = meta.innerHTML =
`<span class="file-chat-user">${_esc(m.user)}</span>` + `<span class="file-chat-user">${_esc(m.user)}</span>` +
`<span class="file-chat-ts">${_esc(_formatTime(m.ts))}</span>`; `<span class="file-chat-ts">${_esc(_formatTime(m.ts))}</span>`;
if (mine) {
const read = _isRead(m.ts, readMap, currentUser);
const status = document.createElement("span");
status.className = "file-chat-status" + (read ? " read" : "");
status.textContent = read ? "✓✓" : "✓";
status.title = t(read ? "chat.status_read" : "chat.status_sent");
meta.appendChild(status);
}
if (delUrl && _canDelete(m, currentUser)) { if (delUrl && _canDelete(m, currentUser)) {
const del = document.createElement("button"); const del = document.createElement("button");
del.type = "button"; del.type = "button";
@@ -182,7 +284,7 @@ export function _messageEl(m, currentUser, delUrl) {
return wrap; return wrap;
} }
export function renderChatMessages(container, messages, currentUser, delUrl) { export function renderChatMessages(container, messages, currentUser, delUrl, readMap) {
container.innerHTML = ""; container.innerHTML = "";
if (!messages || messages.length === 0) { if (!messages || messages.length === 0) {
const empty = document.createElement("div"); const empty = document.createElement("div");
@@ -191,7 +293,7 @@ export function renderChatMessages(container, messages, currentUser, delUrl) {
container.appendChild(empty); container.appendChild(empty);
return; return;
} }
messages.forEach((m) => container.appendChild(_messageEl(m, currentUser, delUrl))); messages.forEach((m) => container.appendChild(_messageEl(m, currentUser, delUrl, readMap)));
container.scrollTop = container.scrollHeight; container.scrollTop = container.scrollHeight;
} }
@@ -227,7 +329,9 @@ async function _load() {
const data = await api( const data = await api(
`/api/file/${encodeURIComponent(_vault)}/chat?path=${encodeURIComponent(_path)}`, `/api/file/${encodeURIComponent(_vault)}/chat?path=${encodeURIComponent(_path)}`,
); );
renderChatMessages(list, data.messages || [], _currentUser()); _fileRead = data.read || {};
renderChatMessages(list, data.messages || [], _currentUser(), null, _fileRead);
_markRead(_vault, _path); // #192 — accusé de réception
} catch (_) { } catch (_) {
list.innerHTML = ""; list.innerHTML = "";
const err = document.createElement("div"); const err = document.createElement("div");
@@ -267,12 +371,20 @@ function _renderShell(panel) {
const form = document.createElement("form"); const form = document.createElement("form");
form.className = "file-chat-form"; form.className = "file-chat-form";
const input = document.createElement("input"); // #192 — textarea auto-agrandie : on lit le message entier avant l'envoi.
input.type = "text"; const input = document.createElement("textarea");
input.className = "file-chat-input"; input.className = "file-chat-input";
input.rows = 1;
input.placeholder = t("chat.placeholder"); input.placeholder = t("chat.placeholder");
input.maxLength = 4000; input.maxLength = 4000;
input.setAttribute("aria-label", t("chat.placeholder")); input.setAttribute("aria-label", t("chat.placeholder"));
input.addEventListener("input", () => _autoGrow(input));
input.addEventListener("keydown", (e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault(); // Shift+Enter = retour à la ligne
_send(input);
}
});
const send = document.createElement("button"); const send = document.createElement("button");
send.type = "submit"; send.type = "submit";
send.className = "file-chat-send file-chat-send-icon"; send.className = "file-chat-send file-chat-send-icon";
@@ -338,6 +450,7 @@ async function _send(input) {
body: JSON.stringify({ path: _path, text }), body: JSON.stringify({ path: _path, text }),
}); });
input.value = ""; input.value = "";
_autoGrow(input); // #192 — repasse à la hauteur d'une ligne
_appendMessage(_panel().querySelector(".file-chat-list"), data.message); _appendMessage(_panel().querySelector(".file-chat-list"), data.message);
} catch (_) { } catch (_) {
showToast(t("chat.send_error"), "error"); showToast(t("chat.send_error"), "error");
@@ -417,8 +530,11 @@ export async function openSidebarChat() {
const data = await api(_channelUrl()); const data = await api(_channelUrl());
_channelMessages = (data.messages || []).slice(); _channelMessages = (data.messages || []).slice();
_channelLoaded = true; _channelLoaded = true;
renderChatMessages(list, _channelMessages, _currentUser(), _channelDelUrl()); _channelRead = data.read || {};
renderChatMessages(list, _channelMessages, _currentUser(), _channelDelUrl(), _channelRead);
_clearUnread(); _clearUnread();
const vp = _channelVaultPath();
_markRead(vp.vault, vp.path); // #192 — accusé de réception
} catch (_) { } catch (_) {
list.innerHTML = ""; list.innerHTML = "";
const err = document.createElement("div"); const err = document.createElement("div");
@@ -448,7 +564,7 @@ export function filterChatMessages(query) {
((m.text || "") + " " + (m.user || "")).toLowerCase().includes(q), ((m.text || "") + " " + (m.user || "")).toLowerCase().includes(q),
) )
: _channelMessages; : _channelMessages;
renderChatMessages(list, shown, _currentUser(), _channelDelUrl()); renderChatMessages(list, shown, _currentUser(), _channelDelUrl(), _channelRead);
} }
function _sendInput() { function _sendInput() {
@@ -487,6 +603,7 @@ export async function sendGeneralMessage() {
try { try {
const data = await api(_channelUrl(), { method: "POST", body: JSON.stringify(body) }); const data = await api(_channelUrl(), { method: "POST", body: JSON.stringify(body) });
input.value = ""; input.value = "";
_autoGrow(input); // #192 — repasse à la hauteur d'une ligne
_pendingAttach = null; _pendingAttach = null;
_renderPending(); _renderPending();
const list = _sidebarList(); const list = _sidebarList();
@@ -584,6 +701,10 @@ export function initSidebarChat() {
const form = document.getElementById("sidebar-panel-chat-form"); const form = document.getElementById("sidebar-panel-chat-form");
if (!form) return; if (!form) return;
const input = document.getElementById("sidebar-panel-chat-input"); const input = document.getElementById("sidebar-panel-chat-input");
if (input) {
input.placeholder = t("chat.placeholder"); // le HTML embarque le FR
input.addEventListener("input", () => _autoGrow(input)); // #192
}
form.addEventListener("submit", (e) => { form.addEventListener("submit", (e) => {
e.preventDefault(); e.preventDefault();
sendGeneralMessage(); sendGeneralMessage();
@@ -616,6 +737,8 @@ export function onChatMessage(data) {
if (visible && isActive) { if (visible && isActive) {
_appendMessage(_sidebarList(), data.message, _channelDelUrl()); _appendMessage(_sidebarList(), data.message, _channelDelUrl());
_clearUnread(); _clearUnread();
// #192 — le canal est affiché : on accuse réception aussitôt.
if (data.message.user !== _currentUser()) _markRead(data.vault, data.path);
} else if (data.message.user !== _currentUser()) { } else if (data.message.user !== _currentUser()) {
_bumpUnread(key); _bumpUnread(key);
refreshUnreadBadge(); refreshUnreadBadge();
@@ -632,12 +755,25 @@ export function onChatMessage(data) {
if (isOpen) { if (isOpen) {
const list = panel.querySelector(".file-chat-list"); const list = panel.querySelector(".file-chat-list");
if (list) _appendMessage(list, data.message); if (list) _appendMessage(list, data.message);
// #192 — panneau affiché : accusé de réception immédiat.
if (data.message.user !== _currentUser()) _markRead(data.vault, data.path);
return; return;
} }
if (data.message.user === _currentUser()) return; // son propre message if (data.message.user === _currentUser()) return; // son propre message
showToast(t("chat.new_message", { user: data.message.user }), "info"); showToast(t("chat.new_message", { user: data.message.user }), "info");
} }
/**
* #192 — SSE `chat_read` : quelqu'un vient d'ouvrir la conversation. Les
* indicateurs de MES messages passent à ✓✓ et, si un message change d'état,
* l'expéditeur est notifié.
*/
export function onChatRead(data) {
if (!data || !data.user || data.user === _currentUser()) return; // notre propre écho
const flipped = _syncRead(data.vault, data.path, data.read || { [data.user]: data.ts });
if (flipped) showToast(t("chat.read_by", { user: data.user }), "info");
}
/** Le path DM est « alice|bob » trié : on en déduit l'interlocuteur. */ /** Le path DM est « alice|bob » trié : on en déduit l'interlocuteur. */
function _pairOf(path) { function _pairOf(path) {
const me = _currentUser(); const me = _currentUser();
+10
View File
@@ -140,6 +140,16 @@ export const IndexUpdateManager = (() => {
} }
}); });
// #192 — accusé de réception (un participant vient d'ouvrir la conversation)
eventSource.addEventListener("chat_read", (e) => {
try {
const data = JSON.parse(e.data);
import("./filechat.js").then((m) => m.onChatRead(data)).catch(() => {});
} catch (err) {
console.error("SSE parse error:", err);
}
});
eventSource.onerror = () => { eventSource.onerror = () => {
connectionState = "disconnected"; connectionState = "disconnected";
_updateBadge(); _updateBadge();
+3
View File
@@ -333,9 +333,12 @@
"chat.new_message": "New message from {user}", "chat.new_message": "New message from {user}",
"chat.placeholder": "Write a message…", "chat.placeholder": "Write a message…",
"chat.placeholder_dm": "Private message to {user}…", "chat.placeholder_dm": "Private message to {user}…",
"chat.read_by": "{user} read your message",
"chat.send": "Send", "chat.send": "Send",
"chat.send_error": "Failed to send the message", "chat.send_error": "Failed to send the message",
"chat.tab": "General chat", "chat.tab": "General chat",
"chat.status_read": "Read",
"chat.status_sent": "Sent",
"chat.title": "Discussion", "chat.title": "Discussion",
"common.actions": "Actions", "common.actions": "Actions",
"common.all": "All", "common.all": "All",
+3
View File
@@ -333,9 +333,12 @@
"chat.new_message": "Nouveau message de {user}", "chat.new_message": "Nouveau message de {user}",
"chat.placeholder": "Écrire un message…", "chat.placeholder": "Écrire un message…",
"chat.placeholder_dm": "Message privé à {user}…", "chat.placeholder_dm": "Message privé à {user}…",
"chat.read_by": "{user} a lu votre message",
"chat.send": "Envoyer", "chat.send": "Envoyer",
"chat.send_error": "Échec de l'envoi du message", "chat.send_error": "Échec de l'envoi du message",
"chat.tab": "Chat général", "chat.tab": "Chat général",
"chat.status_read": "Lu",
"chat.status_sent": "Envoyé",
"chat.title": "Discussion", "chat.title": "Discussion",
"common.actions": "Actions", "common.actions": "Actions",
"common.all": "Tous", "common.all": "Tous",
+19
View File
@@ -13838,6 +13838,14 @@ body.reading-mode .file-actions {
font-weight: 600; font-weight: 600;
color: var(--accent, #58a6ff); color: var(--accent, #58a6ff);
} }
/* #192 — accusé de réception sur mes messages : ✓ envoyé, ✓✓ lu */
.file-chat-status {
color: var(--text-muted, #8b949e);
letter-spacing: -1px;
}
.file-chat-status.read {
color: var(--accent, #58a6ff);
}
.file-chat-body { .file-chat-body {
font-size: 0.88rem; font-size: 0.88rem;
color: var(--text-primary, #e6edf3); color: var(--text-primary, #e6edf3);
@@ -13855,11 +13863,22 @@ body.reading-mode .file-actions {
min-width: 0; min-width: 0;
padding: 8px 10px; padding: 8px 10px;
font-size: 16px; /* anti-zoom iOS */ font-size: 16px; /* anti-zoom iOS */
font-family: inherit;
line-height: 1.4;
border: 1px solid var(--border, #21262d); border: 1px solid var(--border, #21262d);
border-radius: 8px; border-radius: 8px;
background: var(--bg-secondary, #161b22); background: var(--bg-secondary, #161b22);
color: var(--text-primary, #e6edf3); color: var(--text-primary, #e6edf3);
} }
/* #192 — textarea auto-agrandie (hauteur pilotée en JS, plafond ci-dessous) */
textarea.file-chat-input {
display: block;
resize: none;
overflow-y: auto;
max-height: 160px;
white-space: pre-wrap;
word-break: break-word;
}
.file-chat-input:focus { .file-chat-input:focus {
outline: none; outline: none;
border-color: var(--accent, #58a6ff); border-color: var(--accent, #58a6ff);
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "obsigate", "name": "obsigate",
"version": "2.60.0", "version": "2.61.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.", "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", "main": "patch.js",
"directories": { "directories": {
+62 -2
View File
@@ -25,7 +25,7 @@ const dom = new JSDOM(
<div class="sidebar-tab-panel active" id="sidebar-panel-chat"> <div class="sidebar-tab-panel active" id="sidebar-panel-chat">
<div id="sidebar-panel-chat-list" class="file-chat-list"></div> <div id="sidebar-panel-chat-list" class="file-chat-list"></div>
<div id="sidebar-panel-chat-pending"></div> <div id="sidebar-panel-chat-pending"></div>
<form id="sidebar-panel-chat-form"><input id="sidebar-panel-chat-input" /></form> <form id="sidebar-panel-chat-form"><textarea id="sidebar-panel-chat-input"></textarea></form>
</div> </div>
<button id="sidebar-tab-chat"></button> <button id="sidebar-tab-chat"></button>
</body></html>`, </body></html>`,
@@ -77,7 +77,7 @@ const mod = await import("../../frontend/js/filechat.js");
const { const {
renderChatMessages, onChatMessage, openFileChat, closeFileChat, toggleFileChat, renderChatMessages, onChatMessage, openFileChat, closeFileChat, toggleFileChat,
followFileChat, openSidebarChat, filterChatMessages, refreshUnreadBadge, followFileChat, openSidebarChat, filterChatMessages, refreshUnreadBadge,
onChatDeleted, onChatDeleted, onChatRead,
} = mod; } = mod;
let pass = 0; let pass = 0;
@@ -278,6 +278,66 @@ test("#191 — DM routed by pair path, isolated from general", async () => {
assert.strictEqual(list.querySelectorAll(".file-chat-msg").length, before); assert.strictEqual(list.querySelectorAll(".file-chat-msg").length, before);
}); });
// --- #192 : accusé de réception + boîte de saisie auto-agrandie --------------
test("#192 — receipt shows ✓ then ✓✓ once another participant read it", () => {
const list = document.createElement("div");
renderChatMessages(list, [{ id: "r1", user: "bruno", text: "coucou", ts: 100 }], "bruno", null, {});
let st = list.querySelector(".file-chat-status");
assert.strictEqual(st.textContent, "✓");
assert.strictEqual(st.classList.contains("read"), false);
// MY OWN read timestamp must never count as "the peer read it"
renderChatMessages(list, [{ id: "r2", user: "bruno", text: "coucou", ts: 100 }], "bruno", null, { bruno: 500 });
assert.strictEqual(list.querySelector(".file-chat-status").textContent, "✓");
renderChatMessages(list, [{ id: "r3", user: "bruno", text: "coucou", ts: 100 }], "bruno", null, { alice: 150 });
st = list.querySelector(".file-chat-status");
assert.strictEqual(st.textContent, "✓✓");
assert.strictEqual(st.classList.contains("read"), true);
// messages of others carry no receipt
renderChatMessages(list, [{ id: "r4", user: "alice", text: "ok", ts: 100 }], "bruno", null, {});
assert.strictEqual(list.querySelector(".file-chat-status"), null);
});
test("#192 — onChatRead flips ✓ → ✓✓ live and notifies the sender", async () => {
await openSidebarChat();
const list = document.getElementById("sidebar-panel-chat-list");
renderChatMessages(list, [{ id: "s1", user: "bruno", text: "msg", ts: 200 }], "bruno", "/api/chat", {});
assert.strictEqual(list.querySelector(".file-chat-status").textContent, "✓");
const container = document.getElementById("toast-container");
const before = container.children.length;
onChatRead({ vault: "__global__", path: "general", user: "alice", ts: 300, read: { bruno: 250, alice: 300 } });
assert.strictEqual(list.querySelector(".file-chat-status").textContent, "✓✓");
assert.ok(container.children.length > before, "notification de lecture affichée");
// notre propre écho ne notifie pas
const same = container.children.length;
onChatRead({ vault: "__global__", path: "general", user: "bruno", ts: 310, read: { bruno: 310 } });
assert.strictEqual(container.children.length, same);
});
test("#192 — saisie en textarea auto-agrandie, Enter envoie", async () => {
assert.strictEqual(document.getElementById("sidebar-panel-chat-input").tagName, "TEXTAREA");
await openFileChat("V", "grow.md");
const panel = document.getElementById("file-chat-panel");
const input = panel.querySelector(".file-chat-input");
assert.strictEqual(input.tagName, "TEXTAREA");
assert.strictEqual(input.rows, 1);
input.value = "ligne1\nligne2";
input.dispatchEvent(new w.Event("input", { bubbles: true }));
assert.ok(input.style.height, "hauteur pilotée en JS");
let prevented = false;
const ev = new w.KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true });
Object.defineProperty(ev, "preventDefault", { value: () => { prevented = true; } });
input.value = ""; // rien à envoyer : on ne teste que le geste
input.dispatchEvent(ev);
assert.strictEqual(prevented, true, "Enter (sans Shift) est intercepté");
const nl = new w.KeyboardEvent("keydown", { key: "Enter", shiftKey: true, bubbles: true, cancelable: true });
let nlPrevented = false;
Object.defineProperty(nl, "preventDefault", { value: () => { nlPrevented = true; } });
input.dispatchEvent(nl);
assert.strictEqual(nlPrevented, false, "Shift+Enter garde le retour à la ligne");
closeFileChat();
});
// Sequential execution: each case depends on panel state left by the previous. // Sequential execution: each case depends on panel state left by the previous.
for (const [name, fn] of cases) { for (const [name, fn] of cases) {
try { try {
+117 -2
View File
@@ -85,7 +85,7 @@ class TestRoutes:
def test_history_requires_existing_vault(self, client, test_vault_dir): def test_history_requires_existing_vault(self, client, test_vault_dir):
r = client.get("/api/file/TestVault/chat", params={"path": "note1.md"}) r = client.get("/api/file/TestVault/chat", params={"path": "note1.md"})
assert r.status_code == 200 assert r.status_code == 200
assert r.json() == {"messages": []} assert r.json() == {"messages": [], "read": {}}
def test_post_and_get_roundtrip(self, client, test_vault_dir): def test_post_and_get_roundtrip(self, client, test_vault_dir):
r = client.post( r = client.post(
@@ -137,7 +137,7 @@ class TestGlobalChat:
def test_history_empty_by_default(self, client, test_vault_dir): def test_history_empty_by_default(self, client, test_vault_dir):
r = client.get("/api/chat") r = client.get("/api/chat")
assert r.status_code == 200 assert r.status_code == 200
assert r.json() == {"messages": []} assert r.json() == {"messages": [], "read": {}}
def test_post_and_get_roundtrip(self, client, test_vault_dir): def test_post_and_get_roundtrip(self, client, test_vault_dir):
r = client.post("/api/chat", json={"text": "Bonjour à tous"}) r = client.post("/api/chat", json={"text": "Bonjour à tous"})
@@ -364,3 +364,118 @@ class TestLinkPreview:
"image": "", "image": "",
"site": "Site", "site": "Site",
} }
def test_preview_image_is_proxied_same_origin(self, monkeypatch):
"""BUG-109: a remote og:image is blocked by ``img-src 'self'``.
A *relative* og:image must be resolved against the page and downloaded
into ``chat_uploads`` so the browser can load it same-origin.
"""
monkeypatch.setattr(_store, "_PREVIEW_CACHE", {})
from backend.tools import web as _web
monkeypatch.setattr(_web, "_assert_public_http_url", lambda url: None)
class _Page:
status_code = 200
text = (
'<meta property="og:title" content="Titre">'
'<meta property="og:image" content="/img/pic.png">'
)
class _Img:
status_code = 200
content = b"\x89PNG-fake-bytes"
headers = {"content-type": "image/png"}
def _fake_get(url, **kw):
return _Img() if url.endswith(".png") else _Page()
monkeypatch.setattr(_store.httpx, "get", _fake_get)
preview = _store.build_preview("voir https://site.test/a")
assert preview["title"] == "Titre"
assert preview["image"].startswith("/api/chat/attachment/")
assert preview["image"].endswith(".png")
stored = _store.UPLOAD_DIR / preview["image"].rsplit("/", 1)[1]
assert stored.exists()
assert stored.read_bytes() == b"\x89PNG-fake-bytes"
def test_preview_image_failure_keeps_the_card(self, monkeypatch):
"""A dead thumbnail degrades to an empty image, never a lost card."""
monkeypatch.setattr(_store, "_PREVIEW_CACHE", {})
from backend.tools import web as _web
monkeypatch.setattr(_web, "_assert_public_http_url", lambda url: None)
class _Page:
status_code = 200
text = (
'<meta property="og:title" content="Titre">'
'<meta property="og:image" content="https://cdn.test/broken.jpg">'
)
class _Dead:
status_code = 404
content = b""
headers = {}
def _fake_get(url, **kw):
return _Dead() if url.endswith(".jpg") else _Page()
monkeypatch.setattr(_store.httpx, "get", _fake_get)
preview = _store.build_preview("voir https://site.test/b")
assert preview["title"] == "Titre"
assert preview["image"] == ""
# --- #192 : accusé de réception ---------------------------------------------
class TestReadReceipts:
def test_mark_read_roundtrip(self, tmp_path, monkeypatch):
monkeypatch.setattr(_store, "CHAT_DIR", tmp_path / "chats")
assert _store.get_read("V", "a.md") == {}
read = _store.mark_read("V", "a.md", "bruno")
assert read["bruno"] > 0
assert _store.get_read("V", "a.md") == read
def test_mark_read_preserves_messages(self, tmp_path, monkeypatch):
monkeypatch.setattr(_store, "CHAT_DIR", tmp_path / "chats")
_store.add_message("V", "a.md", "alice", "salut")
_store.mark_read("V", "a.md", "bruno")
assert [m["text"] for m in _store.get_messages("V", "a.md")] == ["salut"]
assert "bruno" in _store.get_read("V", "a.md")
def test_post_read_records_for_the_caller(self, client, test_vault_dir):
r = client.post("/api/chat/read", json={"vault": "__global__", "path": "general"})
assert r.status_code == 200, r.text
read = r.json()["read"]
assert read["anonymous"] > 0 # auth disabled in tests → fake user
# the history endpoint exposes the same map (#192)
assert client.get("/api/chat").json()["read"] == read
def test_post_read_refuses_a_dm_we_are_not_in(self, client, test_vault_dir):
r = client.post("/api/chat/read", json={"vault": "__dm__", "path": "alice|bob"})
assert r.status_code == 403
def test_post_read_requires_vault_and_path(self, client, test_vault_dir):
assert client.post("/api/chat/read", json={}).status_code == 400
def test_post_read_follows_vault_acl(self, client, test_vault_dir):
r = client.post("/api/chat/read", json={"vault": "TestVault", "path": "note1.md"})
assert r.status_code == 200, r.text
assert "anonymous" in r.json()["read"]
def test_post_read_broadcasts_chat_read(self, client, test_vault_dir, monkeypatch):
from backend.routers import file_chat as _router
events = []
class _Spy:
async def broadcast(self, event, data):
events.append((event, data))
monkeypatch.setattr(_router, "sse_manager", _Spy())
client.post("/api/chat/read", json={"vault": "__global__", "path": "general"})
assert [e for e, _ in events] == ["chat_read"]
assert events[0][1]["user"] == "anonymous"
assert events[0][1]["vault"] == "__global__"