feat: Add comprehensive PWA offline support
- Enhanced service worker with multi-strategy caching: - App shell precaching for instant loading - Map tiles cache-first with background refresh (max 500 tiles) - API requests network-first with cache fallback - Static assets stale-while-revalidate - IndexedDB room state persistence for offline access - Room state sync in useRoom hook: - Saves state to service worker on changes - Loads cached state on initial load for offline fallback - Message handlers for SAVE_ROOM_STATE, GET_ROOM_STATE, CLEAR_CACHES 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
cf8bd63298
commit
9d8314096b
322
public/sw.js
322
public/sw.js
|
|
@ -1,29 +1,266 @@
|
||||||
// rMaps Service Worker for Push Notifications & Background Location
|
// rMaps Service Worker for Push Notifications, Background Location & Offline Support
|
||||||
const CACHE_NAME = 'rmaps-v1';
|
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';
|
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) => {
|
self.addEventListener('install', (event) => {
|
||||||
console.log('[SW] Installing service worker...');
|
console.log('[SW] Installing service worker...');
|
||||||
self.skipWaiting();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Activate event - clean up old caches
|
|
||||||
self.addEventListener('activate', (event) => {
|
|
||||||
console.log('[SW] Activating service worker...');
|
|
||||||
event.waitUntil(
|
event.waitUntil(
|
||||||
caches.keys().then((cacheNames) => {
|
caches.open(STATIC_CACHE)
|
||||||
return Promise.all(
|
.then((cache) => {
|
||||||
cacheNames
|
console.log('[SW] Precaching app shell');
|
||||||
.filter((name) => name !== CACHE_NAME)
|
return cache.addAll(PRECACHE_ASSETS);
|
||||||
.map((name) => caches.delete(name))
|
})
|
||||||
);
|
.then(() => self.skipWaiting())
|
||||||
|
.catch((err) => {
|
||||||
|
console.error('[SW] Precache failed:', err);
|
||||||
|
// Don't fail installation if precache fails
|
||||||
|
return self.skipWaiting();
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
self.clients.claim();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Background Sync - triggered when device comes back online
|
// ==================== ACTIVATE ====================
|
||||||
|
self.addEventListener('activate', (event) => {
|
||||||
|
console.log('[SW] Activating service worker...');
|
||||||
|
|
||||||
|
event.waitUntil(
|
||||||
|
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())
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ==================== 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) => {
|
self.addEventListener('sync', (event) => {
|
||||||
console.log('[SW] Sync event:', event.tag);
|
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() {
|
async function syncLocation() {
|
||||||
try {
|
try {
|
||||||
// Get stored location data from IndexedDB or localStorage via client
|
|
||||||
const clients = await self.clients.matchAll({ type: 'window' });
|
const clients = await self.clients.matchAll({ type: 'window' });
|
||||||
|
|
||||||
for (const client of clients) {
|
for (const client of clients) {
|
||||||
// Ask the client to send its current location
|
|
||||||
client.postMessage({ type: 'REQUEST_LOCATION_SYNC' });
|
client.postMessage({ type: 'REQUEST_LOCATION_SYNC' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -49,7 +283,6 @@ async function syncLocation() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Request location from any available client
|
|
||||||
async function requestLocationFromClient() {
|
async function requestLocationFromClient() {
|
||||||
const clients = await self.clients.matchAll({
|
const clients = await self.clients.matchAll({
|
||||||
type: 'window',
|
type: 'window',
|
||||||
|
|
@ -57,14 +290,13 @@ async function requestLocationFromClient() {
|
||||||
});
|
});
|
||||||
|
|
||||||
if (clients.length > 0) {
|
if (clients.length > 0) {
|
||||||
// Send message to first available client to get location
|
|
||||||
clients[0].postMessage({ type: 'REQUEST_LOCATION_UPDATE' });
|
clients[0].postMessage({ type: 'REQUEST_LOCATION_UPDATE' });
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Push event - handle incoming push notifications
|
// ==================== PUSH NOTIFICATIONS ====================
|
||||||
self.addEventListener('push', (event) => {
|
self.addEventListener('push', (event) => {
|
||||||
console.log('[SW] Push received:', event);
|
console.log('[SW] Push received:', event);
|
||||||
|
|
||||||
|
|
@ -103,13 +335,10 @@ self.addEventListener('push', (event) => {
|
||||||
badge: data.badge || '/icon-192.png',
|
badge: data.badge || '/icon-192.png',
|
||||||
tag: data.tag || 'rmaps-notification',
|
tag: data.tag || 'rmaps-notification',
|
||||||
data: data.data || {},
|
data: data.data || {},
|
||||||
// Strong vibration pattern: buzz-pause-buzz-pause-long buzz
|
|
||||||
vibrate: [200, 100, 200, 100, 400],
|
vibrate: [200, 100, 200, 100, 400],
|
||||||
actions: data.actions || [],
|
actions: data.actions || [],
|
||||||
requireInteraction: data.requireInteraction || false,
|
requireInteraction: data.requireInteraction || false,
|
||||||
// Use default system notification sound
|
|
||||||
silent: false,
|
silent: false,
|
||||||
// Renotify even if same tag (so user hears it again)
|
|
||||||
renotify: true,
|
renotify: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -118,7 +347,7 @@ self.addEventListener('push', (event) => {
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Notification click event - handle user interaction
|
// ==================== NOTIFICATION EVENTS ====================
|
||||||
self.addEventListener('notificationclick', (event) => {
|
self.addEventListener('notificationclick', (event) => {
|
||||||
console.log('[SW] Notification clicked:', event);
|
console.log('[SW] Notification clicked:', event);
|
||||||
|
|
||||||
|
|
@ -127,14 +356,12 @@ self.addEventListener('notificationclick', (event) => {
|
||||||
const data = event.notification.data || {};
|
const data = event.notification.data || {};
|
||||||
let targetUrl = '/';
|
let targetUrl = '/';
|
||||||
|
|
||||||
// Determine URL based on notification type
|
|
||||||
if (data.roomSlug) {
|
if (data.roomSlug) {
|
||||||
targetUrl = `/${data.roomSlug}`;
|
targetUrl = `/${data.roomSlug}`;
|
||||||
} else if (data.url) {
|
} else if (data.url) {
|
||||||
targetUrl = data.url;
|
targetUrl = data.url;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle action buttons
|
|
||||||
if (event.action === 'view') {
|
if (event.action === 'view') {
|
||||||
targetUrl = data.url || targetUrl;
|
targetUrl = data.url || targetUrl;
|
||||||
} else if (event.action === 'dismiss') {
|
} else if (event.action === 'dismiss') {
|
||||||
|
|
@ -143,13 +370,11 @@ self.addEventListener('notificationclick', (event) => {
|
||||||
|
|
||||||
event.waitUntil(
|
event.waitUntil(
|
||||||
clients.matchAll({ type: 'window', includeUncontrolled: true }).then((clientList) => {
|
clients.matchAll({ type: 'window', includeUncontrolled: true }).then((clientList) => {
|
||||||
// Try to focus an existing window
|
|
||||||
for (const client of clientList) {
|
for (const client of clientList) {
|
||||||
if (client.url.includes(targetUrl) && 'focus' in client) {
|
if (client.url.includes(targetUrl) && 'focus' in client) {
|
||||||
return client.focus();
|
return client.focus();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Open a new window if none exists
|
|
||||||
if (clients.openWindow) {
|
if (clients.openWindow) {
|
||||||
return clients.openWindow(targetUrl);
|
return clients.openWindow(targetUrl);
|
||||||
}
|
}
|
||||||
|
|
@ -157,16 +382,45 @@ self.addEventListener('notificationclick', (event) => {
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Handle notification close
|
|
||||||
self.addEventListener('notificationclose', (event) => {
|
self.addEventListener('notificationclose', (event) => {
|
||||||
console.log('[SW] Notification closed:', event);
|
console.log('[SW] Notification closed:', event);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Handle messages from the main thread
|
// ==================== MESSAGE HANDLER ====================
|
||||||
self.addEventListener('message', (event) => {
|
self.addEventListener('message', async (event) => {
|
||||||
console.log('[SW] Message received:', event.data);
|
console.log('[SW] Message received:', event.data);
|
||||||
|
|
||||||
if (event.data && event.data.type === 'SKIP_WAITING') {
|
if (!event.data) return;
|
||||||
|
|
||||||
|
switch (event.data.type) {
|
||||||
|
case 'SKIP_WAITING':
|
||||||
self.skipWaiting();
|
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');
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,39 @@ const COLORS = [
|
||||||
'#06b6d4', // cyan
|
'#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.
|
* Get or create a persistent participant ID for this browser/room combination.
|
||||||
* This prevents creating duplicate "ghost" participants on page reload.
|
* 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
|
// Initialize with empty string - will be set properly on client side
|
||||||
const participantIdRef = useRef<string>('');
|
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
|
// Handle state updates from sync
|
||||||
const handleStateChange = useCallback((state: RoomState) => {
|
const handleStateChange = useCallback((state: RoomState) => {
|
||||||
setParticipants(Object.values(state.participants).map(stateToParticipant));
|
const newParticipants = Object.values(state.participants).map(stateToParticipant);
|
||||||
setWaypoints(state.waypoints.map(stateToWaypoint));
|
const newWaypoints = state.waypoints.map(stateToWaypoint);
|
||||||
|
|
||||||
|
setParticipants(newParticipants);
|
||||||
|
setWaypoints(newWaypoints);
|
||||||
setRoomName(state.name || slug);
|
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]);
|
}, [slug]);
|
||||||
|
|
||||||
// Handle connection changes
|
// Handle connection changes
|
||||||
|
|
@ -101,6 +144,19 @@ export function useRoom({ slug, userName, userEmoji }: UseRoomOptions): UseRoomR
|
||||||
setIsConnected(connected);
|
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
|
// Initialize room connection
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!userName) {
|
if (!userName) {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue