From 04529d2df3c86529db676fb3b84e5b10266d28ec Mon Sep 17 00:00:00 2001 From: rajchales-monito Date: Fri, 24 Jul 2026 14:29:03 +0200 Subject: [PATCH] Fix widget reply notification effects --- public/widget.js | 87 +++++++++++++++++++++++++++++++++++++++--------- server.js | 5 ++- 2 files changed, 75 insertions(+), 17 deletions(-) diff --git a/public/widget.js b/public/widget.js index 6452e03..e8b2546 100644 --- a/public/widget.js +++ b/public/widget.js @@ -73,7 +73,10 @@ const dropzone = root.querySelector(".mf-dropzone"); const attachmentList = root.querySelector(".mf-attachment-list"); let selectedFiles = []; - let unreadAdminCount = 0; + const messageEffectState = { + unreadAdminCount: 0, + knownAdminMessageId: 0 + }; renderSoundState(soundButton); launcher.addEventListener("click", () => { @@ -81,8 +84,8 @@ if (panel.classList.contains("open")) { launcher.classList.remove("mf-notify"); clearMessageEffects(launcher); - unreadAdminCount = 0; - updateMessageBadge(launcher, unreadAdminCount); + messageEffectState.unreadAdminCount = 0; + updateMessageBadge(launcher, messageEffectState.unreadAdminCount); scrollMessagesToBottom(messages); markConversationSeen(); } @@ -164,8 +167,9 @@ form.message.value = ""; selectedFiles = []; renderSelectedFiles(attachmentList, selectedFiles); - renderConversation(messages, data); - connectEvents(messages, typing, panel, launcher, settings); + handleConversationUpdate(data, messages, panel, launcher, settings, messageEffectState, { notify: false }); + connectEvents(messages, typing, panel, launcher, settings, messageEffectState); + startConversationPolling(messages, panel, launcher, settings, messageEffectState); } catch { showFormError(root, "Zpravu se nepodarilo odeslat. Zkuste to prosim znovu."); } finally { @@ -184,7 +188,14 @@ } }); if (session.conversationId) { - loadExistingConversation(messages, panel).finally(() => connectEvents(messages, typing, panel, launcher, settings)); + loadExistingConversation(messages, panel) + .then((data) => { + messageEffectState.knownAdminMessageId = latestAdminMessageId(data?.messages || []); + }) + .finally(() => { + connectEvents(messages, typing, panel, launcher, settings, messageEffectState); + startConversationPolling(messages, panel, launcher, settings, messageEffectState); + }); } } @@ -196,7 +207,7 @@ }; } - function connectEvents(messages, typing, panel, launcher, settings) { + function connectEvents(messages, typing, panel, launcher, settings, messageEffectState) { if (session.events || !session.conversationId) return; session.events = new EventSource(`${apiBase}/api/widget/conversations/${session.conversationId}/events`); session.events.addEventListener("message", (event) => { @@ -207,17 +218,17 @@ connectEvents.typingTimer = setTimeout(() => { typing.hidden = true; }, 1800); } if (data.type === "message:new") { - renderConversation(messages, data.payload); - if (panel.classList.contains("open")) markConversationSeen(); - const eventMessages = data.payload?.messages || []; - const lastMessage = eventMessages[eventMessages.length - 1]; - if (lastMessage?.senderType === "admin" && !panel.classList.contains("open")) { - unreadAdminCount += 1; - applyAdminReplyEffects(launcher, settings, unreadAdminCount); - playNotifySound(); - } + handleConversationUpdate(data.payload, messages, panel, launcher, settings, messageEffectState, { notify: true }); } }); + session.events.onerror = () => { + session.events?.close(); + session.events = null; + clearTimeout(connectEvents.reconnectTimer); + connectEvents.reconnectTimer = setTimeout(() => { + connectEvents(messages, typing, panel, launcher, settings, messageEffectState); + }, 2500); + }; } function createHeaderActions(root, closeButton) { @@ -283,12 +294,56 @@ const data = await fetchJson(`${apiBase}/api/widget/conversations/${session.conversationId}?visitorToken=${encodeURIComponent(session.visitorToken)}`); renderConversation(messages, data); if (panel.classList.contains("open")) markConversationSeen(); + return data; } catch { session.conversationId = null; saveSession(); + return null; } } + function startConversationPolling(messages, panel, launcher, settings, messageEffectState) { + if (session.pollTimer || !session.conversationId) return; + session.pollTimer = setInterval(async () => { + if (!session.conversationId) return; + try { + const data = await fetchJson(`${apiBase}/api/widget/conversations/${session.conversationId}?visitorToken=${encodeURIComponent(session.visitorToken)}`); + handleConversationUpdate(data, messages, panel, launcher, settings, messageEffectState, { notify: true }); + } catch {} + }, 10_000); + } + + function handleConversationUpdate(data, messages, panel, launcher, settings, messageEffectState, options = {}) { + if (!data?.messages) return; + const previousAdminMessageId = messageEffectState.knownAdminMessageId || 0; + const latestAdminId = latestAdminMessageId(data.messages); + const hasNewAdminMessage = latestAdminId > previousAdminMessageId; + + renderConversation(messages, data); + messageEffectState.knownAdminMessageId = Math.max(previousAdminMessageId, latestAdminId); + + if (panel.classList.contains("open")) { + markConversationSeen(); + messageEffectState.unreadAdminCount = 0; + updateMessageBadge(launcher, messageEffectState.unreadAdminCount); + clearMessageEffects(launcher); + return; + } + + if (options.notify !== false && hasNewAdminMessage) { + messageEffectState.unreadAdminCount += 1; + applyAdminReplyEffects(launcher, settings, messageEffectState.unreadAdminCount); + playNotifySound(); + } + } + + function latestAdminMessageId(messages) { + return messages.reduce((max, message) => { + if (message.senderType !== "admin") return max; + return Math.max(max, Number(message.id) || 0); + }, 0); + } + function renderConversation(messages, data) { messages.innerHTML = ""; let lastAdminName = null; diff --git a/server.js b/server.js index b3b22f7..7bceedf 100644 --- a/server.js +++ b/server.js @@ -56,7 +56,10 @@ async function route(req, res) { if (req.method === "GET" && url.pathname === "/admin") return file(res, path.join(PUBLIC_DIR, "admin.html"), "text/html; charset=utf-8"); if (req.method === "GET" && url.pathname === "/preview") return file(res, path.join(PUBLIC_DIR, "preview.html"), "text/html; charset=utf-8"); if (req.method === "GET" && url.pathname.startsWith("/uploads/")) return serveUpload(res, url.pathname); - if (req.method === "GET" && url.pathname === "/widget.js") return file(res, path.join(PUBLIC_DIR, "widget.js"), "application/javascript; charset=utf-8"); + if (req.method === "GET" && url.pathname === "/widget.js") { + res.setHeader("Cache-Control", "no-store, max-age=0"); + return file(res, path.join(PUBLIC_DIR, "widget.js"), "application/javascript; charset=utf-8"); + } if (req.method === "GET" && url.pathname.startsWith("/admin.")) return servePublic(res, url.pathname); if (url.pathname === "/api/admin/login" && req.method === "POST") return adminLogin(req, res);