@@ -369,7 +370,7 @@ function renderVisitors() {
${escapeHtml(visitor.label || "Navstevnik")}
${escapeHtml(visitor.isBot ? `bot: ${visitor.botReason || "crawler"}` : browserLabel(visitor.device))}
-
+ ${presenceDot(visitor.presence)}
${visitor.countryFlag || escapeHtml(visitor.countryCode || "?")}
@@ -383,6 +384,12 @@ function renderVisitors() {
`).join("") : `
Zatim nikdo online.
`; } +function presenceDot(presence) { + const value = ["active", "idle", "offline"].includes(presence) ? presence : "offline"; + const label = value === "active" ? "Aktivni" : value === "idle" ? "Neaktivni" : "Offline"; + return ``; +} + function pageTitle(visitor) { const history = Array.isArray(visitor.browsingHistory) ? visitor.browsingHistory : []; const current = history.find((item) => item.url === visitor.currentUrl) || history[history.length - 1]; diff --git a/public/widget.js b/public/widget.js index 21dac0d..ba3d1e6 100644 --- a/public/widget.js +++ b/public/widget.js @@ -7,6 +7,7 @@ const session = loadSession(); const allowedImageTypes = new Set(["image/jpeg", "image/png", "image/webp", "image/gif", "image/avif"]); const maxAttachmentBytes = 3_000_000; + let lastActivityAt = Date.now(); fetch(`${apiBase}/api/widget/config?siteKey=${encodeURIComponent(siteKey)}`) .then((res) => res.json()) @@ -164,10 +165,14 @@ }); rememberPage(); + trackVisitorActivity(root); pingVisitor(); setInterval(pingVisitor, 25_000); document.addEventListener("visibilitychange", () => { - if (!document.hidden) pingVisitor(); + if (!document.hidden) { + noteActivity(); + pingVisitor(); + } }); if (session.conversationId) { loadExistingConversation(messages).finally(() => connectEvents(messages, typing, panel, launcher, settings)); @@ -340,6 +345,7 @@ currentUrl: location.href, referrer: document.referrer, browsingHistory: rememberPage(), + lastActivityAt: new Date(lastActivityAt).toISOString(), language: navigator.language, timezone: Intl.DateTimeFormat().resolvedOptions().timeZone, device: { @@ -354,6 +360,20 @@ postJson(`${apiBase}/api/widget/visitors/ping`, withVisitorContext({})).catch(() => {}); } + function trackVisitorActivity(root) { + const passive = { passive: true, capture: true }; + for (const eventName of ["pointerdown", "keydown", "mousemove", "touchstart", "scroll"]) { + document.addEventListener(eventName, noteActivity, passive); + root.addEventListener(eventName, noteActivity, passive); + } + } + + function noteActivity() { + const now = Date.now(); + if (now - lastActivityAt < 1_000) return; + lastActivityAt = now; + } + function showFormError(root, message) { const note = root.querySelector(".mf-note"); note.textContent = message; diff --git a/server.js b/server.js index 1ab6353..3ccf4fb 100644 --- a/server.js +++ b/server.js @@ -19,6 +19,8 @@ const COOKIE_NAME = "mf_session"; const ALLOWED_IMAGE_TYPES = new Set(["image/jpeg", "image/png", "image/webp", "image/gif", "image/avif"]); const ALLOWED_IMAGE_EXTENSIONS = new Set([".jpg", ".jpeg", ".png", ".webp", ".gif", ".avif"]); const MAX_ATTACHMENT_BYTES = 3_000_000; +const ACTIVE_VISITOR_SECONDS = 90; +const PRESENT_VISITOR_SECONDS = 300; fs.mkdirSync(DATA_DIR, { recursive: true }); fs.mkdirSync(UPLOAD_DIR, { recursive: true }); @@ -175,6 +177,7 @@ function initDb() { ip TEXT, country_code TEXT, first_seen_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + last_activity_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, last_seen_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE(site_id, visitor_token) ); @@ -186,6 +189,9 @@ function initDb() { if (ensureColumn("conversations", "admin_seen_at", "TEXT")) { db.prepare("UPDATE conversations SET admin_seen_at = last_message_at WHERE admin_seen_at IS NULL").run(); } + 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(); + } } function ensureColumn(table, column, definition) { @@ -308,8 +314,19 @@ function adminConversations(res) { SELECT 1 FROM visitor_sessions vs WHERE vs.site_id = c.site_id AND vs.visitor_token = c.visitor_token - AND datetime(vs.last_seen_at) >= datetime('now', '-90 seconds') + AND datetime(vs.last_seen_at) >= datetime('now', '-${PRESENT_VISITOR_SECONDS} seconds') ) AS visitor_online, + COALESCE(( + SELECT CASE + WHEN datetime(vs.last_seen_at) < datetime('now', '-${PRESENT_VISITOR_SECONDS} seconds') THEN 'offline' + WHEN datetime(vs.last_activity_at) >= datetime('now', '-${ACTIVE_VISITOR_SECONDS} seconds') THEN 'active' + ELSE 'idle' + END + FROM visitor_sessions vs + WHERE vs.site_id = c.site_id AND vs.visitor_token = c.visitor_token + ORDER BY datetime(vs.last_seen_at) DESC + LIMIT 1 + ), 'offline') AS visitor_presence, (SELECT COUNT(*) FROM conversations vc WHERE vc.site_id = c.site_id AND vc.visitor_token = c.visitor_token) AS visitor_conversation_count FROM conversations c JOIN sites s ON s.id = c.site_id @@ -386,12 +403,13 @@ async function visitorPing(req, res) { const site = db.prepare("SELECT * FROM sites WHERE site_key = ?").get(body.siteKey || "9b-plus"); if (!site) return json(res, 404, { error: "site_not_found" }); const visitorToken = body.visitorToken || id("vis"); + const lastActivityAt = timestampOrNow(body.lastActivityAt); db.prepare(` INSERT INTO visitor_sessions ( company_id, site_id, visitor_token, current_url, referrer, browsing_history_json, - device_json, language, timezone, ip, country_code, first_seen_at, last_seen_at + device_json, language, timezone, ip, country_code, first_seen_at, last_activity_at, last_seen_at ) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP, ?, CURRENT_TIMESTAMP) ON CONFLICT(site_id, visitor_token) DO UPDATE SET current_url = excluded.current_url, referrer = COALESCE(excluded.referrer, visitor_sessions.referrer), @@ -401,6 +419,11 @@ async function visitorPing(req, res) { timezone = excluded.timezone, ip = excluded.ip, country_code = COALESCE(excluded.country_code, visitor_sessions.country_code), + last_activity_at = CASE + WHEN datetime(excluded.last_activity_at) > datetime(visitor_sessions.last_activity_at) + THEN excluded.last_activity_at + ELSE visitor_sessions.last_activity_at + END, last_seen_at = CURRENT_TIMESTAMP `).run( site.company_id, @@ -413,7 +436,8 @@ async function visitorPing(req, res) { nullish(body.language), nullish(body.timezone), clientIp(req), - nullish(countryFromHeaders(req)) + nullish(countryFromHeaders(req)), + lastActivityAt ); publish("admin", { type: "visitors:update", visitors: currentVisitors() }); return json(res, 200, { ok: true }); @@ -635,7 +659,7 @@ function currentVisitors() { (SELECT COUNT(*) FROM conversations c WHERE c.site_id = vs.site_id AND c.visitor_token = vs.visitor_token) AS visitor_conversation_count FROM visitor_sessions vs JOIN sites s ON s.id = vs.site_id - WHERE datetime(vs.last_seen_at) >= datetime('now', '-90 seconds') + WHERE datetime(vs.last_seen_at) >= datetime('now', '-${PRESENT_VISITOR_SECONDS} seconds') ORDER BY datetime(vs.last_seen_at) DESC LIMIT 100 `).all(); @@ -647,11 +671,13 @@ function formatVisitorSession(row) { const device = parseJson(row.device_json, {}); const bot = botInfo(device.userAgent); const countryCode = row.country_code || countryFromTimezone(row.timezone) || countryFromLanguage(row.language); + const presence = visitorPresence(row); return { id: row.visitor_token, initials: visitorInitials(row.visitor_token), label: visitorLabel(row.visitor_token), - online: true, + online: presence !== "offline", + presence, currentUrl: row.current_url, referrer: row.referrer, browsingHistory: history, @@ -667,10 +693,18 @@ function formatVisitorSession(row) { countryCode, countryFlag: countryCode ? flagEmoji(countryCode) : "", sessionSeconds: sessionSeconds(row.browsing_history_json, row.first_seen_at), + lastActivityAt: row.last_activity_at, lastSeenAt: row.last_seen_at }; } +function visitorPresence(row) { + const lastSeen = parseTimestampMs(row.last_seen_at); + if (!Number.isFinite(lastSeen) || Date.now() - lastSeen > PRESENT_VISITOR_SECONDS * 1000) return "offline"; + const lastActivity = parseTimestampMs(row.last_activity_at || row.last_seen_at); + return Number.isFinite(lastActivity) && Date.now() - lastActivity <= ACTIVE_VISITOR_SECONDS * 1000 ? "active" : "idle"; +} + function conversationDetails(conversation) { const messages = db.prepare("SELECT * FROM messages 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); @@ -699,6 +733,7 @@ function formatConversationSummary(row) { visitorInitials: visitorInitials(row.visitor_token), visitorLabel: visitorLabel(row.visitor_token), visitorOnline: Boolean(row.visitor_online), + visitorPresence: row.visitor_presence || (row.visitor_online ? "active" : "offline"), visitor: { name: row.visitor_name, email: row.visitor_email, @@ -779,8 +814,19 @@ function findConversation(publicId) { SELECT 1 FROM visitor_sessions vs WHERE vs.site_id = c.site_id AND vs.visitor_token = c.visitor_token - AND datetime(vs.last_seen_at) >= datetime('now', '-90 seconds') + AND datetime(vs.last_seen_at) >= datetime('now', '-${PRESENT_VISITOR_SECONDS} seconds') ) AS visitor_online, + COALESCE(( + SELECT CASE + WHEN datetime(vs.last_seen_at) < datetime('now', '-${PRESENT_VISITOR_SECONDS} seconds') THEN 'offline' + WHEN datetime(vs.last_activity_at) >= datetime('now', '-${ACTIVE_VISITOR_SECONDS} seconds') THEN 'active' + ELSE 'idle' + END + FROM visitor_sessions vs + WHERE vs.site_id = c.site_id AND vs.visitor_token = c.visitor_token + ORDER BY datetime(vs.last_seen_at) DESC + LIMIT 1 + ), 'offline') AS visitor_presence, (SELECT COUNT(*) FROM conversations vc WHERE vc.site_id = c.site_id AND vc.visitor_token = c.visitor_token) AS visitor_conversation_count FROM conversations c JOIN sites s ON s.id = c.site_id @@ -909,6 +955,21 @@ function nullish(value) { return value === undefined || value === "" ? null : value; } +function timestampOrNow(value) { + const parsed = Date.parse(value || ""); + if (!Number.isFinite(parsed)) return new Date().toISOString(); + const now = Date.now(); + const bounded = Math.min(Math.max(parsed, now - 24 * 60 * 60 * 1000), now); + return new Date(bounded).toISOString(); +} + +function parseTimestampMs(value) { + if (!value) return NaN; + 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; + return Date.parse(normalized); +} + function clientIp(req) { return (req.headers["x-forwarded-for"] || req.socket.remoteAddress || "").split(",")[0].trim(); }