323 lines
9.7 KiB
TypeScript
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,
|
|
};
|
|
}
|