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 bypass_git_hooks: false
check_active_branches: true check_active_branches: true
active_branch_days: 30 active_branch_days: 30
task_prefix: "task"

View File

@ -297,6 +297,11 @@ async function requestLocationFromClient() {
} }
// ==================== PUSH NOTIFICATIONS ==================== // ==================== 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) => { self.addEventListener('push', (event) => {
console.log('[SW] Push received:', event); console.log('[SW] Push received:', event);
@ -321,6 +326,13 @@ self.addEventListener('push', (event) => {
// Handle silent push - request location update without showing notification // Handle silent push - request location update without showing notification
if (data.silent || data.data?.type === 'location_request') { 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( event.waitUntil(
requestLocationFromClient().then((sent) => { requestLocationFromClient().then((sent) => {
console.log('[SW] Silent push - location request sent:', sent); console.log('[SW] Silent push - location request sent:', sent);
@ -329,6 +341,20 @@ self.addEventListener('push', (event) => {
return; return;
} }
// For join/leave notifications, suppress if app is currently focused
// (user can already see who's joining/leaving)
event.waitUntil(
(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 = { const options = {
body: data.body, body: data.body,
icon: data.icon || '/icon-192.png', icon: data.icon || '/icon-192.png',
@ -342,8 +368,8 @@ self.addEventListener('push', (event) => {
renotify: true, renotify: true,
}; };
event.waitUntil( await self.registration.showNotification(data.title, options);
self.registration.showNotification(data.title, options) })()
); );
}); });

View File

@ -165,6 +165,7 @@ export default function RoomPage() {
const { subscribe: subscribePush, isSubscribed: isPushSubscribed } = usePushNotifications({ const { subscribe: subscribePush, isSubscribed: isPushSubscribed } = usePushNotifications({
syncUrl, syncUrl,
roomSlug: slug, roomSlug: slug,
participantId: currentParticipantId || undefined,
}); });
// Auto-subscribe to push when joining room (like location permission) // Auto-subscribe to push when joining room (like location permission)
@ -377,6 +378,7 @@ export default function RoomPage() {
<ParticipantList <ParticipantList
participants={participants} participants={participants}
currentUserId={currentUser.name} currentUserId={currentUser.name}
currentUserName={currentUser.name}
roomSlug={slug} roomSlug={slug}
syncUrl={process.env.NEXT_PUBLIC_SYNC_URL} syncUrl={process.env.NEXT_PUBLIC_SYNC_URL}
onClose={() => setShowParticipants(false)} onClose={() => setShowParticipants(false)}

View File

@ -6,6 +6,7 @@ import type { Participant } from '@/types';
interface ParticipantListProps { interface ParticipantListProps {
participants: Participant[]; participants: Participant[];
currentUserId?: string; currentUserId?: string;
currentUserName?: string;
roomSlug: string; roomSlug: string;
syncUrl?: string; syncUrl?: string;
onClose: () => void; onClose: () => void;
@ -17,6 +18,7 @@ interface ParticipantListProps {
export default function ParticipantList({ export default function ParticipantList({
participants, participants,
currentUserId, currentUserId,
currentUserName,
roomSlug, roomSlug,
syncUrl, syncUrl,
onClose, onClose,
@ -40,7 +42,7 @@ export default function ParticipantList({
const response = await fetch(`${httpUrl}/push/request-location`, { const response = await fetch(`${httpUrl}/push/request-location`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ roomSlug }), body: JSON.stringify({ roomSlug, callerName: currentUserName }),
}); });
const data = await response.json(); const data = await response.json();
@ -59,7 +61,7 @@ export default function ParticipantList({
setIsRefreshing(false); setIsRefreshing(false);
setTimeout(() => setRefreshMessage(null), 3000); setTimeout(() => setRefreshMessage(null), 3000);
} }
}, [syncUrl, roomSlug, isRefreshing]); }, [syncUrl, roomSlug, isRefreshing, currentUserName]);
// Ping a single user // Ping a single user
const handlePingUser = useCallback(async (participantId: string, participantName: string) => { const handlePingUser = useCallback(async (participantId: string, participantName: string) => {
@ -73,7 +75,7 @@ export default function ParticipantList({
const response = await fetch(`${httpUrl}/push/request-location`, { const response = await fetch(`${httpUrl}/push/request-location`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ roomSlug, participantId }), body: JSON.stringify({ roomSlug, participantId, callerName: currentUserName }),
}); });
const data = await response.json(); const data = await response.json();
@ -89,7 +91,7 @@ export default function ParticipantList({
setPingingUser(null); setPingingUser(null);
setTimeout(() => setRefreshMessage(null), 3000); setTimeout(() => setRefreshMessage(null), 3000);
} }
}, [syncUrl, roomSlug, pingingUser]); }, [syncUrl, roomSlug, pingingUser, currentUserName]);
const formatDistance = (participant: Participant, current: Participant | undefined) => { const formatDistance = (participant: Participant, current: Participant | undefined) => {
if (!participant.location || !current?.location) return null; if (!participant.location || !current?.location) return null;

View File

@ -15,6 +15,8 @@ interface UsePushNotificationsOptions {
roomSlug?: string; roomSlug?: string;
/** Sync server URL for storing subscriptions */ /** Sync server URL for storing subscriptions */
syncUrl?: string; syncUrl?: string;
/** Participant ID to associate with this push subscription */
participantId?: string;
} }
// VAPID public key - should match the server's public key // VAPID public key - should match the server's public key
@ -37,7 +39,7 @@ function urlBase64ToUint8Array(base64String: string): Uint8Array {
} }
export function usePushNotifications(options: UsePushNotificationsOptions = {}) { export function usePushNotifications(options: UsePushNotificationsOptions = {}) {
const { roomSlug, syncUrl } = options; const { roomSlug, syncUrl, participantId } = options;
const [state, setState] = useState<PushSubscriptionState>({ const [state, setState] = useState<PushSubscriptionState>({
isSupported: false, isSupported: false,
@ -162,6 +164,7 @@ export function usePushNotifications(options: UsePushNotificationsOptions = {})
body: JSON.stringify({ body: JSON.stringify({
subscription: pushSubscription.toJSON(), subscription: pushSubscription.toJSON(),
roomSlug, roomSlug,
participantId,
}), }),
}); });
@ -194,7 +197,7 @@ export function usePushNotifications(options: UsePushNotificationsOptions = {})
})); }));
throw error; throw error;
} }
}, [state.isSupported, syncUrl, roomSlug]); }, [state.isSupported, syncUrl, roomSlug, participantId]);
// Unsubscribe from push notifications // Unsubscribe from push notifications
const unsubscribe = useCallback(async () => { const unsubscribe = useCallback(async () => {

View File

@ -6,7 +6,7 @@ import webpush from 'web-push';
import { verifyToken, extractTokenFromURL } from './verify-token.js'; import { verifyToken, extractTokenFromURL } from './verify-token.js';
const PORT = process.env.PORT || 3001; 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 const LOCATION_REQUEST_INTERVAL_MS = parseInt(process.env.LOCATION_REQUEST_INTERVAL || '60000', 10); // 60 seconds default
// VAPID keys for push notifications // VAPID keys for push notifications
@ -29,7 +29,8 @@ const rooms = new Map();
// Client tracking: Map<WebSocket, { roomSlug, participantId, claims, readOnly }> // Client tracking: Map<WebSocket, { roomSlug, participantId, claims, readOnly }>
const clients = new Map(); 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(); const pushSubscriptions = new Map();
function getRoomState(slug) { function getRoomState(slug) {
@ -119,40 +120,38 @@ function broadcast(roomSlug, message, excludeWs = null) {
} }
// Send push notification to all subscriptions for a room // 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; if (!VAPID_PUBLIC_KEY || !VAPID_PRIVATE_KEY) return;
const subs = pushSubscriptions.get(roomSlug); const subsMap = pushSubscriptions.get(roomSlug);
if (!subs || subs.size === 0) return; if (!subsMap || subsMap.size === 0) return;
const payload = JSON.stringify(notification); const payload = JSON.stringify(notification);
const failedEndpoints = []; const failedEndpoints = [];
for (const sub of subs) { for (const [endpoint, { subscription, participantId }] of subsMap.entries()) {
if (excludeEndpoint && sub.endpoint === excludeEndpoint) continue; if (excludeEndpoint && endpoint === excludeEndpoint) continue;
if (excludeParticipantId && participantId === excludeParticipantId) continue;
try { try {
await webpush.sendNotification(sub, payload); await webpush.sendNotification(subscription, payload);
console.log(`[${roomSlug}] Push sent to ${sub.endpoint.slice(-20)}`); console.log(`[${roomSlug}] Push sent to ${endpoint.slice(-20)}`);
} catch (error) { } catch (error) {
console.error(`[${roomSlug}] Push failed:`, error.statusCode || error.message); console.error(`[${roomSlug}] Push failed:`, error.statusCode || error.message);
// Remove invalid subscriptions // Remove invalid subscriptions
if (error.statusCode === 404 || error.statusCode === 410) { if (error.statusCode === 404 || error.statusCode === 410) {
failedEndpoints.push(sub.endpoint); failedEndpoints.push(endpoint);
} }
} }
} }
// Clean up failed subscriptions // Clean up failed subscriptions
for (const endpoint of failedEndpoints) { for (const endpoint of failedEndpoints) {
for (const sub of subs) { subsMap.delete(endpoint);
if (sub.endpoint === endpoint) {
subs.delete(sub);
console.log(`[${roomSlug}] Removed invalid subscription`); console.log(`[${roomSlug}] Removed invalid subscription`);
} }
} }
}
}
function handleMessage(ws, data) { function handleMessage(ws, data) {
const clientInfo = clients.get(ws); const clientInfo = clients.get(ws);
@ -181,6 +180,16 @@ function handleMessage(ws, data) {
...message.participant, ...message.participant,
lastSeen: Date.now() 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; room.participants[participant.id] = participant;
clientInfo.participantId = participant.id; clientInfo.participantId = participant.id;
@ -189,7 +198,7 @@ function handleMessage(ws, data) {
// Broadcast join to others // Broadcast join to others
broadcast(clientInfo.roomSlug, message, ws); broadcast(clientInfo.roomSlug, message, ws);
// Send push notification to others // Send push notification to others (exclude the joining user themselves)
sendPushToRoom(clientInfo.roomSlug, { sendPushToRoom(clientInfo.roomSlug, {
title: 'Friend Joined! 👋', title: 'Friend Joined! 👋',
body: `${participant.name} ${participant.emoji} joined the room`, body: `${participant.name} ${participant.emoji} joined the room`,
@ -200,7 +209,7 @@ function handleMessage(ws, data) {
participantId: participant.id, participantId: participant.id,
participantName: participant.name, participantName: participant.name,
}, },
}); }, { excludeParticipantId: participant.id });
// Send current state to the new participant // Send current state to the new participant
ws.send(JSON.stringify({ ws.send(JSON.stringify({
@ -342,40 +351,36 @@ setInterval(() => {
async function requestLocationFromAllRooms() { async function requestLocationFromAllRooms() {
if (!VAPID_PUBLIC_KEY || !VAPID_PRIVATE_KEY) return; if (!VAPID_PUBLIC_KEY || !VAPID_PRIVATE_KEY) return;
for (const [roomSlug, subs] of pushSubscriptions.entries()) { for (const [roomSlug, subsMap] of pushSubscriptions.entries()) {
if (subs.size === 0) continue; if (subsMap.size === 0) continue;
const room = rooms.get(roomSlug); const room = rooms.get(roomSlug);
// Only request if room has participants (active room) // Only request if room has participants (active room)
if (!room || Object.keys(room.participants).length === 0) continue; 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 = []; const failedEndpoints = [];
for (const sub of subs) { for (const [endpoint, { subscription }] of subsMap.entries()) {
try { try {
await webpush.sendNotification(sub, JSON.stringify({ await webpush.sendNotification(subscription, JSON.stringify({
silent: true, silent: true,
data: { type: 'location_request', roomSlug } data: { type: 'location_request', roomSlug }
})); }));
} catch (error) { } catch (error) {
if (error.statusCode === 404 || error.statusCode === 410) { if (error.statusCode === 404 || error.statusCode === 410) {
failedEndpoints.push(sub.endpoint); failedEndpoints.push(endpoint);
} }
} }
} }
// Clean up failed subscriptions // Clean up failed subscriptions
for (const endpoint of failedEndpoints) { for (const endpoint of failedEndpoints) {
for (const sub of subs) { subsMap.delete(endpoint);
if (sub.endpoint === endpoint) {
subs.delete(sub);
console.log(`[${roomSlug}] Removed stale push subscription`); console.log(`[${roomSlug}] Removed stale push subscription`);
} }
} }
} }
}
}
// Start automatic location request interval // Start automatic location request interval
if (VAPID_PUBLIC_KEY && VAPID_PRIVATE_KEY && LOCATION_REQUEST_INTERVAL_MS > 0) { if (VAPID_PUBLIC_KEY && VAPID_PRIVATE_KEY && LOCATION_REQUEST_INTERVAL_MS > 0) {
@ -433,7 +438,7 @@ const server = createServer(async (req, res) => {
roomStats[slug] = { roomStats[slug] = {
participants: Object.keys(room.participants).length, participants: Object.keys(room.participants).length,
waypoints: room.waypoints.length, waypoints: room.waypoints.length,
pushSubscriptions: pushSubscriptions.get(slug)?.size || 0, pushSubscriptions: pushSubscriptions.get(slug)?.size ?? 0,
lastActivity: room.lastActivity lastActivity: room.lastActivity
}; };
} }
@ -446,7 +451,7 @@ const server = createServer(async (req, res) => {
} else if (pathname === '/push/subscribe' && req.method === 'POST') { } else if (pathname === '/push/subscribe' && req.method === 'POST') {
// Subscribe to push notifications // Subscribe to push notifications
try { try {
const { subscription, roomSlug } = await parseJsonBody(req); const { subscription, roomSlug, participantId } = await parseJsonBody(req);
if (!subscription || !subscription.endpoint) { if (!subscription || !subscription.endpoint) {
res.writeHead(400, { 'Content-Type': 'application/json' }); res.writeHead(400, { 'Content-Type': 'application/json' });
@ -454,13 +459,16 @@ const server = createServer(async (req, res) => {
return; return;
} }
// Store subscription for the room // Store subscription for the room (deduplicates by endpoint automatically via Map key)
if (roomSlug) { if (roomSlug) {
if (!pushSubscriptions.has(roomSlug)) { if (!pushSubscriptions.has(roomSlug)) {
pushSubscriptions.set(roomSlug, new Set()); pushSubscriptions.set(roomSlug, new Map());
} }
pushSubscriptions.get(roomSlug).add(subscription); pushSubscriptions.get(roomSlug).set(subscription.endpoint, {
console.log(`[${roomSlug}] Push subscription added`); subscription,
participantId: participantId || null,
});
console.log(`[${roomSlug}] Push subscription added/updated for participant: ${participantId || 'unknown'}`);
} }
res.writeHead(200, { 'Content-Type': 'application/json' }); res.writeHead(200, { 'Content-Type': 'application/json' });
@ -476,13 +484,10 @@ const server = createServer(async (req, res) => {
const { endpoint, roomSlug } = await parseJsonBody(req); const { endpoint, roomSlug } = await parseJsonBody(req);
if (roomSlug && pushSubscriptions.has(roomSlug)) { if (roomSlug && pushSubscriptions.has(roomSlug)) {
const subs = pushSubscriptions.get(roomSlug); const subsMap = pushSubscriptions.get(roomSlug);
for (const sub of subs) { if (subsMap.has(endpoint)) {
if (sub.endpoint === endpoint) { subsMap.delete(endpoint);
subs.delete(sub);
console.log(`[${roomSlug}] Push subscription removed`); console.log(`[${roomSlug}] Push subscription removed`);
break;
}
} }
} }
@ -563,9 +568,9 @@ const server = createServer(async (req, res) => {
} }
} else if (pathname === '/push/request-location' && req.method === 'POST') { } else if (pathname === '/push/request-location' && req.method === 'POST') {
// Manually trigger location request for a room or specific participant // 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 { try {
const { roomSlug, participantId } = await parseJsonBody(req); const { roomSlug, participantId, callerName } = await parseJsonBody(req);
if (!roomSlug) { if (!roomSlug) {
res.writeHead(400, { 'Content-Type': 'application/json' }); res.writeHead(400, { 'Content-Type': 'application/json' });
@ -586,6 +591,14 @@ const server = createServer(async (req, res) => {
targetName = room.participants[participantId].name; 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 // Send WebSocket message to connected clients, deduplicated by name
const locationRequestMsg = JSON.stringify({ type: 'request_location' }); const locationRequestMsg = JSON.stringify({ type: 'request_location' });
const pingedNames = new Set(); const pingedNames = new Set();
@ -617,32 +630,75 @@ const server = createServer(async (req, res) => {
} }
console.log(`[${roomSlug}] Location request via WebSocket: ${wsSent} clients${targetName ? ` (target: ${targetName})` : ' (deduped by name)'}`); console.log(`[${roomSlug}] Location request via WebSocket: ${wsSent} clients${targetName ? ` (target: ${targetName})` : ' (deduped by name)'}`);
// Then, send push notifications to offline subscribers // Send push notifications to subscribers that are NOT online via WebSocket
const subs = pushSubscriptions.get(roomSlug); // Online users: silent push (they already got the WS message)
if (subs && subs.size > 0) { // 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 = []; 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 { try {
await webpush.sendNotification(sub, JSON.stringify({ if (isOnline) {
// Online users already got the WS message; send silent push as backup
await webpush.sendNotification(subscription, JSON.stringify({
silent: true, silent: true,
data: { type: 'location_request', roomSlug } 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++; pushSent++;
} catch (error) { } catch (error) {
pushFailed++; pushFailed++;
if (error.statusCode === 404 || error.statusCode === 410) { if (error.statusCode === 404 || error.statusCode === 410) {
failedEndpoints.push(sub.endpoint); failedEndpoints.push(endpoint);
} }
} }
} }
// Clean up failed subscriptions // Clean up failed subscriptions
for (const endpoint of failedEndpoints) { for (const endpoint of failedEndpoints) {
for (const sub of subs) { subsMap.delete(endpoint);
if (sub.endpoint === endpoint) {
subs.delete(sub);
} }
} }
// 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, websocket: wsSent,
push: pushSent, push: pushSent,
pushFailed, pushFailed,
total: wsSent + pushSent total: wsSent + pushSent,
lastLocations,
})); }));
} catch (error) { } catch (error) {
console.error('Location request error:', error); console.error('Location request error:', error);