Show operator presence in admin chats
This commit is contained in:
+79
-2
@@ -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() {
|
||||
<span title="Navstivene stranky">${Number(conversation.pageCount || 0)} str.</span>
|
||||
<span title="Pocet chatu">${Number(conversation.visitorConversationCount || 1)}x chat</span>
|
||||
${visitorDetails(conversation)}
|
||||
<span id="operatorPresence" class="operator-presence hidden"></span>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@@ -1058,9 +1101,42 @@ function renderActive() {
|
||||
</article>
|
||||
`).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 = `
|
||||
|
||||
Reference in New Issue
Block a user