|
|
|
@@ -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();
|
|
|
|
|