503 lines
16 KiB
TypeScript
503 lines
16 KiB
TypeScript
/**
|
|
* 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<string, ParticipantState>;
|
|
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<typeof setTimeout> | 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<string, ParticipantState> = {};
|
|
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'],
|
|
};
|
|
}
|