896 lines
30 KiB
JavaScript
896 lines
30 KiB
JavaScript
// frontend/js/filechat.js — Chat fichier (#169) + chat général sidebar (#190)
|
|
//
|
|
// Deux surfaces, un module :
|
|
// 1. Panneau latéral du document (#169) — historique + saisie, suivi du
|
|
// document ouvert (followFileChat le re-cible à chaque renderFile).
|
|
// 2. Onglet « Chat » de la sidebar (#190) — conversation générale, pièces
|
|
// jointes (image/vidéo/URL), indicateur de messages non lus, recherche
|
|
// via la barre de filtre de la sidebar.
|
|
//
|
|
// Les deux reçoivent les messages en direct par SSE (`chat_message`, relayé
|
|
// depuis sync.js) : vault `__global__` = chat général, sinon chat fichier.
|
|
import { api, AuthManager } from "./auth.js";
|
|
import { t } from "./i18n.js";
|
|
import { showToast, TabManager } from "./ui.js";
|
|
import { escapeHtml, safeCreateIcons } from "./utils.js";
|
|
import { icon } from "./viewer.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;
|
|
let _title = "";
|
|
let _sending = false;
|
|
|
|
// État du chat sidebar (#190 B + #191 : canaux général / privé)
|
|
let _channel = { type: "general" }; // ou {type: "dm", user: "bob"}
|
|
let _channelMessages = [];
|
|
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é
|
|
// ---------------------------------------------------------------------------
|
|
function _esc(s) {
|
|
return escapeHtml(String(s == null ? "" : s));
|
|
}
|
|
|
|
function _formatTime(ts) {
|
|
try {
|
|
return new Date(ts * 1000).toLocaleString(undefined, {
|
|
day: "2-digit", month: "2-digit", hour: "2-digit", minute: "2-digit",
|
|
});
|
|
} catch (_) {
|
|
return "";
|
|
}
|
|
}
|
|
|
|
function _currentUser() {
|
|
const u = AuthManager.getUser();
|
|
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);
|
|
parts.forEach((part) => {
|
|
if (!part) return;
|
|
if (/^https?:\/\//.test(part)) {
|
|
const a = document.createElement("a");
|
|
a.href = part;
|
|
a.textContent = part;
|
|
a.target = "_blank";
|
|
a.rel = "noopener noreferrer";
|
|
a.className = "file-chat-link";
|
|
body.appendChild(a);
|
|
} else {
|
|
body.appendChild(document.createTextNode(part));
|
|
}
|
|
});
|
|
}
|
|
|
|
// #110 — image jointe : clic = même visualiseur que pour une image de vault
|
|
// (ouvert dans un onglet de l'app), boutons discrets (icônes seules) sous
|
|
// l'image, alignés à droite.
|
|
function _attachEl(att) {
|
|
if (!att || !att.url) return null;
|
|
if (att.kind === "image") {
|
|
const container = document.createElement("div");
|
|
container.className = "file-chat-image-container";
|
|
|
|
const img = document.createElement("img");
|
|
img.src = att.url;
|
|
img.alt = att.name || "image";
|
|
img.loading = "lazy";
|
|
img.className = "file-chat-image";
|
|
img.title = t("chat.view_image");
|
|
img.addEventListener("click", () => TabManager.openChatImage(att));
|
|
container.appendChild(img);
|
|
|
|
const actions = document.createElement("div");
|
|
actions.className = "file-chat-image-actions";
|
|
const mkBtn = (iconName, label, cls, onClick) => {
|
|
const b = document.createElement("button");
|
|
b.type = "button";
|
|
b.className = `file-chat-image-btn ${cls}`;
|
|
b.title = label;
|
|
b.setAttribute("aria-label", label);
|
|
b.appendChild(icon(iconName, 14));
|
|
b.addEventListener("click", (e) => { e.stopPropagation(); onClick(); });
|
|
return b;
|
|
};
|
|
actions.appendChild(mkBtn("download", t("chat.download_image"), "file-chat-image-download", () => {
|
|
const a = document.createElement("a");
|
|
a.href = att.url;
|
|
a.download = att.name || "image";
|
|
document.body.appendChild(a);
|
|
a.click();
|
|
a.remove();
|
|
}));
|
|
actions.appendChild(mkBtn("maximize", t("chat.view_image"), "file-chat-open", () => TabManager.openChatImage(att)));
|
|
container.appendChild(actions);
|
|
|
|
return container;
|
|
}
|
|
if (att.kind === "video") {
|
|
const v = document.createElement("video");
|
|
v.src = att.url;
|
|
v.controls = true;
|
|
v.preload = "metadata";
|
|
v.className = "file-chat-video";
|
|
return v;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// #191 — suppression : auteur ou admin, avec confirmation native.
|
|
function _canDelete(m, currentUser) {
|
|
const u = AuthManager.getUser();
|
|
if (!u || !u.username) return false;
|
|
return u.role === "admin" || m.user === u.username;
|
|
}
|
|
|
|
// #110 — suppression différée : la ligne passe tout de suite à « supprimé »
|
|
// avec un bouton Annuler ; le DELETE n'est envoyé qu'au bout de 10 s.
|
|
// ponytail: un rechargement complet pendant la fenêtre annule la suppression
|
|
// (le message reste côté serveur) — connu, acceptable.
|
|
const _pendingDeletes = new Map(); // id -> {timer}
|
|
|
|
export function _deleteMessage(delUrl, id, wrap) {
|
|
if (_pendingDeletes.has(id)) return;
|
|
const parent = wrap.parentNode;
|
|
if (!parent) return;
|
|
|
|
const holder = document.createElement("div");
|
|
holder.className = "file-chat-msg file-chat-deleted";
|
|
holder.innerHTML =
|
|
`<div class="file-chat-body file-chat-deleted-text">${t("chat.deleted_message")}` +
|
|
`<button class="file-chat-undo" type="button">${t("chat.undo")}</button></div>`;
|
|
parent.replaceChild(holder, wrap);
|
|
|
|
const undo = () => {
|
|
const entry = _pendingDeletes.get(id);
|
|
if (!entry) return;
|
|
clearTimeout(entry.timer);
|
|
_pendingDeletes.delete(id);
|
|
holder.replaceWith(wrap);
|
|
};
|
|
const timer = setTimeout(async () => {
|
|
_pendingDeletes.delete(id);
|
|
try {
|
|
await api(`${delUrl}/${encodeURIComponent(id)}`, { method: "DELETE" });
|
|
holder.remove();
|
|
} catch (_) {
|
|
showToast(t("chat.delete_error"), "error");
|
|
holder.replaceWith(wrap); // suppression ratée : le message revient
|
|
}
|
|
}, 10000);
|
|
_pendingDeletes.set(id, { timer });
|
|
holder.querySelector(".file-chat-undo").addEventListener("click", (e) => {
|
|
e.stopPropagation();
|
|
undo();
|
|
});
|
|
}
|
|
|
|
// #191 — carte de lien : site, titre, description, vignette.
|
|
function _previewEl(p) {
|
|
const card = document.createElement("a");
|
|
card.className = "file-chat-preview";
|
|
card.href = p.url;
|
|
card.target = "_blank";
|
|
card.rel = "noopener noreferrer";
|
|
if (p.image) {
|
|
const img = document.createElement("img");
|
|
img.className = "file-chat-preview-img";
|
|
img.src = p.image;
|
|
img.alt = "";
|
|
img.loading = "lazy";
|
|
img.referrerPolicy = "no-referrer";
|
|
card.appendChild(img);
|
|
}
|
|
const meta = document.createElement("div");
|
|
meta.className = "file-chat-preview-meta";
|
|
const site = document.createElement("div");
|
|
site.className = "file-chat-preview-site";
|
|
site.textContent = p.site || "";
|
|
const title = document.createElement("div");
|
|
title.className = "file-chat-preview-title";
|
|
title.textContent = p.title || p.url;
|
|
meta.appendChild(site);
|
|
meta.appendChild(title);
|
|
if (p.description) {
|
|
const desc = document.createElement("div");
|
|
desc.className = "file-chat-preview-desc";
|
|
desc.textContent = p.description;
|
|
meta.appendChild(desc);
|
|
}
|
|
card.appendChild(meta);
|
|
return card;
|
|
}
|
|
|
|
/**
|
|
* 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, 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";
|
|
del.className = "file-chat-del";
|
|
del.title = t("chat.delete");
|
|
del.setAttribute("aria-label", t("chat.delete"));
|
|
del.textContent = "🗑";
|
|
del.addEventListener("click", () => _deleteMessage(delUrl, m.id, wrap));
|
|
meta.appendChild(del);
|
|
}
|
|
const body = document.createElement("div");
|
|
body.className = "file-chat-body";
|
|
_fillBody(body, m.text);
|
|
wrap.appendChild(meta);
|
|
if (m.text) wrap.appendChild(body);
|
|
const att = _attachEl(m.attachment);
|
|
if (att) wrap.appendChild(att);
|
|
if (m.preview) wrap.appendChild(_previewEl(m.preview));
|
|
return wrap;
|
|
}
|
|
|
|
export function renderChatMessages(container, messages, currentUser, delUrl, readMap) {
|
|
container.innerHTML = "";
|
|
if (!messages || messages.length === 0) {
|
|
const empty = document.createElement("div");
|
|
empty.className = "file-chat-empty";
|
|
empty.textContent = t("chat.empty");
|
|
container.appendChild(empty);
|
|
return;
|
|
}
|
|
messages.forEach((m) => container.appendChild(_messageEl(m, currentUser, delUrl, readMap)));
|
|
container.scrollTop = container.scrollHeight;
|
|
safeCreateIcons(); // #110 — hydrate les icônes des boutons de pièce jointe
|
|
}
|
|
|
|
function _appendMessage(list, m, delUrl) {
|
|
const empty = list.querySelector(".file-chat-empty");
|
|
if (empty) empty.remove();
|
|
const id = m.id;
|
|
if (id && list.querySelector(`[data-id="${CSS.escape(id)}"]`)) return false;
|
|
list.appendChild(_messageEl(m, _currentUser(), delUrl));
|
|
list.scrollTop = list.scrollHeight;
|
|
safeCreateIcons();
|
|
return true;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 1. Panneau du document (#169)
|
|
// ---------------------------------------------------------------------------
|
|
function _panel() {
|
|
let el = document.getElementById(PANEL_ID);
|
|
if (!el) {
|
|
el = document.createElement("aside");
|
|
el.id = PANEL_ID;
|
|
el.className = "file-chat-panel hidden";
|
|
el.setAttribute("aria-label", t("chat.title"));
|
|
document.body.appendChild(el);
|
|
}
|
|
return el;
|
|
}
|
|
|
|
async function _load() {
|
|
const panel = _panel();
|
|
const list = panel.querySelector(".file-chat-list");
|
|
try {
|
|
const data = await api(
|
|
`/api/file/${encodeURIComponent(_vault)}/chat?path=${encodeURIComponent(_path)}`,
|
|
);
|
|
_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");
|
|
err.className = "file-chat-empty";
|
|
err.textContent = t("chat.load_error");
|
|
list.appendChild(err);
|
|
}
|
|
}
|
|
|
|
function _renderShell(panel) {
|
|
panel.innerHTML = "";
|
|
const header = document.createElement("div");
|
|
header.className = "file-chat-header";
|
|
const titles = document.createElement("div");
|
|
titles.className = "file-chat-titles";
|
|
const title = document.createElement("h3");
|
|
title.textContent = t("chat.title");
|
|
titles.appendChild(title);
|
|
// #190 A — le document ciblé est affiché dans l'en-tête
|
|
const target = document.createElement("span");
|
|
target.className = "file-chat-target";
|
|
target.textContent = _title || _path || "";
|
|
target.title = _path || "";
|
|
titles.appendChild(target);
|
|
const close = document.createElement("button");
|
|
close.type = "button";
|
|
close.className = "file-chat-close";
|
|
close.title = t("chat.close");
|
|
close.setAttribute("aria-label", t("chat.close"));
|
|
close.textContent = "✕";
|
|
close.addEventListener("click", closeFileChat);
|
|
header.appendChild(titles);
|
|
header.appendChild(close);
|
|
|
|
const list = document.createElement("div");
|
|
list.className = "file-chat-list";
|
|
|
|
const form = document.createElement("form");
|
|
form.className = "file-chat-form";
|
|
// #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";
|
|
send.title = t("chat.send");
|
|
send.setAttribute("aria-label", t("chat.send"));
|
|
// #191 : icône compacte — la saisie garde toute la largeur
|
|
send.innerHTML = '<i data-lucide="send" style="width:15px;height:15px"></i>';
|
|
form.appendChild(input);
|
|
form.appendChild(send);
|
|
form.addEventListener("submit", (e) => {
|
|
e.preventDefault();
|
|
_send(input);
|
|
});
|
|
|
|
panel.appendChild(header);
|
|
panel.appendChild(list);
|
|
panel.appendChild(form);
|
|
safeCreateIcons(); // #191 — hydrate l'icône d'envoi
|
|
}
|
|
|
|
export async function openFileChat(vault, path, title) {
|
|
_vault = vault;
|
|
_path = path;
|
|
_title = title || path || "";
|
|
_ensureNotifyPermission(); // #110 — geste utilisateur → permission notifications
|
|
const panel = _panel();
|
|
_renderShell(panel);
|
|
panel.classList.remove("hidden");
|
|
await _load();
|
|
const input = panel.querySelector(".file-chat-input");
|
|
if (input) input.focus();
|
|
}
|
|
|
|
export function closeFileChat() {
|
|
const panel = document.getElementById(PANEL_ID);
|
|
if (panel) panel.classList.add("hidden");
|
|
}
|
|
|
|
export function toggleFileChat(vault, path, title) {
|
|
const panel = document.getElementById(PANEL_ID);
|
|
if (panel && !panel.classList.contains("hidden") && _vault === vault && _path === path) {
|
|
closeFileChat();
|
|
return;
|
|
}
|
|
openFileChat(vault, path, title);
|
|
}
|
|
|
|
// #190 A — appelé à chaque renderFile : le panneau ouvert suit le document.
|
|
export function followFileChat(vault, path, title) {
|
|
const panel = document.getElementById(PANEL_ID);
|
|
if (!panel || panel.classList.contains("hidden")) return;
|
|
if (_vault === vault && _path === path) return;
|
|
openFileChat(vault, path, title);
|
|
}
|
|
|
|
async function _send(input) {
|
|
if (_sending) return;
|
|
const text = (input.value || "").trim();
|
|
if (!text) return;
|
|
_sending = true;
|
|
try {
|
|
const data = await api(`/api/file/${encodeURIComponent(_vault)}/chat`, {
|
|
method: "POST",
|
|
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");
|
|
} finally {
|
|
_sending = false;
|
|
input.focus();
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 2. Chat sidebar (#190 B) + canaux privé #191
|
|
// ---------------------------------------------------------------------------
|
|
function _sidebarList() {
|
|
return document.getElementById("sidebar-panel-chat-list");
|
|
}
|
|
|
|
/** Clé de la conversation courante (unread + sélection). */
|
|
function _channelKey() {
|
|
return _channel.type === "dm" ? `dm:${_channel.user}` : "general";
|
|
}
|
|
|
|
/** URL d'API de base du canal courant. */
|
|
function _channelUrl() {
|
|
return _channel.type === "dm"
|
|
? `/api/chat/dm/${encodeURIComponent(_channel.user)}`
|
|
: "/api/chat";
|
|
}
|
|
|
|
/** URL de base de suppression du canal courant. */
|
|
function _channelDelUrl() {
|
|
return _channel.type === "dm"
|
|
? `/api/chat/dm/${encodeURIComponent(_channel.user)}`
|
|
: "/api/chat";
|
|
}
|
|
|
|
/** Rend la rangée de canaux : Général + un bouton par utilisateur. */
|
|
function _renderChannels(users) {
|
|
const holder = document.getElementById("sidebar-panel-chat-channels");
|
|
if (!holder) return;
|
|
holder.innerHTML = "";
|
|
const mk = (label, active, onClick, title) => {
|
|
const b = document.createElement("button");
|
|
b.type = "button";
|
|
b.className = "chat-channel" + (active ? " active" : "");
|
|
b.textContent = label;
|
|
if (title) b.title = title;
|
|
b.addEventListener("click", onClick);
|
|
holder.appendChild(b);
|
|
return b;
|
|
};
|
|
mk(t("chat.channel_general"), _channel.type === "general", () => switchChannel(null));
|
|
(users || []).forEach((u) => {
|
|
mk(u.display_name || u.username, _channel.type === "dm" && _channel.user === u.username,
|
|
() => switchChannel(u.username), `@${u.username}`);
|
|
});
|
|
}
|
|
|
|
/** Charge la liste des utilisateurs (une fois) puis rend les canaux. */
|
|
async function _loadUsers() {
|
|
if (_usersCache) return _usersCache;
|
|
try {
|
|
const data = await api("/api/chat/users");
|
|
_usersCache = Array.isArray(data) ? data : [];
|
|
} catch (_) {
|
|
_usersCache = [];
|
|
}
|
|
return _usersCache;
|
|
}
|
|
|
|
/** Charge (ou recharge) l'historique du canal courant dans la sidebar. */
|
|
export async function openSidebarChat() {
|
|
const list = _sidebarList();
|
|
if (!list) return;
|
|
_ensureNotifyPermission(); // #110 — geste utilisateur → permission notifications
|
|
const users = await _loadUsers();
|
|
_renderChannels(users);
|
|
try {
|
|
const data = await api(_channelUrl());
|
|
_channelMessages = (data.messages || []).slice();
|
|
_channelLoaded = true;
|
|
_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");
|
|
err.className = "file-chat-empty";
|
|
err.textContent = t("chat.load_error");
|
|
list.appendChild(err);
|
|
}
|
|
}
|
|
|
|
/** Change de canal (#191) : null = général, sinon l'username du pair. */
|
|
export function switchChannel(user) {
|
|
_channel = user ? { type: "dm", user } : { type: "general" };
|
|
const input = _sendInput();
|
|
if (input) {
|
|
input.placeholder = user ? t("chat.placeholder_dm", { user }) : t("chat.placeholder");
|
|
}
|
|
openSidebarChat();
|
|
}
|
|
|
|
/** Recherche dans le canal courant (barre de filtre sidebar) — #190 C. */
|
|
export function filterChatMessages(query) {
|
|
const list = _sidebarList();
|
|
if (!list || !_channelLoaded) return;
|
|
const q = String(query || "").toLowerCase().trim();
|
|
const shown = q
|
|
? _channelMessages.filter((m) =>
|
|
((m.text || "") + " " + (m.user || "")).toLowerCase().includes(q),
|
|
)
|
|
: _channelMessages;
|
|
renderChatMessages(list, shown, _currentUser(), _channelDelUrl(), _channelRead);
|
|
}
|
|
|
|
function _sendInput() {
|
|
return document.getElementById("sidebar-panel-chat-input");
|
|
}
|
|
|
|
function _renderPending() {
|
|
const holder = document.getElementById("sidebar-panel-chat-pending");
|
|
if (!holder) return;
|
|
holder.innerHTML = "";
|
|
if (!_pendingAttach) return;
|
|
const chip = document.createElement("span");
|
|
chip.className = "file-chat-pending";
|
|
chip.textContent =
|
|
(_pendingAttach.kind === "video" ? "🎬 " : "🖼 ") + (_pendingAttach.name || "pièce jointe");
|
|
const x = document.createElement("button");
|
|
x.type = "button";
|
|
x.textContent = "✕";
|
|
x.setAttribute("aria-label", t("chat.attach_remove"));
|
|
x.addEventListener("click", () => {
|
|
_pendingAttach = null;
|
|
_renderPending();
|
|
});
|
|
chip.appendChild(x);
|
|
holder.appendChild(chip);
|
|
}
|
|
|
|
/** Envoi d'un message du chat général (texte + pièce jointe éventuelle). */
|
|
export async function sendGeneralMessage() {
|
|
const input = _sendInput();
|
|
if (!input) return;
|
|
const text = (input.value || "").trim();
|
|
if (!text && !_pendingAttach) return;
|
|
const body = { text };
|
|
if (_pendingAttach) body.attachment = _pendingAttach;
|
|
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();
|
|
if (list) {
|
|
_channelMessages.push(data.message);
|
|
_appendMessage(list, data.message, _channelDelUrl());
|
|
}
|
|
input.focus();
|
|
} catch (_) {
|
|
showToast(t("chat.send_error"), "error");
|
|
}
|
|
}
|
|
|
|
/** Sélecteur de pièce jointe (image/vidéo) → upload → envoi libre. */
|
|
export async function pickAttachment() {
|
|
return new Promise((resolve) => {
|
|
const fileInput = document.createElement("input");
|
|
fileInput.type = "file";
|
|
fileInput.accept = "image/*,video/*";
|
|
fileInput.style.display = "none";
|
|
document.body.appendChild(fileInput);
|
|
fileInput.addEventListener("change", async () => {
|
|
const file = fileInput.files && fileInput.files[0];
|
|
fileInput.remove();
|
|
if (!file) return resolve(null);
|
|
try {
|
|
const form = new FormData();
|
|
form.append("file", file);
|
|
const res = await api("/api/chat/upload", { method: "POST", body: form });
|
|
_pendingAttach = res.attachment;
|
|
_renderPending();
|
|
resolve(_pendingAttach);
|
|
} catch (_) {
|
|
showToast(t("chat.attach_error"), "error");
|
|
resolve(null);
|
|
}
|
|
});
|
|
fileInput.click();
|
|
});
|
|
}
|
|
|
|
// --- Messages non lus (#190 B / #191) --------------------------------------
|
|
// Compteurs par clé de canal : {"general": 2, "dm:bob": 1}. Ouverture du
|
|
// canal → remise à zéro de sa clé.
|
|
function _readUnread() {
|
|
try {
|
|
return JSON.parse(localStorage.getItem(UNREAD_KEY) || "{}") || {};
|
|
} catch (_) {
|
|
return {};
|
|
}
|
|
}
|
|
|
|
function _writeUnread(u) {
|
|
try {
|
|
localStorage.setItem(UNREAD_KEY, JSON.stringify(u));
|
|
} catch (_) { /* quota */ }
|
|
}
|
|
|
|
function _clearUnread() {
|
|
const u = _readUnread();
|
|
u[_channelKey()] = 0;
|
|
_writeUnread(u);
|
|
_renderBadge(_totalUnread());
|
|
_renderChannels(_usersCache);
|
|
}
|
|
|
|
function _totalUnread() {
|
|
const u = _readUnread();
|
|
return Object.values(u).reduce((n, v) => n + (typeof v === "number" ? v : 0), 0);
|
|
}
|
|
|
|
function _renderBadge(count) {
|
|
const tab = document.getElementById("sidebar-tab-chat");
|
|
if (!tab) return;
|
|
// #110 — point de couleur sur l'icône chat tant qu'il reste des non-lus
|
|
tab.classList.toggle("chat-has-unread", count > 0);
|
|
let badge = tab.querySelector(".chat-unread-badge");
|
|
if (!count) {
|
|
if (badge) badge.remove();
|
|
return;
|
|
}
|
|
if (!badge) {
|
|
badge = document.createElement("span");
|
|
badge.className = "chat-unread-badge";
|
|
tab.appendChild(badge);
|
|
}
|
|
badge.textContent = count > 99 ? "99+" : String(count);
|
|
}
|
|
|
|
/** Affiche le total des messages non lus (tous canaux confondus). */
|
|
export function refreshUnreadBadge() {
|
|
_renderBadge(_totalUnread());
|
|
}
|
|
|
|
/** #110 — notification OS quand l'app n'est pas au premier plan. */
|
|
function _notifyNewMessage(m) {
|
|
const Notif = window.Notification;
|
|
if (!Notif) return;
|
|
if (!document.hidden && document.hasFocus()) return; // visible : le toast suffit
|
|
if (Notif.permission !== "granted") return;
|
|
try {
|
|
const n = new Notif(t("chat.new_message", { user: m.user }), {
|
|
body: (m.text || "").slice(0, 140),
|
|
icon: "/static/icons/icon-192x192.svg",
|
|
tag: "obsigate-chat",
|
|
});
|
|
n.onclick = () => { try { window.focus(); } catch (_) { /* ignore */ } n.close(); };
|
|
} catch (_) { /* notifications indisponibles */ }
|
|
}
|
|
|
|
/** Demande la permission au premier clic utilisateur (ouverture du chat). */
|
|
function _ensureNotifyPermission() {
|
|
const Notif = window.Notification;
|
|
if (!Notif || Notif.permission !== "default") return;
|
|
try { Notif.requestPermission(); } catch (_) { /* ignoré */ }
|
|
}
|
|
|
|
/** Initialisation : formulaire + raccourcis clavier. */
|
|
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();
|
|
});
|
|
const attach = document.getElementById("sidebar-panel-chat-attach");
|
|
if (attach) attach.addEventListener("click", () => pickAttachment());
|
|
if (input) {
|
|
input.addEventListener("keydown", (e) => {
|
|
if (e.key === "Enter" && !e.shiftKey) {
|
|
e.preventDefault();
|
|
sendGeneralMessage();
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// SSE relay (appelé par sync.js sur l'événement `chat_message`)
|
|
// ---------------------------------------------------------------------------
|
|
export function onChatMessage(data) {
|
|
if (!data || !data.message) return;
|
|
|
|
// --- Canaux sidebar : général (#190) ou privé (#191) ---
|
|
if (data.vault === GLOBAL_VAULT || data.vault === DM_VAULT) {
|
|
const key = data.vault === DM_VAULT ? `dm:${_pairOf(data.path)}` : "general";
|
|
const isActive = key === _channelKey();
|
|
const panel = document.getElementById("sidebar-panel-chat");
|
|
const visible = panel && panel.classList.contains("active");
|
|
if (isActive) _channelMessages.push(data.message);
|
|
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();
|
|
showToast(t("chat.new_message", { user: data.message.user }), "info");
|
|
_notifyNewMessage(data.message); // #110 — notification OS si app en arrière-plan
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Chat d'un document (#169)
|
|
const panel = document.getElementById(PANEL_ID);
|
|
const isOpen =
|
|
panel && !panel.classList.contains("hidden") &&
|
|
_vault === data.vault && _path === data.path;
|
|
if (isOpen) {
|
|
const list = panel.querySelector(".file-chat-list");
|
|
if (list) _appendMessage(list, data.message);
|
|
if (data.message.user !== _currentUser()) {
|
|
// #192 — panneau affiché : accusé de réception immédiat.
|
|
_markRead(data.vault, data.path);
|
|
_notifyNewMessage(data.message); // #110 — notification OS si app en arrière-plan
|
|
}
|
|
return;
|
|
}
|
|
if (data.message.user === _currentUser()) return; // son propre message
|
|
showToast(t("chat.new_message", { user: data.message.user }), "info");
|
|
_notifyNewMessage(data.message); // #110
|
|
}
|
|
|
|
/**
|
|
* #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();
|
|
const parts = String(path || "").split("|");
|
|
return parts[0] === me ? parts[1] : parts[0];
|
|
}
|
|
|
|
function _bumpUnread(key) {
|
|
const u = _readUnread();
|
|
u[key] = (u[key] || 0) + 1;
|
|
_writeUnread(u);
|
|
}
|
|
|
|
/** Suppression d'un post (#191) — relayée par sync.js. */
|
|
export function onChatDeleted(data) {
|
|
if (!data || !data.id) return;
|
|
const sel = `[data-id="${CSS.escape(data.id)}"]`;
|
|
document.querySelectorAll(sel).forEach((el) => el.remove());
|
|
}
|