diff --git a/public/logo-icon-maskable.png b/public/logo-icon-maskable.png new file mode 100644 index 0000000..cd91b66 Binary files /dev/null and b/public/logo-icon-maskable.png differ diff --git a/public/manifest.json b/public/manifest.json index c0eaf1d..4e69ecd 100644 --- a/public/manifest.json +++ b/public/manifest.json @@ -20,7 +20,13 @@ "src": "/logo-icon.png", "sizes": "192x192", "type": "image/png", - "purpose": "any maskable" + "purpose": "any" + }, + { + "src": "/logo-icon-maskable.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "maskable" } ], "screenshots": [], diff --git a/public/sw.js b/public/sw.js index 118917f..3aab8b4 100644 --- a/public/sw.js +++ b/public/sw.js @@ -30,6 +30,9 @@ self.addEventListener('fetch', (event) => { // Skip non-GET requests if (event.request.method !== 'GET') return + // Never intercept Socket.IO — let it pass through directly + if (pathname.startsWith('/socket.io')) return + // Cacheable API routes: Network First with cache fallback (icons, hose-types, dictionary) if (CACHEABLE_API.some(p => pathname.startsWith(p))) { event.respondWith( diff --git a/src/app/app/page.tsx b/src/app/app/page.tsx index 2bbd55f..5536a3e 100644 --- a/src/app/app/page.tsx +++ b/src/app/app/page.tsx @@ -20,7 +20,7 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/u import { JournalView } from '@/components/journal/journal-view' import { jsPDF } from 'jspdf' import { Lock, Unlock, Eye, AlertTriangle, WifiOff } from 'lucide-react' -import { getSocket } from '@/lib/socket' +import { getSocket, setSocketRoom } from '@/lib/socket' import { CustomDragLayer } from '@/components/map/custom-drag-layer' import { addToSyncQueue, flushSyncQueue, getSyncQueue, isOnline as checkOnline } from '@/lib/offline-sync' @@ -507,27 +507,31 @@ export default function AppPage() { const socketRef = useRef(null) const prevProjectIdRef = useRef(null) - // Throttled socket broadcast for near-real-time sync (1.5s instead of 10s auto-save) + // Throttled socket broadcast for near-real-time sync const lastEmitRef = useRef(0) const emitTimerRef = useRef | null>(null) + const currentProjectRef = useRef(currentProject) + useEffect(() => { currentProjectRef.current = currentProject }, [currentProject]) + const broadcastFeatures = useCallback((feats: DrawFeature[]) => { - if (!socketRef.current || !currentProject?.id || !isEditingByMe) return + const proj = currentProjectRef.current + if (!socketRef.current || !proj?.id || !isEditingByMeRef.current) return const now = Date.now() const emit = () => { socketRef.current?.emit('features-updated', { - projectId: currentProject!.id, + projectId: proj!.id, features: feats, }) lastEmitRef.current = Date.now() } - // Throttle: emit at most every 1.5 seconds - if (now - lastEmitRef.current > 1500) { + // Throttle: emit at most every 800ms for snappier sync + if (now - lastEmitRef.current > 800) { emit() } else { if (emitTimerRef.current) clearTimeout(emitTimerRef.current) - emitTimerRef.current = setTimeout(emit, 1500 - (now - lastEmitRef.current)) + emitTimerRef.current = setTimeout(emit, 800 - (now - lastEmitRef.current)) } - }, [currentProject?.id, isEditingByMe]) + }, []) const isEditingByMeRef = useRef(false) // Keep ref in sync with state @@ -546,6 +550,7 @@ export default function AppPage() { socket.emit('leave-project', prevProjectIdRef.current) } socket.emit('join-project', currentProject.id) + setSocketRoom(currentProject.id) prevProjectIdRef.current = currentProject.id // Listen for features changes from other clients (only apply if NOT the editor) diff --git a/src/lib/socket.ts b/src/lib/socket.ts index 04111ca..a0d32a1 100644 --- a/src/lib/socket.ts +++ b/src/lib/socket.ts @@ -3,23 +3,50 @@ import { io, Socket } from 'socket.io-client' let socket: Socket | null = null +let currentRoom: string | null = null export function getSocket(): Socket { if (!socket) { socket = io({ path: '/socket.io', - transports: ['polling', 'websocket'], + transports: ['websocket', 'polling'], upgrade: true, - reconnectionAttempts: 10, - reconnectionDelay: 2000, + reconnection: true, + reconnectionAttempts: Infinity, + reconnectionDelay: 1000, + reconnectionDelayMax: 5000, timeout: 10000, + forceNew: false, }) socket.on('connect', () => { console.log('[Socket.io] Connected:', socket?.id) + // Re-join project room after reconnect + if (currentRoom) { + console.log('[Socket.io] Re-joining room:', currentRoom) + socket?.emit('join-project', currentRoom) + } + }) + socket.on('disconnect', (reason) => { + console.warn('[Socket.io] Disconnected:', reason) + if (reason === 'io server disconnect') { + // Server disconnected us, need to manually reconnect + socket?.connect() + } }) socket.on('connect_error', (err) => { console.warn('[Socket.io] Connection error:', err.message) }) + socket.io.on('reconnect', (attempt) => { + console.log('[Socket.io] Reconnected after', attempt, 'attempts') + }) + socket.io.on('reconnect_attempt', (attempt) => { + console.log('[Socket.io] Reconnect attempt', attempt) + }) } return socket } + +/** Track which room the socket should be in (for auto-rejoin on reconnect) */ +export function setSocketRoom(projectId: string | null): void { + currentRoom = projectId +}