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 // 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 STATIC_CACHE = `rmaps-static-${CACHE_VERSION}`;
const TILE_CACHE = `rmaps-tiles-${CACHE_VERSION}`; const TILE_CACHE = `rmaps-tiles-${CACHE_VERSION}`;
const DATA_CACHE = `rmaps-data-${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) { async function handleStaticRequest(request) {
const cache = await caches.open(STATIC_CACHE); 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 cached = await cache.match(request);
const fetchPromise = fetch(request) const fetchPromise = fetch(request)
@ -174,27 +195,16 @@ async function handleStaticRequest(request) {
}) })
.catch(() => null); .catch(() => null);
// Return cached version immediately if available
if (cached) { if (cached) {
// Refresh in background fetchPromise; // refresh in background
fetchPromise;
return cached; return cached;
} }
// Wait for network if no cache
const networkResponse = await fetchPromise; const networkResponse = await fetchPromise;
if (networkResponse) { if (networkResponse) {
return 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 }); 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({ const clients = await self.clients.matchAll({
type: 'window', type: 'window',
includeUncontrolled: true includeUncontrolled: true
}); });
if (clients.length > 0) { if (clients.length > 0) {
clients[0].postMessage({ type: 'REQUEST_LOCATION_UPDATE' }); clients[0].postMessage({ type: 'REQUEST_LOCATION_UPDATE', manual });
return true; return true;
} }
return false; return false;
@ -333,9 +343,11 @@ self.addEventListener('push', (event) => {
} }
lastLocationRequestTime = now; lastLocationRequestTime = now;
const isManual = !!(data.data?.manual);
event.waitUntil( event.waitUntil(
requestLocationFromClient().then((sent) => { requestLocationFromClient(isManual).then((sent) => {
console.log('[SW] Silent push - location request sent:', sent); console.log('[SW] Push location request sent:', sent, 'manual:', isManual);
}) })
); );
return; return;
@ -398,6 +410,10 @@ self.addEventListener('notificationclick', (event) => {
clients.matchAll({ type: 'window', includeUncontrolled: true }).then((clientList) => { clients.matchAll({ type: 'window', includeUncontrolled: true }).then((clientList) => {
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) {
// 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(); return client.focus();
} }
} }

View File

@ -17,7 +17,7 @@ import ImportModal from '@/components/room/ImportModal';
import type { ImportedPlace } from '@/components/room/ImportModal'; import type { ImportedPlace } from '@/components/room/ImportModal';
import InstallBanner from '@/components/room/InstallBanner'; import InstallBanner from '@/components/room/InstallBanner';
import JoinForm from '@/components/room/JoinForm'; 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 // Dynamic import for map to avoid SSR issues with MapLibre
const DualMapView = dynamic(() => import('@/components/map/DualMapView'), { const DualMapView = dynamic(() => import('@/components/map/DualMapView'), {
@ -129,6 +129,31 @@ export default function RoomPage() {
} }
}, [slug]); }, [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 // Location sharing hook
const { const {
isSharing, isSharing,
@ -144,15 +169,15 @@ export default function RoomPage() {
// Service worker messages for background location sync // Service worker messages for background location sync
useServiceWorkerMessages({ useServiceWorkerMessages({
onLocationRequest: () => { onLocationRequest: (manual?: boolean) => {
// Silent push notification requested location update console.log('Service worker requested location update, manual:', manual);
console.log('Service worker requested location update'); if (manual) {
if (isSharing) { handleManualPing();
} else if (isSharing) {
requestUpdate(); requestUpdate();
} }
}, },
onLocationSync: () => { onLocationSync: () => {
// Device came back online, sync location
console.log('Service worker requested location sync'); console.log('Service worker requested location sync');
if (isSharing) { if (isSharing) {
requestUpdate(); requestUpdate();
@ -217,17 +242,19 @@ export default function RoomPage() {
} }
}, [shouldAutoStartSharing, isConnected, isSharing, startSharing]); }, [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(() => { useEffect(() => {
if (isConnected) { if (isConnected) {
setLocationRequestCallback(() => { setLocationRequestCallback((manual?: boolean) => {
console.log('Server requested location update'); console.log('Server requested location update, manual:', manual);
if (isSharing) { if (manual) {
handleManualPing();
} else if (isSharing) {
requestUpdate(); requestUpdate();
} }
}); });
} }
}, [isConnected, isSharing, requestUpdate, setLocationRequestCallback]); }, [isConnected, isSharing, requestUpdate, setLocationRequestCallback, handleManualPing]);
// Handler for toggling location sharing - persists preference // Handler for toggling location sharing - persists preference
const handleToggleSharing = useCallback(() => { const handleToggleSharing = useCallback(() => {

View File

@ -12,7 +12,7 @@ interface ServiceWorkerRegistrationWithSync extends ServiceWorkerRegistration {
} }
interface UseServiceWorkerMessagesOptions { interface UseServiceWorkerMessagesOptions {
onLocationRequest?: () => void; onLocationRequest?: (manual?: boolean) => void;
onLocationSync?: () => void; onLocationSync?: () => void;
} }
@ -34,8 +34,8 @@ export function useServiceWorkerMessages(options: UseServiceWorkerMessagesOption
switch (event.data?.type) { switch (event.data?.type) {
case 'REQUEST_LOCATION_UPDATE': case 'REQUEST_LOCATION_UPDATE':
// Service worker is requesting a location update (from silent push) // Service worker is requesting a location update (from push)
optionsRef.current.onLocationRequest?.(); optionsRef.current.onLocationRequest?.(event.data?.manual);
break; break;
case 'REQUEST_LOCATION_SYNC': case 'REQUEST_LOCATION_SYNC':

View File

@ -77,11 +77,11 @@ export type SyncMessage =
| { type: 'waypoint_remove'; waypointId: string } | { type: 'waypoint_remove'; waypointId: string }
| { type: 'full_state'; state: RoomState } | { type: 'full_state'; state: RoomState }
| { type: 'request_state' } | { type: 'request_state' }
| { type: 'request_location' }; | { type: 'request_location'; manual?: boolean };
type SyncCallback = (state: RoomState) => void; type SyncCallback = (state: RoomState) => void;
type ConnectionCallback = (connected: boolean) => 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) // Validate that coordinates are reasonable (not 0,0 or out of bounds)
function isValidLocation(location: LocationState | undefined): boolean { function isValidLocation(location: LocationState | undefined): boolean {
@ -127,7 +127,7 @@ export class RoomSync {
this.state = this.loadState() || this.createInitialState(); this.state = this.loadState() || this.createInitialState();
} }
setLocationRequestCallback(callback: LocationRequestCallback): void { setLocationRequestCallback(callback: (manual?: boolean) => void): void {
this.onLocationRequest = callback; this.onLocationRequest = callback;
} }
@ -328,9 +328,9 @@ export class RoomSync {
case 'request_location': case 'request_location':
// Server is requesting a location update from us // 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) { if (this.onLocationRequest) {
this.onLocationRequest(); this.onLocationRequest(message.manual);
} }
return; // Don't notify state change for this message type return; // Don't notify state change for this message type
} }

View File

@ -13,7 +13,7 @@ COPY package*.json ./
RUN npm ci --only=production RUN npm ci --only=production
# Copy server code and fix ownership # 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 # Set ownership for the whole app directory
RUN chown -R nodejs:nodejs /app RUN chown -R nodejs:nodejs /app

View File

@ -92,7 +92,10 @@ function cleanupStaleParticipants(room) {
const staleIds = []; const staleIds = [];
for (const [id, participant] of Object.entries(room.participants)) { 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); staleIds.push(id);
} }
} }
@ -178,7 +181,7 @@ function handleMessage(ws, data) {
case 'join': { case 'join': {
const participant = { const participant = {
...message.participant, ...message.participant,
lastSeen: Date.now() lastSeen: new Date().toISOString()
}; };
// Deduplicate: remove any existing participant with same name but different ID (ghost entries) // Deduplicate: remove any existing participant with same name but different ID (ghost entries)
@ -244,7 +247,7 @@ function handleMessage(ws, data) {
case 'location': { case 'location': {
if (room.participants[message.participantId]) { if (room.participants[message.participantId]) {
room.participants[message.participantId].location = message.location; 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 // Broadcast to all OTHER participants
const count = broadcast(clientInfo.roomSlug, message, ws); const count = broadcast(clientInfo.roomSlug, message, ws);
@ -256,7 +259,7 @@ function handleMessage(ws, data) {
case 'status': { case 'status': {
if (room.participants[message.participantId]) { if (room.participants[message.participantId]) {
room.participants[message.participantId].status = message.status; 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); broadcast(clientInfo.roomSlug, message, ws);
} }
break; break;
@ -318,7 +321,7 @@ function handleClose(ws) {
if (room && clientInfo.participantId && room.participants[clientInfo.participantId]) { if (room && clientInfo.participantId && room.participants[clientInfo.participantId]) {
// Don't delete - mark as offline and preserve last location // Don't delete - mark as offline and preserve last location
room.participants[clientInfo.participantId].status = 'offline'; room.participants[clientInfo.participantId].status = 'offline';
room.participants[clientInfo.participantId].lastSeen = Date.now(); room.participants[clientInfo.participantId].lastSeen = new Date().toISOString();
broadcast(clientInfo.roomSlug, { broadcast(clientInfo.roomSlug, {
type: 'status', type: 'status',
participantId: clientInfo.participantId, participantId: clientInfo.participantId,
@ -600,7 +603,7 @@ const server = createServer(async (req, res) => {
} }
// Send WebSocket message to connected clients, deduplicated by name // 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(); const pingedNames = new Set();
for (const [ws, clientInfo] of clients.entries()) { 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 // Online users already got the WS message; send silent push as backup
await webpush.sendNotification(subscription, JSON.stringify({ await webpush.sendNotification(subscription, JSON.stringify({
silent: true, silent: true,
data: { type: 'location_request', roomSlug } data: { type: 'location_request', roomSlug, manual: true }
})); }));
} else { } else {
// Offline users: send a VISIBLE callout notification // Offline users: send a VISIBLE callout notification
@ -664,6 +667,7 @@ const server = createServer(async (req, res) => {
type: 'callout', type: 'callout',
roomSlug, roomSlug,
callerName: pingerName, callerName: pingerName,
manual: true,
}, },
actions: [ actions: [
{ action: 'view', title: 'Open Map' }, { action: 'view', title: 'Open Map' },