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
+61 -1
View File
@@ -143,7 +143,7 @@
### 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`
inexistante sur `httpx.get` levait un TypeError silencieusement avalé
par le `try`, testé par `test_build_preview_happy_path_parses_og`).
@@ -151,6 +151,66 @@
(droit auteur/admin), `onChatDeleted`, compteurs non-lus,
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
- **SSE plutôt qu'un second WebSocket** : le transport de #62 (EventSource