From 9a0f7dd22434fca7391ef8e43a42f54bc4707930 Mon Sep 17 00:00:00 2001 From: rajchales-monito Date: Thu, 30 Jul 2026 12:10:53 +0200 Subject: [PATCH] Show operator presence in admin chats --- public/admin.css | 13 ++++++++ public/admin.js | 81 ++++++++++++++++++++++++++++++++++++++++++++++-- server.js | 60 ++++++++++++++++++++++++++++++++++- 3 files changed, 151 insertions(+), 3 deletions(-) diff --git a/public/admin.css b/public/admin.css index 064db2d..2959bf3 100644 --- a/public/admin.css +++ b/public/admin.css @@ -686,6 +686,19 @@ label { display: grid; gap: 6px; color: var(--muted); font-size: 13px; } .conversation-header p { margin: 0; color: var(--muted); font-size: 13px; } .conversation-header select { width: 150px; align-self: start; } .visitor-head { width: 100%; min-width: 0; display: grid; gap: 7px; } +.visitor-summary-row .operator-presence { + max-width: 240px; + border-color: #dfe9e4; + color: var(--muted); + overflow: hidden; + text-overflow: ellipsis; +} +.visitor-summary-row .operator-presence.hidden { display: none; } +.visitor-summary-row .operator-presence.typing { + border-color: #f7d8a8; + background: #fff8ed; + color: #8a5a00; +} .visitor-summary-row { display: flex; align-items: center; diff --git a/public/admin.js b/public/admin.js index b6edf9b..48bc7b0 100644 --- a/public/admin.js +++ b/public/admin.js @@ -20,7 +20,11 @@ const state = { originalTitle: document.title, originalFavicon: document.querySelector("link[rel='icon']")?.href || "/favicon.svg", aiModels: [], - adminUsers: [] + adminUsers: [], + me: null, + operatorPresence: {}, + operatorTyping: {}, + operatorPresenceTimer: null }; const $ = (selector) => document.querySelector(selector); @@ -39,7 +43,10 @@ renderAdminSoundControls(); async function boot() { const me = await api("/api/admin/me").catch(() => null); - if (me?.user) showApp(); + if (me?.user) { + state.me = me.user; + showApp(); + } else showLogin(); } @@ -368,6 +375,10 @@ $("#conversationHeader").addEventListener("click", async (event) => { async function showApp() { $("#loginView").classList.add("hidden"); $("#appView").classList.remove("hidden"); + if (!state.me) { + const me = await api("/api/admin/me").catch(() => null); + state.me = me?.user || null; + } await loadBootstrap(); await loadAiModels().catch(() => {}); await loadAdminUsers().catch(() => {}); @@ -408,14 +419,39 @@ async function openConversation(id) { state.activeId = id; const data = await api(`/api/admin/conversations/${id}`); state.active = data; + state.operatorPresence[id] = data.operators || []; const index = state.conversations.findIndex((item) => item.id === id); if (index >= 0) state.conversations[index] = { ...state.conversations[index], ...data.conversation }; syncConversationVisitors(); renderConversations(); renderActive(); + startOperatorPresence(id); syncAttentionWithUnread(); } +function startOperatorPresence(id) { + stopOperatorPresence(); + sendOperatorPresence(id); + state.operatorPresenceTimer = setInterval(() => { + if (state.activeId) sendOperatorPresence(state.activeId); + }, 15_000); +} + +function stopOperatorPresence() { + if (!state.operatorPresenceTimer) return; + clearInterval(state.operatorPresenceTimer); + state.operatorPresenceTimer = null; +} + +function sendOperatorPresence(id) { + api(`/api/admin/conversations/${id}/presence`, { method: "POST" }) + .then((data) => { + state.operatorPresence[id] = data.operators || []; + if (id === state.activeId) renderOperatorPresence(); + }) + .catch(() => {}); +} + function connectEvents() { if (state.events) state.events.close(); state.events = new EventSource("/api/admin/events"); @@ -423,6 +459,12 @@ function connectEvents() { const data = JSON.parse(event.data); if (data.type === "typing" && data.actor === "visitor" && data.conversationId !== state.activeId) return; if (data.type === "typing" && data.actor === "visitor") return flashTyping(); + if (data.type === "typing" && data.actor === "admin") return flashOperatorTyping(data); + if (data.type === "operator:presence") { + state.operatorPresence[data.conversationId] = data.operators || []; + if (data.conversationId === state.activeId) renderOperatorPresence(); + return; + } if (data.type === "site:update") { state.site = data.site; renderSite(); @@ -1046,6 +1088,7 @@ function renderActive() { ${Number(conversation.pageCount || 0)} str. ${Number(conversation.visitorConversationCount || 1)}x chat ${visitorDetails(conversation)} + `; @@ -1058,9 +1101,42 @@ function renderActive() { `).join("") + adminReadReceipt(conversation, messages); decorateTranslationMeta(messages); + renderOperatorPresence(); $("#messages").scrollTop = $("#messages").scrollHeight; } +function renderOperatorPresence() { + const box = $("#operatorPresence"); + if (!box || !state.activeId) return; + const operators = (state.operatorPresence[state.activeId] || []) + .filter((operator) => operator.username !== state.me?.username); + const typing = state.operatorTyping[state.activeId]; + const parts = []; + if (operators.length) { + parts.push(`V chatu: ${operators.map((operator) => operator.name).join(", ")}`); + } + if (typing && typing.username !== state.me?.username) { + parts.push(`${typing.name} pise...`); + } + box.textContent = parts.join(" ยท "); + box.classList.toggle("hidden", !parts.length); + box.classList.toggle("typing", Boolean(typing && typing.username !== state.me?.username)); +} + +function flashOperatorTyping(data) { + if (data.conversationId !== state.activeId || data.username === state.me?.username) return; + state.operatorTyping[data.conversationId] = { + username: data.username, + name: data.name || "Operator" + }; + renderOperatorPresence(); + clearTimeout(flashOperatorTyping.timer); + flashOperatorTyping.timer = setTimeout(() => { + delete state.operatorTyping[data.conversationId]; + renderOperatorPresence(); + }, 2200); +} + function decorateTranslationMeta(messages) { document.querySelectorAll("#messages .message .by").forEach((element, index) => { const meta = translationMeta(messages[index]); @@ -1242,6 +1318,7 @@ async function deleteConversation(id) { } function clearActiveConversation() { + stopOperatorPresence(); state.activeId = null; state.active = null; $("#conversationHeader").innerHTML = ` diff --git a/server.js b/server.js index a50a1f6..e4601c5 100644 --- a/server.js +++ b/server.js @@ -43,6 +43,7 @@ initDb(); seedDefaults(); const streams = new Map(); +const operatorPresence = new Map(); const server = http.createServer(async (req, res) => { try { @@ -112,6 +113,7 @@ async function adminApi(req, res, url) { if (url.pathname.match(/^\/api\/admin\/conversations\/[^/]+\/messages$/) && req.method === "POST") return createAdminMessage(req, res, url); if (url.pathname.match(/^\/api\/admin\/conversations\/[^/]+\/status$/) && req.method === "PATCH") return updateConversationStatus(req, res, url); if (url.pathname.match(/^\/api\/admin\/conversations\/[^/]+\/typing$/) && req.method === "POST") return adminTyping(req, res, url); + if (url.pathname.match(/^\/api\/admin\/conversations\/[^/]+\/presence$/) && req.method === "POST") return adminPresence(req, res, url); return json(res, 404, { error: "not_found" }); } @@ -967,10 +969,65 @@ async function visitorTyping(req, res, url) { async function adminTyping(req, res, url) { const conversation = findConversation(publicIdFrom(url)); if (!conversation) return json(res, 404, { error: "conversation_not_found" }); - publishConversation(conversation, { type: "typing", actor: "admin", conversationId: conversation.public_id }); + touchOperatorPresence(conversation.public_id, req.adminUser); + publishConversation(conversation, { + type: "typing", + actor: "admin", + conversationId: conversation.public_id, + username: req.adminUser.username, + name: operatorDisplayName(req.adminUser) + }); + publish("admin", { + type: "operator:presence", + conversationId: conversation.public_id, + operators: currentOperatorPresence(conversation.public_id) + }); return json(res, 200, { ok: true }); } +async function adminPresence(req, res, url) { + const conversation = findConversation(publicIdFrom(url)); + if (!conversation) return json(res, 404, { error: "conversation_not_found" }); + touchOperatorPresence(conversation.public_id, req.adminUser); + const operators = currentOperatorPresence(conversation.public_id); + publish("admin", { type: "operator:presence", conversationId: conversation.public_id, operators }); + return json(res, 200, { ok: true, operators }); +} + +function touchOperatorPresence(conversationId, adminUser) { + cleanupOperatorPresence(); + const username = adminUser.username || "operator"; + operatorPresence.set(`${conversationId}:${username}`, { + conversationId, + username, + name: operatorDisplayName(adminUser), + lastSeenAt: Date.now() + }); +} + +function operatorDisplayName(adminUser = {}) { + return adminUser.chatNick || adminUser.displayName || adminUser.username || "Operator"; +} + +function currentOperatorPresence(conversationId) { + cleanupOperatorPresence(); + return [...operatorPresence.values()] + .filter((item) => item.conversationId === conversationId) + .sort((a, b) => a.name.localeCompare(b.name)) + .map((item) => ({ + username: item.username, + name: item.name, + lastSeenAt: new Date(item.lastSeenAt).toISOString() + })); +} + +function cleanupOperatorPresence() { + const now = Date.now(); + for (const [key, item] of operatorPresence.entries()) { + if (now - item.lastSeenAt > 45_000) operatorPresence.delete(key); + } +} + function eventStream(req, res, url, channel) { const key = channel === "admin" ? "admin" : `conversation:${publicIdFrom(url)}`; res.writeHead(200, { @@ -1731,6 +1788,7 @@ function conversationDetails(conversation) { const attachments = db.prepare("SELECT * FROM attachments WHERE conversation_id = ? ORDER BY id ASC").all(conversation.id); return { conversation: formatConversationSummary(conversation), + operators: currentOperatorPresence(conversation.public_id), messages: messages.map((message) => ({ id: message.id, senderType: message.sender_type,