'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) => 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(null); const [participants, setParticipants] = useState([]); const [waypoints, setWaypoints] = useState([]); const [roomName, setRoomName] = useState(slug); const syncRef = useRef(null); // Initialize with empty string - will be set properly on client side const participantIdRef = useRef(''); // 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) => { 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, }; }