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
|
// 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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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(() => {
|
||||||
|
|
|
||||||
|
|
@ -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':
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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' },
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue