/** * WebSocket-based room sync for rMaps. * Ported from rmaps-online/src/lib/sync.ts (simplified — no @/types dependency). */ 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; } 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"; manual?: boolean; callerName?: string }; type SyncCallback = (state: RoomState) => void; type ConnectionCallback = (connected: boolean) => void; type LocationRequestCallback = (manual?: boolean, callerName?: string) => void; export function isValidLocation(location: LocationState | undefined): boolean { if (!location) return false; const { latitude, longitude } = location; if (latitude < -90 || latitude > 90 || longitude < -180 || longitude > 180) return false; if (latitude === 0 && longitude === 0) return false; 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; constructor( slug: string, participantId: string, onStateChange: SyncCallback, onConnectionChange: ConnectionCallback, onLocationRequest?: LocationRequestCallback, ) { this.slug = slug; this.participantId = participantId; this.onStateChange = onStateChange; this.onConnectionChange = onConnectionChange; this.onLocationRequest = onLocationRequest || null; this.state = this.loadState() || this.createInitialState(); } 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; return this.cleanupStaleParticipants(state); } } catch (e) { console.warn("Failed to load room state:", e); } return null; } private cleanupStaleParticipants(state: RoomState): RoomState { const STALE_MS = 15 * 60 * 1000; const now = Date.now(); const cleaned: Record = {}; for (const [id, p] of Object.entries(state.participants)) { const isStale = now - new Date(p.lastSeen).getTime() > STALE_MS; if (id === this.participantId || !isStale) { if (p.location && !isValidLocation(p.location)) delete p.location; cleaned[id] = p; } } return { ...state, participants: cleaned }; } private saveState(): void { try { localStorage.setItem(`rmaps_room_${this.slug}`, JSON.stringify(this.state)); } catch {} } private notifyStateChange(): void { this.saveState(); this.onStateChange({ ...this.state }); } connect(syncUrl?: string): void { if (!syncUrl) { this.onConnectionChange(true); return; } try { this.ws = new WebSocket(`${syncUrl}/room/${this.slug}`); this.ws.onopen = () => { this.onConnectionChange(true); if (this.currentParticipant) { this.send({ type: "join", participant: this.currentParticipant }); } }; this.ws.onmessage = (event) => { try { this.handleMessage(JSON.parse(event.data)); } catch {} }; this.ws.onclose = () => { this.onConnectionChange(false); this.scheduleReconnect(syncUrl); }; this.ws.onerror = () => {}; } catch { this.onConnectionChange(false); } } private scheduleReconnect(syncUrl: string): void { if (this.reconnectTimer) clearTimeout(this.reconnectTimer); this.reconnectTimer = setTimeout(() => 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": { const myP = this.state.participants[this.participantId]; this.state = message.state; if (myP) this.state.participants[this.participantId] = myP; 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]) { 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) { delete this.state.participants[message.participantId].location; this.state.participants[message.participantId].lastSeen = new Date().toISOString(); } } 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": if (this.onLocationRequest) this.onLocationRequest(message.manual, message.callerName); return; } this.notifyStateChange(); } join(participant: ParticipantState): void { this.currentParticipant = participant; this.state.participants[participant.id] = participant; 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 { if (!isValidLocation(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 }); this.notifyStateChange(); } } clearLocation(): void { if (this.state.participants[this.participantId]) { delete this.state.participants[this.participantId].location; this.state.participants[this.participantId].lastSeen = new Date().toISOString(); this.send({ type: "location", participantId: this.participantId, location: null as any }); 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(); } } 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 }; } }