diff --git a/backlog/config.yml b/backlog/config.yml index 4b831a1..7d4b579 100644 --- a/backlog/config.yml +++ b/backlog/config.yml @@ -13,3 +13,4 @@ auto_commit: false bypass_git_hooks: false check_active_branches: true active_branch_days: 30 +task_prefix: "task" diff --git a/public/sw.js b/public/sw.js index 6453c57..43d351f 100644 --- a/public/sw.js +++ b/public/sw.js @@ -297,6 +297,11 @@ async function requestLocationFromClient() { } // ==================== PUSH NOTIFICATIONS ==================== + +// Cooldown to prevent burst of queued location requests when app reopens +let lastLocationRequestTime = 0; +const LOCATION_REQUEST_COOLDOWN_MS = 10000; // 10 seconds + self.addEventListener('push', (event) => { console.log('[SW] Push received:', event); @@ -321,6 +326,13 @@ self.addEventListener('push', (event) => { // Handle silent push - request location update without showing notification if (data.silent || data.data?.type === 'location_request') { + const now = Date.now(); + if (now - lastLocationRequestTime < LOCATION_REQUEST_COOLDOWN_MS) { + console.log('[SW] Skipping duplicate location request (cooldown)'); + return; + } + lastLocationRequestTime = now; + event.waitUntil( requestLocationFromClient().then((sent) => { console.log('[SW] Silent push - location request sent:', sent); @@ -329,21 +341,35 @@ self.addEventListener('push', (event) => { return; } - const options = { - body: data.body, - icon: data.icon || '/icon-192.png', - badge: data.badge || '/icon-192.png', - tag: data.tag || 'rmaps-notification', - data: data.data || {}, - vibrate: [200, 100, 200, 100, 400], - actions: data.actions || [], - requireInteraction: data.requireInteraction || false, - silent: false, - renotify: true, - }; - + // For join/leave notifications, suppress if app is currently focused + // (user can already see who's joining/leaving) event.waitUntil( - self.registration.showNotification(data.title, options) + (async () => { + const notifType = data.data?.type; + if (notifType === 'join' || notifType === 'leave') { + const allClients = await self.clients.matchAll({ type: 'window' }); + const hasFocusedClient = allClients.some(c => c.visibilityState === 'visible'); + if (hasFocusedClient) { + console.log('[SW] Suppressing join/leave notification - app is focused'); + return; + } + } + + const options = { + body: data.body, + icon: data.icon || '/icon-192.png', + badge: data.badge || '/icon-192.png', + tag: data.tag || 'rmaps-notification', + data: data.data || {}, + vibrate: [200, 100, 200, 100, 400], + actions: data.actions || [], + requireInteraction: data.requireInteraction || false, + silent: false, + renotify: true, + }; + + await self.registration.showNotification(data.title, options); + })() ); }); diff --git a/src/app/[slug]/page.tsx b/src/app/[slug]/page.tsx index d95b76c..6fa1475 100644 --- a/src/app/[slug]/page.tsx +++ b/src/app/[slug]/page.tsx @@ -165,6 +165,7 @@ export default function RoomPage() { const { subscribe: subscribePush, isSubscribed: isPushSubscribed } = usePushNotifications({ syncUrl, roomSlug: slug, + participantId: currentParticipantId || undefined, }); // Auto-subscribe to push when joining room (like location permission) @@ -377,6 +378,7 @@ export default function RoomPage() { setShowParticipants(false)} diff --git a/src/components/room/ParticipantList.tsx b/src/components/room/ParticipantList.tsx index ea9ce8f..e0fd796 100644 --- a/src/components/room/ParticipantList.tsx +++ b/src/components/room/ParticipantList.tsx @@ -6,6 +6,7 @@ import type { Participant } from '@/types'; interface ParticipantListProps { participants: Participant[]; currentUserId?: string; + currentUserName?: string; roomSlug: string; syncUrl?: string; onClose: () => void; @@ -17,6 +18,7 @@ interface ParticipantListProps { export default function ParticipantList({ participants, currentUserId, + currentUserName, roomSlug, syncUrl, onClose, @@ -40,7 +42,7 @@ export default function ParticipantList({ const response = await fetch(`${httpUrl}/push/request-location`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ roomSlug }), + body: JSON.stringify({ roomSlug, callerName: currentUserName }), }); const data = await response.json(); @@ -59,7 +61,7 @@ export default function ParticipantList({ setIsRefreshing(false); setTimeout(() => setRefreshMessage(null), 3000); } - }, [syncUrl, roomSlug, isRefreshing]); + }, [syncUrl, roomSlug, isRefreshing, currentUserName]); // Ping a single user const handlePingUser = useCallback(async (participantId: string, participantName: string) => { @@ -73,7 +75,7 @@ export default function ParticipantList({ const response = await fetch(`${httpUrl}/push/request-location`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ roomSlug, participantId }), + body: JSON.stringify({ roomSlug, participantId, callerName: currentUserName }), }); const data = await response.json(); @@ -89,7 +91,7 @@ export default function ParticipantList({ setPingingUser(null); setTimeout(() => setRefreshMessage(null), 3000); } - }, [syncUrl, roomSlug, pingingUser]); + }, [syncUrl, roomSlug, pingingUser, currentUserName]); const formatDistance = (participant: Participant, current: Participant | undefined) => { if (!participant.location || !current?.location) return null; diff --git a/src/hooks/usePushNotifications.ts b/src/hooks/usePushNotifications.ts index a25343e..13a09f7 100644 --- a/src/hooks/usePushNotifications.ts +++ b/src/hooks/usePushNotifications.ts @@ -15,6 +15,8 @@ interface UsePushNotificationsOptions { roomSlug?: string; /** Sync server URL for storing subscriptions */ syncUrl?: string; + /** Participant ID to associate with this push subscription */ + participantId?: string; } // VAPID public key - should match the server's public key @@ -37,7 +39,7 @@ function urlBase64ToUint8Array(base64String: string): Uint8Array { } export function usePushNotifications(options: UsePushNotificationsOptions = {}) { - const { roomSlug, syncUrl } = options; + const { roomSlug, syncUrl, participantId } = options; const [state, setState] = useState({ isSupported: false, @@ -162,6 +164,7 @@ export function usePushNotifications(options: UsePushNotificationsOptions = {}) body: JSON.stringify({ subscription: pushSubscription.toJSON(), roomSlug, + participantId, }), }); @@ -194,7 +197,7 @@ export function usePushNotifications(options: UsePushNotificationsOptions = {}) })); throw error; } - }, [state.isSupported, syncUrl, roomSlug]); + }, [state.isSupported, syncUrl, roomSlug, participantId]); // Unsubscribe from push notifications const unsubscribe = useCallback(async () => { diff --git a/sync-server/server.js b/sync-server/server.js index a6e12b8..da03de0 100644 --- a/sync-server/server.js +++ b/sync-server/server.js @@ -6,7 +6,7 @@ import webpush from 'web-push'; import { verifyToken, extractTokenFromURL } from './verify-token.js'; const PORT = process.env.PORT || 3001; -const STALE_THRESHOLD_MS = 60 * 60 * 1000; // 1 hour +const STALE_THRESHOLD_MS = 15 * 60 * 1000; // 15 minutes (match client-side cleanup) const LOCATION_REQUEST_INTERVAL_MS = parseInt(process.env.LOCATION_REQUEST_INTERVAL || '60000', 10); // 60 seconds default // VAPID keys for push notifications @@ -29,7 +29,8 @@ const rooms = new Map(); // Client tracking: Map const clients = new Map(); -// Push subscriptions: Map> +// Push subscriptions: Map> +// Using Map keyed by endpoint to deduplicate subscriptions from the same device const pushSubscriptions = new Map(); function getRoomState(slug) { @@ -119,38 +120,36 @@ function broadcast(roomSlug, message, excludeWs = null) { } // Send push notification to all subscriptions for a room -async function sendPushToRoom(roomSlug, notification, excludeEndpoint = null) { +// excludeParticipantId: skip subscriptions belonging to this participant (e.g. don't notify yourself) +async function sendPushToRoom(roomSlug, notification, { excludeEndpoint = null, excludeParticipantId = null } = {}) { if (!VAPID_PUBLIC_KEY || !VAPID_PRIVATE_KEY) return; - const subs = pushSubscriptions.get(roomSlug); - if (!subs || subs.size === 0) return; + const subsMap = pushSubscriptions.get(roomSlug); + if (!subsMap || subsMap.size === 0) return; const payload = JSON.stringify(notification); const failedEndpoints = []; - for (const sub of subs) { - if (excludeEndpoint && sub.endpoint === excludeEndpoint) continue; + for (const [endpoint, { subscription, participantId }] of subsMap.entries()) { + if (excludeEndpoint && endpoint === excludeEndpoint) continue; + if (excludeParticipantId && participantId === excludeParticipantId) continue; try { - await webpush.sendNotification(sub, payload); - console.log(`[${roomSlug}] Push sent to ${sub.endpoint.slice(-20)}`); + await webpush.sendNotification(subscription, payload); + console.log(`[${roomSlug}] Push sent to ${endpoint.slice(-20)}`); } catch (error) { console.error(`[${roomSlug}] Push failed:`, error.statusCode || error.message); // Remove invalid subscriptions if (error.statusCode === 404 || error.statusCode === 410) { - failedEndpoints.push(sub.endpoint); + failedEndpoints.push(endpoint); } } } // Clean up failed subscriptions for (const endpoint of failedEndpoints) { - for (const sub of subs) { - if (sub.endpoint === endpoint) { - subs.delete(sub); - console.log(`[${roomSlug}] Removed invalid subscription`); - } - } + subsMap.delete(endpoint); + console.log(`[${roomSlug}] Removed invalid subscription`); } } @@ -181,6 +180,16 @@ function handleMessage(ws, data) { ...message.participant, lastSeen: Date.now() }; + + // Deduplicate: remove any existing participant with same name but different ID (ghost entries) + for (const [existingId, existing] of Object.entries(room.participants)) { + if (existing.name === participant.name && existingId !== participant.id) { + console.log(`[${clientInfo.roomSlug}] Removing ghost participant: ${existingId} (replaced by ${participant.id})`); + delete room.participants[existingId]; + broadcast(clientInfo.roomSlug, { type: 'leave', participantId: existingId }, ws); + } + } + room.participants[participant.id] = participant; clientInfo.participantId = participant.id; @@ -189,7 +198,7 @@ function handleMessage(ws, data) { // Broadcast join to others broadcast(clientInfo.roomSlug, message, ws); - // Send push notification to others + // Send push notification to others (exclude the joining user themselves) sendPushToRoom(clientInfo.roomSlug, { title: 'Friend Joined! 👋', body: `${participant.name} ${participant.emoji} joined the room`, @@ -200,7 +209,7 @@ function handleMessage(ws, data) { participantId: participant.id, participantName: participant.name, }, - }); + }, { excludeParticipantId: participant.id }); // Send current state to the new participant ws.send(JSON.stringify({ @@ -342,37 +351,33 @@ setInterval(() => { async function requestLocationFromAllRooms() { if (!VAPID_PUBLIC_KEY || !VAPID_PRIVATE_KEY) return; - for (const [roomSlug, subs] of pushSubscriptions.entries()) { - if (subs.size === 0) continue; + for (const [roomSlug, subsMap] of pushSubscriptions.entries()) { + if (subsMap.size === 0) continue; const room = rooms.get(roomSlug); // Only request if room has participants (active room) if (!room || Object.keys(room.participants).length === 0) continue; - console.log(`[${roomSlug}] Requesting location from ${subs.size} subscribers`); + console.log(`[${roomSlug}] Requesting location from ${subsMap.size} subscribers`); const failedEndpoints = []; - for (const sub of subs) { + for (const [endpoint, { subscription }] of subsMap.entries()) { try { - await webpush.sendNotification(sub, JSON.stringify({ + await webpush.sendNotification(subscription, JSON.stringify({ silent: true, data: { type: 'location_request', roomSlug } })); } catch (error) { if (error.statusCode === 404 || error.statusCode === 410) { - failedEndpoints.push(sub.endpoint); + failedEndpoints.push(endpoint); } } } // Clean up failed subscriptions for (const endpoint of failedEndpoints) { - for (const sub of subs) { - if (sub.endpoint === endpoint) { - subs.delete(sub); - console.log(`[${roomSlug}] Removed stale push subscription`); - } - } + subsMap.delete(endpoint); + console.log(`[${roomSlug}] Removed stale push subscription`); } } } @@ -433,7 +438,7 @@ const server = createServer(async (req, res) => { roomStats[slug] = { participants: Object.keys(room.participants).length, waypoints: room.waypoints.length, - pushSubscriptions: pushSubscriptions.get(slug)?.size || 0, + pushSubscriptions: pushSubscriptions.get(slug)?.size ?? 0, lastActivity: room.lastActivity }; } @@ -446,7 +451,7 @@ const server = createServer(async (req, res) => { } else if (pathname === '/push/subscribe' && req.method === 'POST') { // Subscribe to push notifications try { - const { subscription, roomSlug } = await parseJsonBody(req); + const { subscription, roomSlug, participantId } = await parseJsonBody(req); if (!subscription || !subscription.endpoint) { res.writeHead(400, { 'Content-Type': 'application/json' }); @@ -454,13 +459,16 @@ const server = createServer(async (req, res) => { return; } - // Store subscription for the room + // Store subscription for the room (deduplicates by endpoint automatically via Map key) if (roomSlug) { if (!pushSubscriptions.has(roomSlug)) { - pushSubscriptions.set(roomSlug, new Set()); + pushSubscriptions.set(roomSlug, new Map()); } - pushSubscriptions.get(roomSlug).add(subscription); - console.log(`[${roomSlug}] Push subscription added`); + pushSubscriptions.get(roomSlug).set(subscription.endpoint, { + subscription, + participantId: participantId || null, + }); + console.log(`[${roomSlug}] Push subscription added/updated for participant: ${participantId || 'unknown'}`); } res.writeHead(200, { 'Content-Type': 'application/json' }); @@ -476,13 +484,10 @@ const server = createServer(async (req, res) => { const { endpoint, roomSlug } = await parseJsonBody(req); if (roomSlug && pushSubscriptions.has(roomSlug)) { - const subs = pushSubscriptions.get(roomSlug); - for (const sub of subs) { - if (sub.endpoint === endpoint) { - subs.delete(sub); - console.log(`[${roomSlug}] Push subscription removed`); - break; - } + const subsMap = pushSubscriptions.get(roomSlug); + if (subsMap.has(endpoint)) { + subsMap.delete(endpoint); + console.log(`[${roomSlug}] Push subscription removed`); } } @@ -563,9 +568,9 @@ const server = createServer(async (req, res) => { } } else if (pathname === '/push/request-location' && req.method === 'POST') { // Manually trigger location request for a room or specific participant - // Uses WebSocket for online clients, push for offline/background + // Online clients get a silent WebSocket message; offline clients get a VISIBLE push callout try { - const { roomSlug, participantId } = await parseJsonBody(req); + const { roomSlug, participantId, callerName } = await parseJsonBody(req); if (!roomSlug) { res.writeHead(400, { 'Content-Type': 'application/json' }); @@ -586,6 +591,14 @@ const server = createServer(async (req, res) => { targetName = room.participants[participantId].name; } + // Build a set of participantIds that are currently connected via WebSocket + const onlineParticipantIds = new Set(); + for (const [ws, clientInfo] of clients.entries()) { + if (clientInfo.roomSlug === roomSlug && ws.readyState === 1 && clientInfo.participantId) { + onlineParticipantIds.add(clientInfo.participantId); + } + } + // Send WebSocket message to connected clients, deduplicated by name const locationRequestMsg = JSON.stringify({ type: 'request_location' }); const pingedNames = new Set(); @@ -617,31 +630,74 @@ const server = createServer(async (req, res) => { } console.log(`[${roomSlug}] Location request via WebSocket: ${wsSent} clients${targetName ? ` (target: ${targetName})` : ' (deduped by name)'}`); - // Then, send push notifications to offline subscribers - const subs = pushSubscriptions.get(roomSlug); - if (subs && subs.size > 0) { + // Send push notifications to subscribers that are NOT online via WebSocket + // Online users: silent push (they already got the WS message) + // Offline users: visible "callout" notification so they know someone is looking for them + const subsMap = pushSubscriptions.get(roomSlug); + if (subsMap && subsMap.size > 0) { const failedEndpoints = []; - for (const sub of subs) { + const pingerName = callerName || 'Someone'; + + for (const [endpoint, { subscription, participantId: subParticipantId }] of subsMap.entries()) { + // If targeting specific participant, skip others + if (targetName && subParticipantId) { + const subParticipant = room?.participants?.[subParticipantId]; + if (subParticipant && subParticipant.name !== targetName) continue; + } + + const isOnline = subParticipantId && onlineParticipantIds.has(subParticipantId); + try { - await webpush.sendNotification(sub, JSON.stringify({ - silent: true, - data: { type: 'location_request', roomSlug } - })); + if (isOnline) { + // 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 } + })); + } else { + // Offline users: send a VISIBLE callout notification + await webpush.sendNotification(subscription, JSON.stringify({ + title: `📍 ${pingerName} is looking for you!`, + body: `Tap to share your location in ${roomSlug}`, + tag: `callout-${roomSlug}`, + data: { + type: 'callout', + roomSlug, + callerName: pingerName, + }, + actions: [ + { action: 'view', title: 'Open Map' }, + ], + requireInteraction: true, + })); + } pushSent++; } catch (error) { pushFailed++; if (error.statusCode === 404 || error.statusCode === 410) { - failedEndpoints.push(sub.endpoint); + failedEndpoints.push(endpoint); } } } // Clean up failed subscriptions for (const endpoint of failedEndpoints) { - for (const sub of subs) { - if (sub.endpoint === endpoint) { - subs.delete(sub); - } + subsMap.delete(endpoint); + } + } + + // Collect last known locations of targeted participants for the response + const lastLocations = {}; + if (room) { + for (const [pid, p] of Object.entries(room.participants)) { + if (targetName && p.name !== targetName) continue; + if (p.location) { + lastLocations[pid] = { + name: p.name, + location: p.location, + status: p.status, + lastSeen: p.lastSeen, + }; } } } @@ -653,7 +709,8 @@ const server = createServer(async (req, res) => { websocket: wsSent, push: pushSent, pushFailed, - total: wsSent + pushSent + total: wsSent + pushSent, + lastLocations, })); } catch (error) { console.error('Location request error:', error);