feat: chat — saisie auto-agrandissante, accusé de réception ✓✓, vignette de tuile #192 (BUG-109)
This commit is contained in:
+38
-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).
|
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
@@ -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.
|
||||||
|
|
||||||
[]()
|
[]()
|
||||||
[](https://opensource.org/licenses/MIT)
|
[](https://opensource.org/licenses/MIT)
|
||||||
[](https://www.docker.com/)
|
[](https://www.docker.com/)
|
||||||
[](https://www.python.org/)
|
[](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*
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
[]()
|
[]()
|
||||||
[](https://opensource.org/licenses/MIT)
|
[](https://opensource.org/licenses/MIT)
|
||||||
[](https://www.docker.com/)
|
[](https://www.docker.com/)
|
||||||
[](https://www.python.org/)
|
[](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*
|
||||||
|
|||||||
+108
-19
@@ -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"]:
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
Generated
+1
-1
@@ -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
@@ -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,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",
|
||||||
|
|||||||
@@ -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
@@ -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) |
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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();
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
@@ -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": {
|
||||||
|
|||||||
@@ -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
@@ -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__"
|
||||||
|
|||||||
Reference in New Issue
Block a user