291 lines
8.7 KiB
TypeScript
291 lines
8.7 KiB
TypeScript
/**
|
|
* 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<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;
|
|
}
|
|
|
|
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<typeof setTimeout> | 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<string, ParticipantState> = {};
|
|
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 };
|
|
}
|
|
}
|