Files
the-matrix/js/visitor.js

142 lines
3.9 KiB
JavaScript
Raw Normal View History

feat: Workshop interaction layer — chat input, visitor presence, bark display (#40, #41, #42) 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
2026-03-19 01:46:04 +00:00
/**
* 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();
});
}
}