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:
parent
f9faea1851
commit
a54ae04140
52
public/sw.js
52
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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -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':
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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' },
|
||||
|
|
|
|||
Loading…
Reference in New Issue