/** * Simple WebSocket-based room sync * * This is a lightweight sync layer that works without WASM dependencies. * Can be replaced with Automerge later when we have a proper sync server. * * Architecture: * - Each room is a shared state object * - Changes are broadcast via WebSocket to all participants * - State is stored in localStorage for reconnection * - Falls back to local-only mode if WebSocket unavailable */ import type { Participant, ParticipantLocation, Waypoint } from '@/types'; // Room state that gets synced export interface RoomState { id: string; slug: string; name: string; createdAt: string; participants: Record; waypoints: WaypointState[]; } export interface ParticipantState { id: string; name: string; emoji: string; color: string; joinedAt: string; lastSeen: string; status: string; location?: LocationState; } export interface LocationState { latitude: number; longitude: number; accuracy: number; altitude?: number; heading?: number; speed?: number; timestamp: string; source: string; indoor?: { level: number; x: number; y: number; spaceName?: string; }; } export interface WaypointState { id: string; name: string; emoji?: string; latitude: number; longitude: number; indoor?: { level: number; x: number; y: number; }; createdBy: string; createdAt: string; type: string; } // Message types for sync export type SyncMessage = | { type: 'join'; participant: ParticipantState } | { type: 'leave'; participantId: string } | { type: 'location'; participantId: string; location: LocationState } | { type: 'status'; participantId: string; status: string } | { type: 'waypoint_add'; waypoint: WaypointState } | { type: 'waypoint_remove'; waypointId: string } | { type: 'full_state'; state: RoomState } | { type: 'request_state' } | { type: 'request_location' }; type SyncCallback = (state: RoomState) => void; type ConnectionCallback = (connected: boolean) => void; type LocationRequestCallback = () => void; // Validate that coordinates are reasonable (not 0,0 or out of bounds) function isValidLocation(location: LocationState | undefined): boolean { if (!location) return false; const { latitude, longitude } = location; // Basic bounds check if (latitude < -90 || latitude > 90 || longitude < -180 || longitude > 180) return false; // Reject (0,0) which is a common invalid default if (latitude === 0 && longitude === 0) return false; // Reject NaN if (isNaN(latitude) || isNaN(longitude)) return false; return true; } export class RoomSync { private slug: string; private state: RoomState; private ws: WebSocket | null = null; private reconnectTimer: ReturnType | null = null; private onStateChange: SyncCallback; private onConnectionChange: ConnectionCallback; private onLocationRequest: LocationRequestCallback | null = null; private participantId: string; private currentParticipant: ParticipantState | null = null; private authToken: string | null = null; constructor( slug: string, participantId: string, onStateChange: SyncCallback, onConnectionChange: ConnectionCallback, onLocationRequest?: LocationRequestCallback, authToken?: string | null ) { this.slug = slug; this.participantId = participantId; this.onStateChange = onStateChange; this.onConnectionChange = onConnectionChange; this.onLocationRequest = onLocationRequest || null; this.authToken = authToken || null; // Initialize or load state this.state = this.loadState() || this.createInitialState(); } setLocationRequestCallback(callback: LocationRequestCallback): void { this.onLocationRequest = callback; } private createInitialState(): RoomState { return { id: crypto.randomUUID(), slug: this.slug, name: this.slug, createdAt: new Date().toISOString(), participants: {}, waypoints: [], }; } private loadState(): RoomState | null { try { const stored = localStorage.getItem(`rmaps_room_${this.slug}`); if (stored) { const state = JSON.parse(stored) as RoomState; // Clean up stale participants (not seen in last hour) return this.cleanupStaleParticipants(state); } } catch (e) { console.warn('Failed to load room state:', e); } return null; } private cleanupStaleParticipants(state: RoomState): RoomState { const STALE_THRESHOLD_MS = 15 * 60 * 1000; // 15 minutes (was 1 hour) const now = Date.now(); const cleanedParticipants: Record = {}; let removedCount = 0; let invalidLocationCount = 0; for (const [id, participant] of Object.entries(state.participants)) { const lastSeen = new Date(participant.lastSeen).getTime(); const isStale = now - lastSeen > STALE_THRESHOLD_MS; const isCurrentUser = id === this.participantId; // Keep current user (they'll be updated) and non-stale participants if (isCurrentUser || !isStale) { // Also clean up invalid locations if (participant.location && !isValidLocation(participant.location)) { console.log(`Removing invalid location for participant ${id}:`, participant.location); delete participant.location; invalidLocationCount++; } cleanedParticipants[id] = participant; } else { removedCount++; } } if (removedCount > 0) { console.log(`Cleaned up ${removedCount} stale participant(s) from room state`); } if (invalidLocationCount > 0) { console.log(`Cleaned up ${invalidLocationCount} invalid location(s) from room state`); } return { ...state, participants: cleanedParticipants, }; } private saveState(): void { try { localStorage.setItem(`rmaps_room_${this.slug}`, JSON.stringify(this.state)); } catch (e) { console.warn('Failed to save room state:', e); } } private notifyStateChange(): void { this.saveState(); this.onStateChange({ ...this.state }); } // Connect to sync server (when available) connect(syncUrl?: string): void { if (!syncUrl) { // No sync server - local only mode console.log('Running in local-only mode (no sync server)'); this.onConnectionChange(true); return; } try { const wsUrl = this.authToken ? `${syncUrl}/room/${this.slug}?token=${encodeURIComponent(this.authToken)}` : `${syncUrl}/room/${this.slug}`; this.ws = new WebSocket(wsUrl); this.ws.onopen = () => { console.log('Connected to sync server'); this.onConnectionChange(true); // Send join message if we have participant info if (this.currentParticipant) { console.log('Sending join message for:', this.currentParticipant.name); this.send({ type: 'join', participant: this.currentParticipant }); } }; this.ws.onmessage = (event) => { try { const message: SyncMessage = JSON.parse(event.data); this.handleMessage(message); } catch (e) { console.warn('Invalid sync message:', e); } }; this.ws.onclose = () => { console.log('Disconnected from sync server'); this.onConnectionChange(false); this.scheduleReconnect(syncUrl); }; this.ws.onerror = (error) => { console.error('WebSocket error:', error); }; } catch (e) { console.error('Failed to connect to sync server:', e); this.onConnectionChange(false); } } private scheduleReconnect(syncUrl: string): void { if (this.reconnectTimer) { clearTimeout(this.reconnectTimer); } this.reconnectTimer = setTimeout(() => { console.log('Attempting to reconnect...'); this.connect(syncUrl); }, 5000); } private send(message: SyncMessage): void { if (this.ws?.readyState === WebSocket.OPEN) { this.ws.send(JSON.stringify(message)); } } private handleMessage(message: SyncMessage): void { switch (message.type) { case 'full_state': // Merge with local state, keeping our participant const myParticipant = this.state.participants[this.participantId]; this.state = message.state; if (myParticipant) { this.state.participants[this.participantId] = myParticipant; } break; case 'join': this.state.participants[message.participant.id] = message.participant; break; case 'leave': delete this.state.participants[message.participantId]; break; case 'location': if (this.state.participants[message.participantId]) { // Validate location before accepting if (message.location && isValidLocation(message.location)) { this.state.participants[message.participantId].location = message.location; this.state.participants[message.participantId].lastSeen = new Date().toISOString(); } else if (message.location === null) { // Allow explicit null to clear location delete this.state.participants[message.participantId].location; this.state.participants[message.participantId].lastSeen = new Date().toISOString(); } else { console.warn('Ignoring invalid location from participant:', message.participantId, message.location); } } break; case 'status': if (this.state.participants[message.participantId]) { this.state.participants[message.participantId].status = message.status; this.state.participants[message.participantId].lastSeen = new Date().toISOString(); } break; case 'waypoint_add': this.state.waypoints.push(message.waypoint); break; case 'waypoint_remove': this.state.waypoints = this.state.waypoints.filter( (w) => w.id !== message.waypointId ); break; case 'request_location': // Server is requesting a location update from us console.log('[RoomSync] Received location request from server'); if (this.onLocationRequest) { this.onLocationRequest(); } return; // Don't notify state change for this message type } this.notifyStateChange(); } // Public methods for updating state join(participant: ParticipantState): void { // Store participant for sending after WebSocket connects this.currentParticipant = participant; this.state.participants[participant.id] = participant; // Try to send join (will succeed if already connected) this.send({ type: 'join', participant }); this.notifyStateChange(); } leave(): void { delete this.state.participants[this.participantId]; this.send({ type: 'leave', participantId: this.participantId }); this.notifyStateChange(); if (this.ws) { this.ws.close(); this.ws = null; } if (this.reconnectTimer) { clearTimeout(this.reconnectTimer); } } updateLocation(location: LocationState): void { console.log('RoomSync.updateLocation called:', location.latitude, location.longitude); // Validate location before setting if (!isValidLocation(location)) { console.warn('Rejecting invalid location:', location); return; } if (this.state.participants[this.participantId]) { this.state.participants[this.participantId].location = location; this.state.participants[this.participantId].lastSeen = new Date().toISOString(); this.send({ type: 'location', participantId: this.participantId, location }); console.log('Location set for participant:', this.participantId, 'Total participants:', Object.keys(this.state.participants).length); this.notifyStateChange(); } else { console.warn('Cannot update location - participant not found:', this.participantId); } } clearLocation(): void { console.log('RoomSync.clearLocation called'); if (this.state.participants[this.participantId]) { delete this.state.participants[this.participantId].location; this.state.participants[this.participantId].lastSeen = new Date().toISOString(); // Broadcast a null location to clear it for other participants this.send({ type: 'location', participantId: this.participantId, location: null as any }); console.log('Location cleared for participant:', this.participantId); this.notifyStateChange(); } } updateStatus(status: string): void { if (this.state.participants[this.participantId]) { this.state.participants[this.participantId].status = status; this.state.participants[this.participantId].lastSeen = new Date().toISOString(); this.send({ type: 'status', participantId: this.participantId, status }); this.notifyStateChange(); } } updateIndoorPosition(indoor: { level: number; x: number; y: number }): void { console.log('RoomSync.updateIndoorPosition called:', indoor.level, indoor.x, indoor.y); if (this.state.participants[this.participantId]) { // Update or create location with indoor data // Use CCH venue center as default when no outdoor location exists const existingLocation = this.state.participants[this.participantId].location; const CCH_CENTER = { latitude: 53.5555, longitude: 9.9898 }; // Hamburg CCH venue center const location: LocationState = existingLocation || { latitude: CCH_CENTER.latitude, longitude: CCH_CENTER.longitude, accuracy: 50, // Indoor accuracy timestamp: new Date().toISOString(), source: 'manual', }; location.indoor = { level: indoor.level, x: indoor.x, y: indoor.y, }; location.timestamp = new Date().toISOString(); location.source = 'manual'; this.state.participants[this.participantId].location = location; this.state.participants[this.participantId].lastSeen = new Date().toISOString(); this.send({ type: 'location', participantId: this.participantId, location }); console.log('Indoor position set for participant:', this.participantId); this.notifyStateChange(); } else { console.warn('Cannot update indoor position - participant not found:', this.participantId); } } addWaypoint(waypoint: WaypointState): void { this.state.waypoints.push(waypoint); this.send({ type: 'waypoint_add', waypoint }); this.notifyStateChange(); } removeWaypoint(waypointId: string): void { this.state.waypoints = this.state.waypoints.filter((w) => w.id !== waypointId); this.send({ type: 'waypoint_remove', waypointId }); this.notifyStateChange(); } getState(): RoomState { return { ...this.state }; } } // Convert sync state to typed Participant export function stateToParticipant(state: ParticipantState): Participant { return { id: state.id, name: state.name, emoji: state.emoji, color: state.color, joinedAt: new Date(state.joinedAt), lastSeen: new Date(state.lastSeen), status: state.status as Participant['status'], location: state.location ? { latitude: state.location.latitude, longitude: state.location.longitude, accuracy: state.location.accuracy, altitude: state.location.altitude, heading: state.location.heading, speed: state.location.speed, timestamp: new Date(state.location.timestamp), source: state.location.source as ParticipantLocation['source'], indoor: state.location.indoor, } : undefined, privacySettings: { sharingEnabled: true, defaultPrecision: 'exact', showIndoorFloor: true, ghostMode: false, }, }; } // Convert sync state to typed Waypoint export function stateToWaypoint(state: WaypointState): Waypoint { return { id: state.id, name: state.name, emoji: state.emoji, location: { latitude: state.latitude, longitude: state.longitude, indoor: state.indoor, }, createdBy: state.createdBy, createdAt: new Date(state.createdAt), type: state.type as Waypoint['type'], }; }