/** * visitor.js — Visitor presence protocol for the Workshop. * * Announces when a visitor enters and leaves the 3D world, * sends chat messages, and tracks session duration. * * Resolves Issue #41 — Visitor presence protocol * Resolves Issue #40 — Chat input (visitor message sending) */ import { sendMessage, getConnectionState } from './websocket.js'; import { appendChatMessage } from './ui.js'; let sessionStart = Date.now(); let visibilityTimeout = null; const VISIBILITY_LEAVE_MS = 30000; // 30s hidden = considered "left" /** * Detect device type from UA + touch capability. */ function detectDevice() { const ua = navigator.userAgent; const hasTouch = 'ontouchstart' in window || navigator.maxTouchPoints > 0; if (/iPad/.test(ua) || (hasTouch && /Macintosh/.test(ua))) return 'ipad'; if (/iPhone|iPod/.test(ua)) return 'mobile'; if (/Android/.test(ua) && hasTouch) return 'mobile'; if (hasTouch && window.innerWidth < 768) return 'mobile'; return 'desktop'; } /** * Send visitor_entered event to the backend. */ function announceEntry() { sessionStart = Date.now(); sendMessage({ type: 'visitor_entered', device: detectDevice(), viewport: { w: window.innerWidth, h: window.innerHeight }, timestamp: new Date().toISOString(), }); } /** * Send visitor_left event to the backend. */ function announceLeave() { const duration = Math.round((Date.now() - sessionStart) / 1000); sendMessage({ type: 'visitor_left', duration_seconds: duration, timestamp: new Date().toISOString(), }); } /** * Send a chat message from the visitor to Timmy. * @param {string} text — the visitor's message */ export function sendVisitorMessage(text) { const trimmed = text.trim(); if (!trimmed) return; // Show in local chat panel immediately const isOffline = getConnectionState() !== 'connected' && getConnectionState() !== 'mock'; const label = isOffline ? 'YOU (offline)' : 'YOU'; appendChatMessage(label, trimmed, '#888888', 'visitor'); // Send via WebSocket sendMessage({ type: 'visitor_message', text: trimmed, timestamp: new Date().toISOString(), }); } /** * Send a visitor_interaction event (e.g., tapped an agent). * @param {string} targetId — the ID of the interacted object * @param {string} action — the type of interaction */ export function sendVisitorInteraction(targetId, action) { sendMessage({ type: 'visitor_interaction', target: targetId, action: action, timestamp: new Date().toISOString(), }); } /** * Initialize the visitor presence system. * Sets up lifecycle events and chat input handling. */ export function initVisitor() { // Announce entry after a small delay (let WS connect first) setTimeout(announceEntry, 1500); // Visibility change handling (iPad tab suspend) document.addEventListener('visibilitychange', () => { if (document.hidden) { // Start countdown — if hidden for 30s, announce leave visibilityTimeout = setTimeout(announceLeave, VISIBILITY_LEAVE_MS); } else { // Returned before timeout — cancel leave if (visibilityTimeout) { clearTimeout(visibilityTimeout); visibilityTimeout = null; } else { // Was gone long enough that we sent visitor_left — re-announce entry announceEntry(); } } }); // Before unload — best-effort leave announcement window.addEventListener('beforeunload', () => { announceLeave(); }); // Chat input handling const $input = document.getElementById('chat-input'); const $send = document.getElementById('chat-send'); if ($input && $send) { $input.addEventListener('keydown', (e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendVisitorMessage($input.value); $input.value = ''; } }); $send.addEventListener('click', () => { sendVisitorMessage($input.value); $input.value = ''; $input.focus(); }); } }