From a54ae041406fe17c59242c24abc26112d143346f Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Thu, 19 Feb 2026 00:06:05 +0000 Subject: [PATCH] feat: manual ping vibrates device and force-shares GPS location MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Manual "Ping All" now sends `manual: true` flag through WebSocket and push channels. Receiving clients vibrate and respond with a one-shot getCurrentPosition() regardless of sharing toggle. Auto-periodic 60s pings stay silent and only respond if sharing is enabled. Also fixes: SW cache invalidation (v2→v3), navigation requests now network-first, sync server lastSeen uses ISO strings, Dockerfile includes verify-token.js. Co-Authored-By: Claude Opus 4.6 --- public/sw.js | 52 +++++++++++++++++---------- src/app/[slug]/page.tsx | 49 +++++++++++++++++++------ src/hooks/useServiceWorkerMessages.ts | 6 ++-- src/lib/sync.ts | 10 +++--- sync-server/Dockerfile | 2 +- sync-server/server.js | 18 ++++++---- 6 files changed, 92 insertions(+), 45 deletions(-) diff --git a/public/sw.js b/public/sw.js index 43d351f..ad3167c 100644 --- a/public/sw.js +++ b/public/sw.js @@ -1,5 +1,5 @@ // rMaps Service Worker for Push Notifications, Background Location & Offline Support -const CACHE_VERSION = 'v2'; +const CACHE_VERSION = 'v3'; const STATIC_CACHE = `rmaps-static-${CACHE_VERSION}`; const TILE_CACHE = `rmaps-tiles-${CACHE_VERSION}`; const DATA_CACHE = `rmaps-data-${CACHE_VERSION}`; @@ -160,9 +160,30 @@ async function handleApiRequest(request) { } } -// Handle static assets - stale-while-revalidate +// Handle static assets +// Navigation requests use network-first (always get latest HTML/JS) +// Other static assets use stale-while-revalidate async function handleStaticRequest(request) { const cache = await caches.open(STATIC_CACHE); + + // Navigation requests: network-first to ensure latest HTML/JS bundles + if (request.mode === 'navigate') { + try { + const response = await fetch(request); + if (response.ok) { + cache.put(request, response.clone()); + } + return response; + } catch (error) { + const cached = await cache.match(request); + if (cached) return cached; + const rootCached = await cache.match('/'); + if (rootCached) return rootCached; + return new Response('Offline', { status: 503 }); + } + } + + // Other static assets: stale-while-revalidate const cached = await cache.match(request); const fetchPromise = fetch(request) @@ -174,27 +195,16 @@ async function handleStaticRequest(request) { }) .catch(() => null); - // Return cached version immediately if available if (cached) { - // Refresh in background - fetchPromise; + fetchPromise; // refresh in background 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 }); } @@ -283,14 +293,14 @@ async function syncLocation() { } } -async function requestLocationFromClient() { +async function requestLocationFromClient(manual = false) { const clients = await self.clients.matchAll({ type: 'window', includeUncontrolled: true }); if (clients.length > 0) { - clients[0].postMessage({ type: 'REQUEST_LOCATION_UPDATE' }); + clients[0].postMessage({ type: 'REQUEST_LOCATION_UPDATE', manual }); return true; } return false; @@ -333,9 +343,11 @@ self.addEventListener('push', (event) => { } lastLocationRequestTime = now; + const isManual = !!(data.data?.manual); + event.waitUntil( - requestLocationFromClient().then((sent) => { - console.log('[SW] Silent push - location request sent:', sent); + requestLocationFromClient(isManual).then((sent) => { + console.log('[SW] Push location request sent:', sent, 'manual:', isManual); }) ); return; @@ -398,6 +410,10 @@ self.addEventListener('notificationclick', (event) => { clients.matchAll({ type: 'window', includeUncontrolled: true }).then((clientList) => { for (const client of clientList) { if (client.url.includes(targetUrl) && 'focus' in client) { + // If this was a manual callout, tell the app to send location + if (data.manual) { + client.postMessage({ type: 'REQUEST_LOCATION_UPDATE', manual: true }); + } return client.focus(); } } diff --git a/src/app/[slug]/page.tsx b/src/app/[slug]/page.tsx index 6fa1475..77e56b1 100644 --- a/src/app/[slug]/page.tsx +++ b/src/app/[slug]/page.tsx @@ -17,7 +17,7 @@ import ImportModal from '@/components/room/ImportModal'; import type { ImportedPlace } from '@/components/room/ImportModal'; import InstallBanner from '@/components/room/InstallBanner'; import JoinForm from '@/components/room/JoinForm'; -import type { Participant, ParticipantLocation, Waypoint } from '@/types'; +import type { Participant, ParticipantLocation, LocationSource, Waypoint } from '@/types'; // Dynamic import for map to avoid SSR issues with MapLibre const DualMapView = dynamic(() => import('@/components/map/DualMapView'), { @@ -129,6 +129,31 @@ export default function RoomPage() { } }, [slug]); + // Handle manual ping: vibrate device + one-shot GPS regardless of sharing state + const handleManualPing = useCallback(() => { + if (typeof navigator !== 'undefined' && 'vibrate' in navigator) { + navigator.vibrate([200, 100, 200, 100, 400]); + } + if (typeof navigator !== 'undefined' && 'geolocation' in navigator) { + navigator.geolocation.getCurrentPosition( + (position) => { + handleLocationUpdate({ + latitude: position.coords.latitude, + longitude: position.coords.longitude, + accuracy: position.coords.accuracy, + altitude: position.coords.altitude ?? undefined, + heading: position.coords.heading ?? undefined, + speed: position.coords.speed ?? undefined, + timestamp: new Date(position.timestamp), + source: 'gps' as LocationSource, + }); + }, + (err) => console.warn('Ping location failed:', err.message), + { enableHighAccuracy: true, maximumAge: 30000, timeout: 10000 } + ); + } + }, [handleLocationUpdate]); + // Location sharing hook const { isSharing, @@ -144,15 +169,15 @@ export default function RoomPage() { // Service worker messages for background location sync useServiceWorkerMessages({ - onLocationRequest: () => { - // Silent push notification requested location update - console.log('Service worker requested location update'); - if (isSharing) { + onLocationRequest: (manual?: boolean) => { + console.log('Service worker requested location update, manual:', manual); + if (manual) { + handleManualPing(); + } else if (isSharing) { requestUpdate(); } }, onLocationSync: () => { - // Device came back online, sync location console.log('Service worker requested location sync'); if (isSharing) { requestUpdate(); @@ -217,17 +242,19 @@ export default function RoomPage() { } }, [shouldAutoStartSharing, isConnected, isSharing, startSharing]); - // Set up callback for when server requests location (via refresh button) + // Set up callback for when server requests location (via ping button or auto) useEffect(() => { if (isConnected) { - setLocationRequestCallback(() => { - console.log('Server requested location update'); - if (isSharing) { + setLocationRequestCallback((manual?: boolean) => { + console.log('Server requested location update, manual:', manual); + if (manual) { + handleManualPing(); + } else if (isSharing) { requestUpdate(); } }); } - }, [isConnected, isSharing, requestUpdate, setLocationRequestCallback]); + }, [isConnected, isSharing, requestUpdate, setLocationRequestCallback, handleManualPing]); // Handler for toggling location sharing - persists preference const handleToggleSharing = useCallback(() => { diff --git a/src/hooks/useServiceWorkerMessages.ts b/src/hooks/useServiceWorkerMessages.ts index 3af9061..6a93112 100644 --- a/src/hooks/useServiceWorkerMessages.ts +++ b/src/hooks/useServiceWorkerMessages.ts @@ -12,7 +12,7 @@ interface ServiceWorkerRegistrationWithSync extends ServiceWorkerRegistration { } interface UseServiceWorkerMessagesOptions { - onLocationRequest?: () => void; + onLocationRequest?: (manual?: boolean) => void; onLocationSync?: () => void; } @@ -34,8 +34,8 @@ export function useServiceWorkerMessages(options: UseServiceWorkerMessagesOption switch (event.data?.type) { case 'REQUEST_LOCATION_UPDATE': - // Service worker is requesting a location update (from silent push) - optionsRef.current.onLocationRequest?.(); + // Service worker is requesting a location update (from push) + optionsRef.current.onLocationRequest?.(event.data?.manual); break; case 'REQUEST_LOCATION_SYNC': diff --git a/src/lib/sync.ts b/src/lib/sync.ts index dc7a224..8cd9b35 100644 --- a/src/lib/sync.ts +++ b/src/lib/sync.ts @@ -77,11 +77,11 @@ export type SyncMessage = | { type: 'waypoint_remove'; waypointId: string } | { type: 'full_state'; state: RoomState } | { type: 'request_state' } - | { type: 'request_location' }; + | { type: 'request_location'; manual?: boolean }; type SyncCallback = (state: RoomState) => void; type ConnectionCallback = (connected: boolean) => void; -type LocationRequestCallback = () => void; +type LocationRequestCallback = (manual?: boolean) => void; // Validate that coordinates are reasonable (not 0,0 or out of bounds) function isValidLocation(location: LocationState | undefined): boolean { @@ -127,7 +127,7 @@ export class RoomSync { this.state = this.loadState() || this.createInitialState(); } - setLocationRequestCallback(callback: LocationRequestCallback): void { + setLocationRequestCallback(callback: (manual?: boolean) => void): void { this.onLocationRequest = callback; } @@ -328,9 +328,9 @@ export class RoomSync { case 'request_location': // Server is requesting a location update from us - console.log('[RoomSync] Received location request from server'); + console.log('[RoomSync] Received location request, manual:', message.manual); if (this.onLocationRequest) { - this.onLocationRequest(); + this.onLocationRequest(message.manual); } return; // Don't notify state change for this message type } diff --git a/sync-server/Dockerfile b/sync-server/Dockerfile index fadfa7c..6040820 100644 --- a/sync-server/Dockerfile +++ b/sync-server/Dockerfile @@ -13,7 +13,7 @@ COPY package*.json ./ RUN npm ci --only=production # Copy server code and fix ownership -COPY --chown=nodejs:nodejs server.js ./ +COPY --chown=nodejs:nodejs server.js verify-token.js ./ # Set ownership for the whole app directory RUN chown -R nodejs:nodejs /app diff --git a/sync-server/server.js b/sync-server/server.js index da03de0..fdcea37 100644 --- a/sync-server/server.js +++ b/sync-server/server.js @@ -92,7 +92,10 @@ function cleanupStaleParticipants(room) { const staleIds = []; for (const [id, participant] of Object.entries(room.participants)) { - if (participant.lastSeen && now - participant.lastSeen > STALE_THRESHOLD_MS) { + const lastSeenMs = typeof participant.lastSeen === 'string' + ? new Date(participant.lastSeen).getTime() + : participant.lastSeen; + if (lastSeenMs && now - lastSeenMs > STALE_THRESHOLD_MS) { staleIds.push(id); } } @@ -178,7 +181,7 @@ function handleMessage(ws, data) { case 'join': { const participant = { ...message.participant, - lastSeen: Date.now() + lastSeen: new Date().toISOString() }; // Deduplicate: remove any existing participant with same name but different ID (ghost entries) @@ -244,7 +247,7 @@ function handleMessage(ws, data) { case 'location': { if (room.participants[message.participantId]) { room.participants[message.participantId].location = message.location; - room.participants[message.participantId].lastSeen = Date.now(); + room.participants[message.participantId].lastSeen = new Date().toISOString(); // Broadcast to all OTHER participants const count = broadcast(clientInfo.roomSlug, message, ws); @@ -256,7 +259,7 @@ function handleMessage(ws, data) { case 'status': { if (room.participants[message.participantId]) { room.participants[message.participantId].status = message.status; - room.participants[message.participantId].lastSeen = Date.now(); + room.participants[message.participantId].lastSeen = new Date().toISOString(); broadcast(clientInfo.roomSlug, message, ws); } break; @@ -318,7 +321,7 @@ function handleClose(ws) { if (room && clientInfo.participantId && room.participants[clientInfo.participantId]) { // Don't delete - mark as offline and preserve last location room.participants[clientInfo.participantId].status = 'offline'; - room.participants[clientInfo.participantId].lastSeen = Date.now(); + room.participants[clientInfo.participantId].lastSeen = new Date().toISOString(); broadcast(clientInfo.roomSlug, { type: 'status', participantId: clientInfo.participantId, @@ -600,7 +603,7 @@ const server = createServer(async (req, res) => { } // Send WebSocket message to connected clients, deduplicated by name - const locationRequestMsg = JSON.stringify({ type: 'request_location' }); + const locationRequestMsg = JSON.stringify({ type: 'request_location', manual: true }); const pingedNames = new Set(); for (const [ws, clientInfo] of clients.entries()) { @@ -652,7 +655,7 @@ const server = createServer(async (req, res) => { // Online users already got the WS message; send silent push as backup await webpush.sendNotification(subscription, JSON.stringify({ silent: true, - data: { type: 'location_request', roomSlug } + data: { type: 'location_request', roomSlug, manual: true } })); } else { // Offline users: send a VISIBLE callout notification @@ -664,6 +667,7 @@ const server = createServer(async (req, res) => { type: 'callout', roomSlug, callerName: pingerName, + manual: true, }, actions: [ { action: 'view', title: 'Open Map' },