Show operator presence in admin chats
This commit is contained in:
@@ -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 p { margin: 0; color: var(--muted); font-size: 13px; }
|
||||||
.conversation-header select { width: 150px; align-self: start; }
|
.conversation-header select { width: 150px; align-self: start; }
|
||||||
.visitor-head { width: 100%; min-width: 0; display: grid; gap: 7px; }
|
.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 {
|
.visitor-summary-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
+79
-2
@@ -20,7 +20,11 @@ const state = {
|
|||||||
originalTitle: document.title,
|
originalTitle: document.title,
|
||||||
originalFavicon: document.querySelector("link[rel='icon']")?.href || "/favicon.svg",
|
originalFavicon: document.querySelector("link[rel='icon']")?.href || "/favicon.svg",
|
||||||
aiModels: [],
|
aiModels: [],
|
||||||
adminUsers: []
|
adminUsers: [],
|
||||||
|
me: null,
|
||||||
|
operatorPresence: {},
|
||||||
|
operatorTyping: {},
|
||||||
|
operatorPresenceTimer: null
|
||||||
};
|
};
|
||||||
|
|
||||||
const $ = (selector) => document.querySelector(selector);
|
const $ = (selector) => document.querySelector(selector);
|
||||||
@@ -39,7 +43,10 @@ renderAdminSoundControls();
|
|||||||
|
|
||||||
async function boot() {
|
async function boot() {
|
||||||
const me = await api("/api/admin/me").catch(() => null);
|
const me = await api("/api/admin/me").catch(() => null);
|
||||||
if (me?.user) showApp();
|
if (me?.user) {
|
||||||
|
state.me = me.user;
|
||||||
|
showApp();
|
||||||
|
}
|
||||||
else showLogin();
|
else showLogin();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -368,6 +375,10 @@ $("#conversationHeader").addEventListener("click", async (event) => {
|
|||||||
async function showApp() {
|
async function showApp() {
|
||||||
$("#loginView").classList.add("hidden");
|
$("#loginView").classList.add("hidden");
|
||||||
$("#appView").classList.remove("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 loadBootstrap();
|
||||||
await loadAiModels().catch(() => {});
|
await loadAiModels().catch(() => {});
|
||||||
await loadAdminUsers().catch(() => {});
|
await loadAdminUsers().catch(() => {});
|
||||||
@@ -408,14 +419,39 @@ async function openConversation(id) {
|
|||||||
state.activeId = id;
|
state.activeId = id;
|
||||||
const data = await api(`/api/admin/conversations/${id}`);
|
const data = await api(`/api/admin/conversations/${id}`);
|
||||||
state.active = data;
|
state.active = data;
|
||||||
|
state.operatorPresence[id] = data.operators || [];
|
||||||
const index = state.conversations.findIndex((item) => item.id === id);
|
const index = state.conversations.findIndex((item) => item.id === id);
|
||||||
if (index >= 0) state.conversations[index] = { ...state.conversations[index], ...data.conversation };
|
if (index >= 0) state.conversations[index] = { ...state.conversations[index], ...data.conversation };
|
||||||
syncConversationVisitors();
|
syncConversationVisitors();
|
||||||
renderConversations();
|
renderConversations();
|
||||||
renderActive();
|
renderActive();
|
||||||
|
startOperatorPresence(id);
|
||||||
syncAttentionWithUnread();
|
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() {
|
function connectEvents() {
|
||||||
if (state.events) state.events.close();
|
if (state.events) state.events.close();
|
||||||
state.events = new EventSource("/api/admin/events");
|
state.events = new EventSource("/api/admin/events");
|
||||||
@@ -423,6 +459,12 @@ function connectEvents() {
|
|||||||
const data = JSON.parse(event.data);
|
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" && data.conversationId !== state.activeId) return;
|
||||||
if (data.type === "typing" && data.actor === "visitor") return flashTyping();
|
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") {
|
if (data.type === "site:update") {
|
||||||
state.site = data.site;
|
state.site = data.site;
|
||||||
renderSite();
|
renderSite();
|
||||||
@@ -1046,6 +1088,7 @@ function renderActive() {
|
|||||||
<span title="Navstivene stranky">${Number(conversation.pageCount || 0)} str.</span>
|
<span title="Navstivene stranky">${Number(conversation.pageCount || 0)} str.</span>
|
||||||
<span title="Pocet chatu">${Number(conversation.visitorConversationCount || 1)}x chat</span>
|
<span title="Pocet chatu">${Number(conversation.visitorConversationCount || 1)}x chat</span>
|
||||||
${visitorDetails(conversation)}
|
${visitorDetails(conversation)}
|
||||||
|
<span id="operatorPresence" class="operator-presence hidden"></span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
@@ -1058,9 +1101,42 @@ function renderActive() {
|
|||||||
</article>
|
</article>
|
||||||
`).join("") + adminReadReceipt(conversation, messages);
|
`).join("") + adminReadReceipt(conversation, messages);
|
||||||
decorateTranslationMeta(messages);
|
decorateTranslationMeta(messages);
|
||||||
|
renderOperatorPresence();
|
||||||
$("#messages").scrollTop = $("#messages").scrollHeight;
|
$("#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) {
|
function decorateTranslationMeta(messages) {
|
||||||
document.querySelectorAll("#messages .message .by").forEach((element, index) => {
|
document.querySelectorAll("#messages .message .by").forEach((element, index) => {
|
||||||
const meta = translationMeta(messages[index]);
|
const meta = translationMeta(messages[index]);
|
||||||
@@ -1242,6 +1318,7 @@ async function deleteConversation(id) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function clearActiveConversation() {
|
function clearActiveConversation() {
|
||||||
|
stopOperatorPresence();
|
||||||
state.activeId = null;
|
state.activeId = null;
|
||||||
state.active = null;
|
state.active = null;
|
||||||
$("#conversationHeader").innerHTML = `
|
$("#conversationHeader").innerHTML = `
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ initDb();
|
|||||||
seedDefaults();
|
seedDefaults();
|
||||||
|
|
||||||
const streams = new Map();
|
const streams = new Map();
|
||||||
|
const operatorPresence = new Map();
|
||||||
|
|
||||||
const server = http.createServer(async (req, res) => {
|
const server = http.createServer(async (req, res) => {
|
||||||
try {
|
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\/[^/]+\/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\/[^/]+\/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\/[^/]+\/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" });
|
return json(res, 404, { error: "not_found" });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -967,10 +969,65 @@ async function visitorTyping(req, res, url) {
|
|||||||
async function adminTyping(req, res, url) {
|
async function adminTyping(req, res, url) {
|
||||||
const conversation = findConversation(publicIdFrom(url));
|
const conversation = findConversation(publicIdFrom(url));
|
||||||
if (!conversation) return json(res, 404, { error: "conversation_not_found" });
|
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 });
|
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) {
|
function eventStream(req, res, url, channel) {
|
||||||
const key = channel === "admin" ? "admin" : `conversation:${publicIdFrom(url)}`;
|
const key = channel === "admin" ? "admin" : `conversation:${publicIdFrom(url)}`;
|
||||||
res.writeHead(200, {
|
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);
|
const attachments = db.prepare("SELECT * FROM attachments WHERE conversation_id = ? ORDER BY id ASC").all(conversation.id);
|
||||||
return {
|
return {
|
||||||
conversation: formatConversationSummary(conversation),
|
conversation: formatConversationSummary(conversation),
|
||||||
|
operators: currentOperatorPresence(conversation.public_id),
|
||||||
messages: messages.map((message) => ({
|
messages: messages.map((message) => ({
|
||||||
id: message.id,
|
id: message.id,
|
||||||
senderType: message.sender_type,
|
senderType: message.sender_type,
|
||||||
|
|||||||
Reference in New Issue
Block a user