Add visitor read receipts
This commit is contained in:
@@ -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 .by { font-size: 12px; color: var(--muted); margin-bottom: 4px; }
|
||||||
.message p { margin: 0; white-space: pre-wrap; }
|
.message p { margin: 0; white-space: pre-wrap; }
|
||||||
.message a { color: var(--brand); display: inline-block; margin-top: 6px; }
|
.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 {
|
.attachments {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
|
|||||||
+18
-1
@@ -435,10 +435,19 @@ function renderActive() {
|
|||||||
${message.body ? `<p>${escapeHtml(message.body)}</p>` : ""}
|
${message.body ? `<p>${escapeHtml(message.body)}</p>` : ""}
|
||||||
${renderAttachments(message.attachments)}
|
${renderAttachments(message.attachments)}
|
||||||
</article>
|
</article>
|
||||||
`).join("");
|
`).join("") + adminReadReceipt(conversation, messages);
|
||||||
$("#messages").scrollTop = $("#messages").scrollHeight;
|
$("#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 `<div class="read-receipt ${isSeen ? "seen" : "unseen"}">${escapeHtml(text)}</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
function filteredConversations() {
|
function filteredConversations() {
|
||||||
if (state.statusFilter === "all") return state.conversations;
|
if (state.statusFilter === "all") return state.conversations;
|
||||||
if (state.statusFilter === "active") return state.conversations.filter((item) => item.status === "new" || item.status === "open");
|
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" });
|
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) {
|
function deviceLabel(device = {}, language, timezone) {
|
||||||
return [device.platform, device.viewport, language, timezone].filter(Boolean).join(" | ");
|
return [device.platform, device.viewport, language, timezone].filter(Boolean).join(" | ");
|
||||||
}
|
}
|
||||||
|
|||||||
+13
-2
@@ -76,6 +76,7 @@
|
|||||||
if (panel.classList.contains("open")) {
|
if (panel.classList.contains("open")) {
|
||||||
launcher.classList.remove("mf-notify");
|
launcher.classList.remove("mf-notify");
|
||||||
scrollMessagesToBottom(messages);
|
scrollMessagesToBottom(messages);
|
||||||
|
markConversationSeen();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
close.addEventListener("click", () => panel.classList.remove("open"));
|
close.addEventListener("click", () => panel.classList.remove("open"));
|
||||||
@@ -175,7 +176,7 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
if (session.conversationId) {
|
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") {
|
if (data.type === "message:new") {
|
||||||
renderConversation(messages, data.payload);
|
renderConversation(messages, data.payload);
|
||||||
|
if (panel.classList.contains("open")) markConversationSeen();
|
||||||
const eventMessages = data.payload?.messages || [];
|
const eventMessages = data.payload?.messages || [];
|
||||||
const lastMessage = eventMessages[eventMessages.length - 1];
|
const lastMessage = eventMessages[eventMessages.length - 1];
|
||||||
if (settings.notifications?.pulseOnAdminReply !== false && lastMessage?.senderType === "admin" && !panel.classList.contains("open")) {
|
if (settings.notifications?.pulseOnAdminReply !== false && lastMessage?.senderType === "admin" && !panel.classList.contains("open")) {
|
||||||
@@ -267,10 +269,11 @@
|
|||||||
setTimeout(() => audio.close().catch(() => {}), 420);
|
setTimeout(() => audio.close().catch(() => {}), 420);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadExistingConversation(messages) {
|
async function loadExistingConversation(messages, panel) {
|
||||||
try {
|
try {
|
||||||
const data = await fetchJson(`${apiBase}/api/widget/conversations/${session.conversationId}?visitorToken=${encodeURIComponent(session.visitorToken)}`);
|
const data = await fetchJson(`${apiBase}/api/widget/conversations/${session.conversationId}?visitorToken=${encodeURIComponent(session.visitorToken)}`);
|
||||||
renderConversation(messages, data);
|
renderConversation(messages, data);
|
||||||
|
if (panel.classList.contains("open")) markConversationSeen();
|
||||||
} catch {
|
} catch {
|
||||||
session.conversationId = null;
|
session.conversationId = null;
|
||||||
saveSession();
|
saveSession();
|
||||||
@@ -288,6 +291,14 @@
|
|||||||
scrollMessagesToBottom(messages);
|
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) {
|
function addMessage(messages, sender, senderName, body, attachments, lastAdminName) {
|
||||||
if (sender === "admin" && (senderName || "Operator") !== lastAdminName) {
|
if (sender === "admin" && (senderName || "Operator") !== lastAdminName) {
|
||||||
const label = document.createElement("div");
|
const label = document.createElement("div");
|
||||||
|
|||||||
@@ -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 === "/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\/[^/]+$/) && 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\/[^/]+\/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\/[^/]+\/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);
|
if (url.pathname.match(/^\/api\/widget\/conversations\/[^/]+\/typing$/) && req.method === "POST") return visitorTyping(req, res, url);
|
||||||
|
|
||||||
@@ -137,6 +138,7 @@ function initDb() {
|
|||||||
ip TEXT,
|
ip TEXT,
|
||||||
country_code TEXT,
|
country_code TEXT,
|
||||||
vpn_risk INTEGER NOT NULL DEFAULT 0,
|
vpn_risk INTEGER NOT NULL DEFAULT 0,
|
||||||
|
visitor_seen_at TEXT,
|
||||||
last_message_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
last_message_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
updated_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")) {
|
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();
|
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) {
|
function ensureColumn(table, column, definition) {
|
||||||
@@ -515,6 +518,17 @@ async function createVisitorMessage(req, res, url) {
|
|||||||
return json(res, 201, conversationDetails(updated));
|
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) {
|
async function createAdminMessage(req, res, url) {
|
||||||
const body = await readJson(req, 8_000_000);
|
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" });
|
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),
|
visitorLabel: visitorLabel(row.visitor_token),
|
||||||
visitorOnline: Boolean(row.visitor_online),
|
visitorOnline: Boolean(row.visitor_online),
|
||||||
visitorPresence: row.visitor_presence || (row.visitor_online ? "active" : "offline"),
|
visitorPresence: row.visitor_presence || (row.visitor_online ? "active" : "offline"),
|
||||||
|
visitorSeenAt: row.visitor_seen_at,
|
||||||
visitor: {
|
visitor: {
|
||||||
name: row.visitor_name,
|
name: row.visitor_name,
|
||||||
email: row.visitor_email,
|
email: row.visitor_email,
|
||||||
|
|||||||
Reference in New Issue
Block a user