feat: manual ping vibrates device and force-shares GPS location

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 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-02-19 00:06:05 +00:00
parent f9faea1851
commit a54ae04140
6 changed files with 92 additions and 45 deletions

View File

@ -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();
}
}

View File

@ -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(() => {

View File

@ -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':

View File

@ -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
}

View File

@ -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

View File

@ -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' },