Implements the minimum viable conversation loop for Workshop #222: visitor arrives → sends message → Timmy barks back. - js/visitor.js: Visitor presence protocol (#41) - visitor_entered on load (with device detection: ipad/desktop/mobile) - visitor_left on unload or 30s hidden (iPad tab suspend) - visitor_message dispatched from chat input - visitor_interaction export for future tap-to-interact (#44) - Session duration tracking - js/bark.js: Bark display system (#42) - showBark() renders prominent viewport toasts with typing animation - Auto-dismiss after display time + typing duration - Queue system (max 3 simultaneous, overflow queued) - Demo barks in mock mode (Workshop-themed: 222, sovereignty, chain) - Barks also logged permanently in chat panel - index.html: Chat input bar (#40) - Terminal-styled input + send button at viewport bottom - Enter to send (desktop), button tap (iPad) - Safe-area padding for notched devices - Chat panel repositioned above input bar - Bark container in upper viewport third - js/websocket.js: New message handlers - 'bark' message → showBark() dispatch - 'ambient_state' message → placeholder for #43 - Demo barks start in mock mode - js/ui.js: appendChatMessage() accepts optional CSS class - Visitor messages styled differently from agent messages Build: 18 modules, 0 errors Tested: desktop (1280x800) + mobile (390x844) via Playwright Closes #40, #41, #42 Ref: rockachopa/Timmy-time-dashboard#222, #243
142 lines
3.9 KiB
JavaScript
142 lines
3.9 KiB
JavaScript
/**
|
|
* 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();
|
|
});
|
|
}
|
|
}
|