diff --git a/public/sw.js b/public/sw.js index 21811d6..6453c57 100644 --- a/public/sw.js +++ b/public/sw.js @@ -1,29 +1,266 @@ -// rMaps Service Worker for Push Notifications & Background Location -const CACHE_NAME = 'rmaps-v1'; +// rMaps Service Worker for Push Notifications, Background Location & Offline Support +const CACHE_VERSION = 'v2'; +const STATIC_CACHE = `rmaps-static-${CACHE_VERSION}`; +const TILE_CACHE = `rmaps-tiles-${CACHE_VERSION}`; +const DATA_CACHE = `rmaps-data-${CACHE_VERSION}`; const SYNC_TAG = 'rmaps-location-sync'; -// Install event - cache essential assets +// Assets to precache for app shell +const PRECACHE_ASSETS = [ + '/', + '/icon-192.png', + '/icon-512.png', + '/manifest.json', +]; + +// Tile URL patterns to cache +const TILE_PATTERNS = [ + /basemaps\.cartocdn\.com/, + /tile\.openstreetmap\.org/, +]; + +// Max tiles to cache (prevent storage bloat) +const MAX_TILE_CACHE_SIZE = 500; + +// ==================== INSTALL ==================== self.addEventListener('install', (event) => { console.log('[SW] Installing service worker...'); - self.skipWaiting(); + + event.waitUntil( + caches.open(STATIC_CACHE) + .then((cache) => { + console.log('[SW] Precaching app shell'); + return cache.addAll(PRECACHE_ASSETS); + }) + .then(() => self.skipWaiting()) + .catch((err) => { + console.error('[SW] Precache failed:', err); + // Don't fail installation if precache fails + return self.skipWaiting(); + }) + ); }); -// Activate event - clean up old caches +// ==================== ACTIVATE ==================== self.addEventListener('activate', (event) => { console.log('[SW] Activating service worker...'); + event.waitUntil( - caches.keys().then((cacheNames) => { - return Promise.all( - cacheNames - .filter((name) => name !== CACHE_NAME) - .map((name) => caches.delete(name)) - ); - }) + caches.keys() + .then((cacheNames) => { + return Promise.all( + cacheNames + .filter((name) => { + // Delete old versioned caches + return name.startsWith('rmaps-') && + name !== STATIC_CACHE && + name !== TILE_CACHE && + name !== DATA_CACHE; + }) + .map((name) => { + console.log('[SW] Deleting old cache:', name); + return caches.delete(name); + }) + ); + }) + .then(() => self.clients.claim()) ); - self.clients.claim(); }); -// Background Sync - triggered when device comes back online +// ==================== FETCH ==================== +self.addEventListener('fetch', (event) => { + const url = new URL(event.request.url); + + // Skip non-GET requests + if (event.request.method !== 'GET') return; + + // Skip chrome-extension and other non-http(s) requests + if (!url.protocol.startsWith('http')) return; + + // Map tiles - cache-first strategy + if (isTileRequest(url)) { + event.respondWith(handleTileRequest(event.request)); + return; + } + + // API requests - network-first strategy + if (url.pathname.startsWith('/api/')) { + event.respondWith(handleApiRequest(event.request)); + return; + } + + // Static assets and pages - stale-while-revalidate + event.respondWith(handleStaticRequest(event.request)); +}); + +// Check if URL is a map tile +function isTileRequest(url) { + return TILE_PATTERNS.some(pattern => pattern.test(url.href)); +} + +// Handle map tile requests - cache-first for fast loading +async function handleTileRequest(request) { + const cache = await caches.open(TILE_CACHE); + const cached = await cache.match(request); + + if (cached) { + // Return cached tile immediately, refresh in background + refreshTileCache(request, cache); + return cached; + } + + try { + const response = await fetch(request); + if (response.ok) { + // Clone and cache the response + cache.put(request, response.clone()); + // Trim cache if too large + trimCache(cache, MAX_TILE_CACHE_SIZE); + } + return response; + } catch (error) { + console.log('[SW] Tile fetch failed, no cache:', request.url); + // Return a placeholder or transparent tile + return new Response('', { status: 404 }); + } +} + +// Refresh tile in background +async function refreshTileCache(request, cache) { + try { + const response = await fetch(request); + if (response.ok) { + cache.put(request, response); + } + } catch (e) { + // Ignore background refresh failures + } +} + +// Handle API requests - network-first with cache fallback +async function handleApiRequest(request) { + const cache = await caches.open(DATA_CACHE); + + try { + const response = await fetch(request); + if (response.ok) { + cache.put(request, response.clone()); + } + return response; + } catch (error) { + console.log('[SW] API fetch failed, trying cache:', request.url); + const cached = await cache.match(request); + if (cached) { + return cached; + } + return new Response(JSON.stringify({ error: 'Offline' }), { + status: 503, + headers: { 'Content-Type': 'application/json' } + }); + } +} + +// Handle static assets - stale-while-revalidate +async function handleStaticRequest(request) { + const cache = await caches.open(STATIC_CACHE); + const cached = await cache.match(request); + + const fetchPromise = fetch(request) + .then((response) => { + if (response.ok) { + cache.put(request, response.clone()); + } + return response; + }) + .catch(() => null); + + // Return cached version immediately if available + if (cached) { + // Refresh in background + fetchPromise; + return cached; + } + + // Wait for network if no cache + const networkResponse = await fetchPromise; + if (networkResponse) { + return networkResponse; + } + + // Fallback to root for navigation requests (SPA) + if (request.mode === 'navigate') { + const rootCached = await cache.match('/'); + if (rootCached) { + return rootCached; + } + } + + return new Response('Offline', { status: 503 }); +} + +// Trim cache to max size (LRU-ish - just delete oldest) +async function trimCache(cache, maxSize) { + const keys = await cache.keys(); + if (keys.length > maxSize) { + const deleteCount = keys.length - maxSize; + console.log(`[SW] Trimming ${deleteCount} tiles from cache`); + for (let i = 0; i < deleteCount; i++) { + await cache.delete(keys[i]); + } + } +} + +// ==================== INDEXEDDB FOR ROOM STATE ==================== +const DB_NAME = 'rmaps-offline'; +const DB_VERSION = 1; +const ROOM_STORE = 'room-state'; + +function openDB() { + return new Promise((resolve, reject) => { + const request = indexedDB.open(DB_NAME, DB_VERSION); + + request.onerror = () => reject(request.error); + request.onsuccess = () => resolve(request.result); + + request.onupgradeneeded = (event) => { + const db = event.target.result; + if (!db.objectStoreNames.contains(ROOM_STORE)) { + db.createObjectStore(ROOM_STORE, { keyPath: 'slug' }); + } + }; + }); +} + +async function saveRoomState(slug, state) { + try { + const db = await openDB(); + const tx = db.transaction(ROOM_STORE, 'readwrite'); + const store = tx.objectStore(ROOM_STORE); + await store.put({ slug, state, timestamp: Date.now() }); + console.log('[SW] Room state saved:', slug); + } catch (error) { + console.error('[SW] Failed to save room state:', error); + } +} + +async function getRoomState(slug) { + try { + const db = await openDB(); + const tx = db.transaction(ROOM_STORE, 'readonly'); + const store = tx.objectStore(ROOM_STORE); + const request = store.get(slug); + + return new Promise((resolve) => { + request.onsuccess = () => resolve(request.result?.state || null); + request.onerror = () => resolve(null); + }); + } catch (error) { + console.error('[SW] Failed to get room state:', error); + return null; + } +} + +// ==================== BACKGROUND SYNC ==================== self.addEventListener('sync', (event) => { console.log('[SW] Sync event:', event.tag); @@ -32,14 +269,11 @@ self.addEventListener('sync', (event) => { } }); -// Sync location to server when coming back online async function syncLocation() { try { - // Get stored location data from IndexedDB or localStorage via client const clients = await self.clients.matchAll({ type: 'window' }); for (const client of clients) { - // Ask the client to send its current location client.postMessage({ type: 'REQUEST_LOCATION_SYNC' }); } @@ -49,7 +283,6 @@ async function syncLocation() { } } -// Request location from any available client async function requestLocationFromClient() { const clients = await self.clients.matchAll({ type: 'window', @@ -57,14 +290,13 @@ async function requestLocationFromClient() { }); if (clients.length > 0) { - // Send message to first available client to get location clients[0].postMessage({ type: 'REQUEST_LOCATION_UPDATE' }); return true; } return false; } -// Push event - handle incoming push notifications +// ==================== PUSH NOTIFICATIONS ==================== self.addEventListener('push', (event) => { console.log('[SW] Push received:', event); @@ -103,13 +335,10 @@ self.addEventListener('push', (event) => { badge: data.badge || '/icon-192.png', tag: data.tag || 'rmaps-notification', data: data.data || {}, - // Strong vibration pattern: buzz-pause-buzz-pause-long buzz vibrate: [200, 100, 200, 100, 400], actions: data.actions || [], requireInteraction: data.requireInteraction || false, - // Use default system notification sound silent: false, - // Renotify even if same tag (so user hears it again) renotify: true, }; @@ -118,7 +347,7 @@ self.addEventListener('push', (event) => { ); }); -// Notification click event - handle user interaction +// ==================== NOTIFICATION EVENTS ==================== self.addEventListener('notificationclick', (event) => { console.log('[SW] Notification clicked:', event); @@ -127,14 +356,12 @@ self.addEventListener('notificationclick', (event) => { const data = event.notification.data || {}; let targetUrl = '/'; - // Determine URL based on notification type if (data.roomSlug) { targetUrl = `/${data.roomSlug}`; } else if (data.url) { targetUrl = data.url; } - // Handle action buttons if (event.action === 'view') { targetUrl = data.url || targetUrl; } else if (event.action === 'dismiss') { @@ -143,13 +370,11 @@ self.addEventListener('notificationclick', (event) => { event.waitUntil( clients.matchAll({ type: 'window', includeUncontrolled: true }).then((clientList) => { - // Try to focus an existing window for (const client of clientList) { if (client.url.includes(targetUrl) && 'focus' in client) { return client.focus(); } } - // Open a new window if none exists if (clients.openWindow) { return clients.openWindow(targetUrl); } @@ -157,16 +382,45 @@ self.addEventListener('notificationclick', (event) => { ); }); -// Handle notification close self.addEventListener('notificationclose', (event) => { console.log('[SW] Notification closed:', event); }); -// Handle messages from the main thread -self.addEventListener('message', (event) => { +// ==================== MESSAGE HANDLER ==================== +self.addEventListener('message', async (event) => { console.log('[SW] Message received:', event.data); - if (event.data && event.data.type === 'SKIP_WAITING') { - self.skipWaiting(); + if (!event.data) return; + + switch (event.data.type) { + case 'SKIP_WAITING': + self.skipWaiting(); + break; + + case 'SAVE_ROOM_STATE': + if (event.data.slug && event.data.state) { + await saveRoomState(event.data.slug, event.data.state); + } + break; + + case 'GET_ROOM_STATE': + if (event.data.slug) { + const state = await getRoomState(event.data.slug); + event.source.postMessage({ + type: 'ROOM_STATE', + slug: event.data.slug, + state + }); + } + break; + + case 'CLEAR_CACHES': + await caches.delete(STATIC_CACHE); + await caches.delete(TILE_CACHE); + await caches.delete(DATA_CACHE); + console.log('[SW] All caches cleared'); + break; } }); + +console.log('[SW] Service worker loaded'); diff --git a/src/hooks/useRoom.ts b/src/hooks/useRoom.ts index 758e43b..a8c5bb0 100644 --- a/src/hooks/useRoom.ts +++ b/src/hooks/useRoom.ts @@ -27,6 +27,39 @@ const COLORS = [ '#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. * This prevents creating duplicate "ghost" participants on page reload. @@ -89,11 +122,21 @@ export function useRoom({ slug, userName, userEmoji }: UseRoomOptions): UseRoomR // 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) => { - setParticipants(Object.values(state.participants).map(stateToParticipant)); - setWaypoints(state.waypoints.map(stateToWaypoint)); + 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 @@ -101,6 +144,19 @@ export function useRoom({ slug, userName, userEmoji }: UseRoomOptions): UseRoomR 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) {