diff --git a/public/admin.css b/public/admin.css index 5948ab9..99c5b9a 100644 --- a/public/admin.css +++ b/public/admin.css @@ -501,6 +501,13 @@ label { display: grid; gap: 6px; color: var(--muted); font-size: 13px; } .message .by { font-size: 12px; color: var(--muted); margin-bottom: 4px; } .message p { margin: 0; white-space: pre-wrap; } .message a { color: var(--brand); display: inline-block; margin-top: 6px; } +.read-receipt { + align-self: flex-end; + margin-top: -4px; + font-size: 12px; + color: var(--muted); +} +.read-receipt.seen { color: var(--brand); } .attachments { display: flex; flex-wrap: wrap; diff --git a/public/admin.js b/public/admin.js index dc585f4..113e577 100644 --- a/public/admin.js +++ b/public/admin.js @@ -435,10 +435,19 @@ function renderActive() { ${message.body ? `

${escapeHtml(message.body)}

` : ""} ${renderAttachments(message.attachments)} - `).join(""); + `).join("") + adminReadReceipt(conversation, messages); $("#messages").scrollTop = $("#messages").scrollHeight; } +function adminReadReceipt(conversation, messages) { + const lastAdminMessage = [...messages].reverse().find((message) => message.senderType === "admin"); + if (!lastAdminMessage) return ""; + const seenAt = conversation.visitorSeenAt; + const isSeen = Boolean(seenAt && String(seenAt) >= String(lastAdminMessage.createdAt)); + const text = isSeen ? `Precteno zakaznikem ${formatTime(seenAt)}` : "Zatim neprecteno"; + return `
${escapeHtml(text)}
`; +} + function filteredConversations() { if (state.statusFilter === "all") return state.conversations; if (state.statusFilter === "active") return state.conversations.filter((item) => item.status === "new" || item.status === "open"); @@ -641,6 +650,14 @@ function formatVisitedAt(value) { return date.toLocaleTimeString("cs-CZ", { hour: "2-digit", minute: "2-digit" }); } +function formatTime(value) { + const text = String(value || ""); + const normalized = /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/.test(text) ? `${text.replace(" ", "T")}Z` : text; + const date = new Date(normalized); + if (Number.isNaN(date.getTime())) return ""; + return date.toLocaleTimeString("cs-CZ", { hour: "2-digit", minute: "2-digit" }); +} + function deviceLabel(device = {}, language, timezone) { return [device.platform, device.viewport, language, timezone].filter(Boolean).join(" | "); } diff --git a/public/widget.js b/public/widget.js index ba3d1e6..9845a19 100644 --- a/public/widget.js +++ b/public/widget.js @@ -76,6 +76,7 @@ if (panel.classList.contains("open")) { launcher.classList.remove("mf-notify"); scrollMessagesToBottom(messages); + markConversationSeen(); } }); close.addEventListener("click", () => panel.classList.remove("open")); @@ -175,7 +176,7 @@ } }); if (session.conversationId) { - loadExistingConversation(messages).finally(() => connectEvents(messages, typing, panel, launcher, settings)); + loadExistingConversation(messages, panel).finally(() => connectEvents(messages, typing, panel, launcher, settings)); } } @@ -199,6 +200,7 @@ } 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 (settings.notifications?.pulseOnAdminReply !== false && lastMessage?.senderType === "admin" && !panel.classList.contains("open")) { @@ -267,10 +269,11 @@ setTimeout(() => audio.close().catch(() => {}), 420); } - async function loadExistingConversation(messages) { + async function loadExistingConversation(messages, panel) { try { const data = await fetchJson(`${apiBase}/api/widget/conversations/${session.conversationId}?visitorToken=${encodeURIComponent(session.visitorToken)}`); renderConversation(messages, data); + if (panel.classList.contains("open")) markConversationSeen(); } catch { session.conversationId = null; saveSession(); @@ -288,6 +291,14 @@ scrollMessagesToBottom(messages); } + function markConversationSeen() { + if (!session.conversationId) return; + clearTimeout(markConversationSeen.timer); + markConversationSeen.timer = setTimeout(() => { + postJson(`${apiBase}/api/widget/conversations/${session.conversationId}/seen`, { visitorToken: session.visitorToken }).catch(() => {}); + }, 250); + } + function addMessage(messages, sender, senderName, body, attachments, lastAdminName) { if (sender === "admin" && (senderName || "Operator") !== lastAdminName) { const label = document.createElement("div"); diff --git a/server.js b/server.js index 3ccf4fb..d18ab91 100644 --- a/server.js +++ b/server.js @@ -68,6 +68,7 @@ async function route(req, res) { if (url.pathname === "/api/widget/conversations" && req.method === "POST") return createConversation(req, res); if (url.pathname.match(/^\/api\/widget\/conversations\/[^/]+$/) && req.method === "GET") return getVisitorConversation(res, url); if (url.pathname.match(/^\/api\/widget\/conversations\/[^/]+\/messages$/) && req.method === "POST") return createVisitorMessage(req, res, url); + if (url.pathname.match(/^\/api\/widget\/conversations\/[^/]+\/seen$/) && req.method === "POST") return markVisitorSeen(req, res, url); if (url.pathname.match(/^\/api\/widget\/conversations\/[^/]+\/events$/) && req.method === "GET") return eventStream(req, res, url, "visitor"); if (url.pathname.match(/^\/api\/widget\/conversations\/[^/]+\/typing$/) && req.method === "POST") return visitorTyping(req, res, url); @@ -137,6 +138,7 @@ function initDb() { ip TEXT, country_code TEXT, vpn_risk INTEGER NOT NULL DEFAULT 0, + visitor_seen_at TEXT, last_message_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP @@ -192,6 +194,7 @@ function initDb() { if (ensureColumn("visitor_sessions", "last_activity_at", "TEXT")) { db.prepare("UPDATE visitor_sessions SET last_activity_at = last_seen_at WHERE last_activity_at IS NULL").run(); } + ensureColumn("conversations", "visitor_seen_at", "TEXT"); } function ensureColumn(table, column, definition) { @@ -515,6 +518,17 @@ async function createVisitorMessage(req, res, url) { return json(res, 201, conversationDetails(updated)); } +async function markVisitorSeen(req, res, url) { + const body = await readJson(req).catch(() => ({})); + const conversation = findConversation(publicIdFrom(url)); + if (!conversation) return json(res, 404, { error: "conversation_not_found" }); + if (conversation.visitor_token !== body.visitorToken) return json(res, 403, { error: "forbidden" }); + db.prepare("UPDATE conversations SET visitor_seen_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP WHERE id = ?").run(conversation.id); + const updated = findConversation(conversation.public_id); + publishConversation(updated, { type: "conversation:seen", conversation: formatConversationSummary(updated) }); + return json(res, 200, { ok: true, conversation: formatConversationSummary(updated) }); +} + async function createAdminMessage(req, res, url) { const body = await readJson(req, 8_000_000); if ((!body.message || !String(body.message).trim()) && !(body.attachments || []).length) return json(res, 400, { error: "message_or_image_required" }); @@ -734,6 +748,7 @@ function formatConversationSummary(row) { visitorLabel: visitorLabel(row.visitor_token), visitorOnline: Boolean(row.visitor_online), visitorPresence: row.visitor_presence || (row.visitor_online ? "active" : "offline"), + visitorSeenAt: row.visitor_seen_at, visitor: { name: row.visitor_name, email: row.visitor_email,