rmaps-online/src/hooks/useRoom.ts

323 lines
9.7 KiB
TypeScript

'use client';
import { useState, useEffect, useCallback, useRef } from 'react';
import { nanoid } from 'nanoid';
import {
RoomSync,
stateToParticipant,
stateToWaypoint,
type ParticipantState,
type LocationState,
type WaypointState,
type RoomState,
} from '@/lib/sync';
import type { Participant, ParticipantLocation, Waypoint } from '@/types';
// Color palette for participants
const COLORS = [
'#10b981', // emerald
'#6366f1', // indigo
'#f59e0b', // amber
'#ef4444', // red
'#8b5cf6', // violet
'#ec4899', // pink
'#14b8a6', // teal
'#f97316', // orange
'#84cc16', // lime
'#06b6d4', // cyan
];
// Service worker room state caching
function saveRoomStateToSW(slug: string, state: { participants: Participant[]; waypoints: Waypoint[] }) {
if (typeof navigator === 'undefined' || !navigator.serviceWorker?.controller) return;
navigator.serviceWorker.controller.postMessage({
type: 'SAVE_ROOM_STATE',
slug,
state,
});
}
async function loadRoomStateFromSW(slug: string): Promise<{ participants: Participant[]; waypoints: Waypoint[] } | null> {
if (typeof navigator === 'undefined' || !navigator.serviceWorker?.controller) return null;
return new Promise((resolve) => {
const timeout = setTimeout(() => resolve(null), 2000);
const handler = (event: MessageEvent) => {
if (event.data?.type === 'ROOM_STATE' && event.data.slug === slug) {
clearTimeout(timeout);
navigator.serviceWorker?.removeEventListener('message', handler);
resolve(event.data.state || null);
}
};
navigator.serviceWorker.addEventListener('message', handler);
navigator.serviceWorker.controller!.postMessage({
type: 'GET_ROOM_STATE',
slug,
});
});
}
/**
* Get or create a persistent participant ID for this browser/room combination.
* Uses EncryptID DID when authenticated for cross-session persistence,
* otherwise falls back to localStorage nanoid.
*/
function getOrCreateParticipantId(slug: string, encryptIdDid?: string | null): string {
// If authenticated with EncryptID, use DID as stable identity
if (encryptIdDid) {
console.log('Using EncryptID DID as participant ID:', encryptIdDid.slice(0, 20) + '...');
return encryptIdDid;
}
const storageKey = `rmaps_participant_id_${slug}`;
try {
let participantId = localStorage.getItem(storageKey);
if (!participantId) {
participantId = nanoid();
localStorage.setItem(storageKey, participantId);
console.log('Created new participant ID:', participantId);
} else {
console.log('Using existing participant ID:', participantId);
}
return participantId;
} catch {
// localStorage not available (SSR or private browsing)
console.warn('localStorage not available, generating temporary participant ID');
return nanoid();
}
}
interface UseRoomOptions {
slug: string;
userName: string;
userEmoji: string;
/** EncryptID DID for persistent cross-session identity (optional) */
encryptIdDid?: string | null;
/** EncryptID JWT token for authenticated sync server access (optional) */
authToken?: string | null;
}
interface UseRoomReturn {
isConnected: boolean;
isLoading: boolean;
error: string | null;
participants: Participant[];
waypoints: Waypoint[];
currentParticipantId: string | null;
roomName: string;
updateLocation: (location: ParticipantLocation) => void;
updateIndoorPosition: (position: { level: number; x: number; y: number }) => void;
clearLocation: () => void;
setStatus: (status: Participant['status']) => void;
addWaypoint: (waypoint: Omit<Waypoint, 'id' | 'createdAt' | 'createdBy'>) => void;
removeWaypoint: (waypointId: string) => void;
leave: () => void;
setLocationRequestCallback: (callback: (manual?: boolean, callerName?: string) => void) => void;
}
export function useRoom({ slug, userName, userEmoji, encryptIdDid, authToken }: UseRoomOptions): UseRoomReturn {
const [isConnected, setIsConnected] = useState(false);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [participants, setParticipants] = useState<Participant[]>([]);
const [waypoints, setWaypoints] = useState<Waypoint[]>([]);
const [roomName, setRoomName] = useState(slug);
const syncRef = useRef<RoomSync | null>(null);
// Initialize with empty string - will be set properly on client side
const participantIdRef = useRef<string>('');
// Track if we've loaded cached state (to avoid overwriting with stale data)
const hasSyncedRef = useRef(false);
// Handle state updates from sync
const handleStateChange = useCallback((state: RoomState) => {
const newParticipants = Object.values(state.participants).map(stateToParticipant);
const newWaypoints = state.waypoints.map(stateToWaypoint);
setParticipants(newParticipants);
setWaypoints(newWaypoints);
setRoomName(state.name || slug);
// Mark as synced and save to service worker for offline access
hasSyncedRef.current = true;
saveRoomStateToSW(slug, { participants: newParticipants, waypoints: newWaypoints });
}, [slug]);
// Handle connection changes
const handleConnectionChange = useCallback((connected: boolean) => {
setIsConnected(connected);
}, []);
// Load cached room state from service worker for offline fallback
useEffect(() => {
if (!slug) return;
loadRoomStateFromSW(slug).then((cachedState) => {
if (cachedState && !hasSyncedRef.current) {
console.log('[useRoom] Loaded cached room state:', cachedState);
setParticipants(cachedState.participants || []);
setWaypoints(cachedState.waypoints || []);
}
});
}, [slug]);
// Initialize room connection
useEffect(() => {
if (!userName) {
// No user yet - not loading, just waiting
setIsLoading(false);
return;
}
setIsLoading(true);
setError(null);
// Use persistent participant ID to avoid creating duplicate "ghost" participants
const participantId = getOrCreateParticipantId(slug, encryptIdDid);
participantIdRef.current = participantId;
const color = COLORS[Math.floor(Math.random() * COLORS.length)];
// Create sync instance (pass auth token for server-side access control)
const sync = new RoomSync(
slug,
participantId,
handleStateChange,
handleConnectionChange,
undefined,
authToken
);
syncRef.current = sync;
// Create participant state
const participant: ParticipantState = {
id: participantId,
name: userName,
emoji: userEmoji,
color,
joinedAt: new Date().toISOString(),
lastSeen: new Date().toISOString(),
status: 'online',
};
// Join room
sync.join(participant);
// Connect to sync server (if available)
// For now, runs in local-only mode
const syncUrl = process.env.NEXT_PUBLIC_SYNC_URL;
sync.connect(syncUrl);
setIsLoading(false);
return () => {
sync.leave();
syncRef.current = null;
};
}, [slug, userName, userEmoji, handleStateChange, handleConnectionChange]);
// Update location
const updateLocation = useCallback((location: ParticipantLocation) => {
if (!syncRef.current) return;
const locationState: LocationState = {
latitude: location.latitude,
longitude: location.longitude,
accuracy: location.accuracy,
altitude: location.altitude,
heading: location.heading,
speed: location.speed,
timestamp: location.timestamp.toISOString(),
source: location.source,
indoor: location.indoor,
};
syncRef.current.updateLocation(locationState);
}, []);
// Clear location (when user stops sharing)
const clearLocation = useCallback(() => {
if (!syncRef.current) return;
syncRef.current.clearLocation();
}, []);
// Update indoor position (from c3nav map tap)
const updateIndoorPosition = useCallback((position: { level: number; x: number; y: number }) => {
if (!syncRef.current) return;
syncRef.current.updateIndoorPosition(position);
}, []);
// Set status
const setStatus = useCallback((status: Participant['status']) => {
if (!syncRef.current) return;
syncRef.current.updateStatus(status);
}, []);
// Add waypoint
const addWaypoint = useCallback(
(waypoint: Omit<Waypoint, 'id' | 'createdAt' | 'createdBy'>) => {
if (!syncRef.current) return;
const waypointState: WaypointState = {
id: nanoid(),
name: waypoint.name,
emoji: waypoint.emoji,
latitude: waypoint.location.latitude,
longitude: waypoint.location.longitude,
indoor: waypoint.location.indoor,
createdBy: participantIdRef.current,
createdAt: new Date().toISOString(),
type: waypoint.type,
};
syncRef.current.addWaypoint(waypointState);
},
[]
);
// Remove waypoint
const removeWaypoint = useCallback((waypointId: string) => {
if (!syncRef.current) return;
syncRef.current.removeWaypoint(waypointId);
}, []);
// Leave room
const leave = useCallback(() => {
if (!syncRef.current) return;
syncRef.current.leave();
syncRef.current = null;
setIsConnected(false);
}, []);
// Set location request callback (called when server requests location update)
const setLocationRequestCallback = useCallback((callback: (manual?: boolean, callerName?: string) => void) => {
if (syncRef.current) {
syncRef.current.setLocationRequestCallback(callback);
}
}, []);
return {
isConnected,
isLoading,
error,
participants,
waypoints,
currentParticipantId: participantIdRef.current,
roomName,
updateLocation,
updateIndoorPosition,
clearLocation,
setStatus,
addWaypoint,
removeWaypoint,
leave,
setLocationRequestCallback,
};
}