fix: deduplicate participants, visible callout push for offline users, fix notification timing

- Server-side participant dedup on join: remove ghost entries with same name but different ID
- Reduce stale participant threshold from 1hr to 15min to match client-side cleanup
- Refactor push subscriptions from Set to Map keyed by endpoint (prevents duplicate pushes)
- Store participantId with push subscriptions for identity-aware routing
- Exclude joining user from their own "Friend Joined" push notification
- Callout (ping) sends visible push to offline users ("X is looking for you!") instead of silent push
- Return last known locations in callout API response for immediate display
- Service worker: 10s cooldown on location request pushes to prevent burst on app reopen
- Service worker: suppress join/leave notifications when app window is focused
- Pass callerName from ParticipantList so offline callout shows who's looking

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-02-15 14:01:56 -07:00
parent 8e96d4eec0
commit 30f32e6da7
6 changed files with 170 additions and 79 deletions

View File

@ -13,3 +13,4 @@ auto_commit: false
bypass_git_hooks: false
check_active_branches: true
active_branch_days: 30
task_prefix: "task"

View File

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

View File

@ -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() {
<ParticipantList
participants={participants}
currentUserId={currentUser.name}
currentUserName={currentUser.name}
roomSlug={slug}
syncUrl={process.env.NEXT_PUBLIC_SYNC_URL}
onClose={() => setShowParticipants(false)}

View File

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

View File

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

View File

@ -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<WebSocket, { roomSlug, participantId, claims, readOnly }>
const clients = new Map();
// Push subscriptions: Map<roomSlug, Set<subscription>>
// Push subscriptions: Map<roomSlug, Map<endpoint, { subscription, participantId }>>
// 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);