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
+3 -3
View File
@@ -1100,15 +1100,15 @@
style="width: 15px; height: 15px"
></i>
</button>
<input
type="text"
<textarea
id="sidebar-panel-chat-input"
class="file-chat-input"
rows="1"
maxlength="4000"
placeholder="Écrire un message…"
autocomplete="off"
aria-label="Écrire un message"
/>
></textarea>
<!-- #191 : bouton compact (icône) pour laisser la place à la saisie -->
<button
type="submit"
+144 -8
View File
@@ -16,8 +16,10 @@ import { escapeHtml, safeCreateIcons } from "./utils.js";
const PANEL_ID = "file-chat-panel";
const GLOBAL_VAULT = "__global__";
const GLOBAL_PATH = "general";
const DM_VAULT = "__dm__";
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 _path = null;
@@ -31,6 +33,10 @@ let _channelLoaded = false;
let _usersCache = null; // liste des destinataires DM (une seule requête)
let _pendingAttach = null;
// #192 — accusé de réception : {username: dernier_ts_de_lecture} par surface.
let _fileRead = {};
let _channelRead = {};
// ---------------------------------------------------------------------------
// Partagé
// ---------------------------------------------------------------------------
@@ -53,6 +59,92 @@ function _currentUser() {
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.
function _fillBody(body, text) {
const parts = String(text || "").split(/(https?:\/\/[^\s<>"']+)/g);
@@ -150,17 +242,27 @@ function _previewEl(p) {
* Render one message.
* *delUrl* (#191) is the DELETE endpoint base for this conversation; when
* provided the author/admin sees a trash button on their own messages.
* *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 wrap = document.createElement("div");
wrap.className = "file-chat-msg" + (mine ? " mine" : "");
wrap.dataset.id = m.id || "";
wrap.dataset.ts = m.ts || 0;
const meta = document.createElement("div");
meta.className = "file-chat-meta";
meta.innerHTML =
`<span class="file-chat-user">${_esc(m.user)}</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)) {
const del = document.createElement("button");
del.type = "button";
@@ -182,7 +284,7 @@ export function _messageEl(m, currentUser, delUrl) {
return wrap;
}
export function renderChatMessages(container, messages, currentUser, delUrl) {
export function renderChatMessages(container, messages, currentUser, delUrl, readMap) {
container.innerHTML = "";
if (!messages || messages.length === 0) {
const empty = document.createElement("div");
@@ -191,7 +293,7 @@ export function renderChatMessages(container, messages, currentUser, delUrl) {
container.appendChild(empty);
return;
}
messages.forEach((m) => container.appendChild(_messageEl(m, currentUser, delUrl)));
messages.forEach((m) => container.appendChild(_messageEl(m, currentUser, delUrl, readMap)));
container.scrollTop = container.scrollHeight;
}
@@ -227,7 +329,9 @@ async function _load() {
const data = await api(
`/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 (_) {
list.innerHTML = "";
const err = document.createElement("div");
@@ -267,12 +371,20 @@ function _renderShell(panel) {
const form = document.createElement("form");
form.className = "file-chat-form";
const input = document.createElement("input");
input.type = "text";
// #192 — textarea auto-agrandie : on lit le message entier avant l'envoi.
const input = document.createElement("textarea");
input.className = "file-chat-input";
input.rows = 1;
input.placeholder = t("chat.placeholder");
input.maxLength = 4000;
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");
send.type = "submit";
send.className = "file-chat-send file-chat-send-icon";
@@ -338,6 +450,7 @@ async function _send(input) {
body: JSON.stringify({ path: _path, text }),
});
input.value = "";
_autoGrow(input); // #192 — repasse à la hauteur d'une ligne
_appendMessage(_panel().querySelector(".file-chat-list"), data.message);
} catch (_) {
showToast(t("chat.send_error"), "error");
@@ -417,8 +530,11 @@ export async function openSidebarChat() {
const data = await api(_channelUrl());
_channelMessages = (data.messages || []).slice();
_channelLoaded = true;
renderChatMessages(list, _channelMessages, _currentUser(), _channelDelUrl());
_channelRead = data.read || {};
renderChatMessages(list, _channelMessages, _currentUser(), _channelDelUrl(), _channelRead);
_clearUnread();
const vp = _channelVaultPath();
_markRead(vp.vault, vp.path); // #192 — accusé de réception
} catch (_) {
list.innerHTML = "";
const err = document.createElement("div");
@@ -448,7 +564,7 @@ export function filterChatMessages(query) {
((m.text || "") + " " + (m.user || "")).toLowerCase().includes(q),
)
: _channelMessages;
renderChatMessages(list, shown, _currentUser(), _channelDelUrl());
renderChatMessages(list, shown, _currentUser(), _channelDelUrl(), _channelRead);
}
function _sendInput() {
@@ -487,6 +603,7 @@ export async function sendGeneralMessage() {
try {
const data = await api(_channelUrl(), { method: "POST", body: JSON.stringify(body) });
input.value = "";
_autoGrow(input); // #192 — repasse à la hauteur d'une ligne
_pendingAttach = null;
_renderPending();
const list = _sidebarList();
@@ -584,6 +701,10 @@ export function initSidebarChat() {
const form = document.getElementById("sidebar-panel-chat-form");
if (!form) return;
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) => {
e.preventDefault();
sendGeneralMessage();
@@ -616,6 +737,8 @@ export function onChatMessage(data) {
if (visible && isActive) {
_appendMessage(_sidebarList(), data.message, _channelDelUrl());
_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()) {
_bumpUnread(key);
refreshUnreadBadge();
@@ -632,12 +755,25 @@ export function onChatMessage(data) {
if (isOpen) {
const list = panel.querySelector(".file-chat-list");
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;
}
if (data.message.user === _currentUser()) return; // son propre message
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. */
function _pairOf(path) {
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 = () => {
connectionState = "disconnected";
_updateBadge();
+3
View File
@@ -333,9 +333,12 @@
"chat.new_message": "New message from {user}",
"chat.placeholder": "Write a message…",
"chat.placeholder_dm": "Private message to {user}…",
"chat.read_by": "{user} read your message",
"chat.send": "Send",
"chat.send_error": "Failed to send the message",
"chat.tab": "General chat",
"chat.status_read": "Read",
"chat.status_sent": "Sent",
"chat.title": "Discussion",
"common.actions": "Actions",
"common.all": "All",
+3
View File
@@ -333,9 +333,12 @@
"chat.new_message": "Nouveau message de {user}",
"chat.placeholder": "Écrire un message…",
"chat.placeholder_dm": "Message privé à {user}…",
"chat.read_by": "{user} a lu votre message",
"chat.send": "Envoyer",
"chat.send_error": "Échec de l'envoi du message",
"chat.tab": "Chat général",
"chat.status_read": "Lu",
"chat.status_sent": "Envoyé",
"chat.title": "Discussion",
"common.actions": "Actions",
"common.all": "Tous",
+19
View File
@@ -13838,6 +13838,14 @@ body.reading-mode .file-actions {
font-weight: 600;
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 {
font-size: 0.88rem;
color: var(--text-primary, #e6edf3);
@@ -13855,11 +13863,22 @@ body.reading-mode .file-actions {
min-width: 0;
padding: 8px 10px;
font-size: 16px; /* anti-zoom iOS */
font-family: inherit;
line-height: 1.4;
border: 1px solid var(--border, #21262d);
border-radius: 8px;
background: var(--bg-secondary, #161b22);
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 {
outline: none;
border-color: var(--accent, #58a6ff);