diff --git a/public/admin.css b/public/admin.css index 80e1467..5948ab9 100644 --- a/public/admin.css +++ b/public/admin.css @@ -172,7 +172,7 @@ label { display: grid; gap: 6px; color: var(--muted); font-size: 13px; } text-overflow: ellipsis; white-space: nowrap; } -.visitor-name-wrap .online-dot { +.visitor-name-wrap .presence-dot { flex: 0 0 auto; } .unread-dot { @@ -442,13 +442,23 @@ label { display: grid; gap: 6px; color: var(--muted); font-size: 13px; } font-weight: 800; font-size: 13px; } -.online-dot { +.presence-dot { width: 9px; height: 9px; + flex: 0 0 auto; border-radius: 50%; +} +.presence-dot.active { background: #2f9e44; box-shadow: 0 0 0 4px rgb(47 158 68 / 14%); } +.presence-dot.idle { + background: #f59f00; + box-shadow: 0 0 0 4px rgb(245 159 0 / 16%); +} +.presence-dot.offline { + background: #c9d4ce; +} .visitor-compact-meta { display: flex; align-items: center; diff --git a/public/admin.js b/public/admin.js index 777fb25..dc585f4 100644 --- a/public/admin.js +++ b/public/admin.js @@ -321,7 +321,7 @@ function renderConversations() { ${escapeHtml(conversationVisitorName(item))} - ${item.visitorOnline ? `` : ""} + ${presenceDot(item.visitorPresence)} ${item.imageCount ? `foto ${item.imageCount}` : ""} @@ -344,10 +344,11 @@ function renderConversations() { function syncConversationVisitors() { if (!state.visitorsLoaded) return; - const onlineIds = new Set(state.visitors.map((visitor) => visitor.id)); + const visitorsById = new Map(state.visitors.map((visitor) => [visitor.id, visitor])); state.conversations = state.conversations.map((item) => ({ ...item, - visitorOnline: onlineIds.has(item.visitorToken) + visitorOnline: visitorsById.has(item.visitorToken), + visitorPresence: visitorsById.get(item.visitorToken)?.presence || "offline" })); } @@ -360,7 +361,7 @@ function renderVisitors() { const botCount = state.visitors.filter((visitor) => visitor.isBot).length; $("#hideBotsToggle").classList.toggle("active", state.hideBots); $("#hideBotsToggle").textContent = state.hideBots ? "Skryt boty" : "Boty videt"; - $("#visitorCount").textContent = `${visitors.length} online${state.hideBots && botCount ? ` · ${botCount} bot` : ""}`; + $("#visitorCount").textContent = `${visitors.length} na webu${state.hideBots && botCount ? ` · ${botCount} bot` : ""}`; $("#visitorList").innerHTML = visitors.length ? visitors.map((visitor) => `
@@ -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(); }