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,