From c34a84672a9e80140dd080e84aa9a71b607d7884 Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Fri, 2 Jan 2026 13:32:25 +0100 Subject: [PATCH] feat: Bell icon for pings and individual user ping support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Changed ping button icon from circular arrows to bell with brackets - Added individual ping button on each participant row - Server now supports targeting specific participant by ID - Deduplication still applies (only pings one session per name) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- src/components/room/ParticipantList.tsx | 133 +++++++++++++++++++----- sync-server/server.js | 21 +++- 2 files changed, 121 insertions(+), 33 deletions(-) diff --git a/src/components/room/ParticipantList.tsx b/src/components/room/ParticipantList.tsx index 77ef920..b9aff0c 100644 --- a/src/components/room/ParticipantList.tsx +++ b/src/components/room/ParticipantList.tsx @@ -23,9 +23,11 @@ export default function ParticipantList({ onSetMeetingPoint, }: ParticipantListProps) { const [isRefreshing, setIsRefreshing] = useState(false); + const [pingingUser, setPingingUser] = useState(null); const [refreshMessage, setRefreshMessage] = useState(null); - const handleRefreshLocations = useCallback(async () => { + // Ping all users in the room + const handlePingAll = useCallback(async () => { if (!syncUrl || isRefreshing) return; setIsRefreshing(true); @@ -49,14 +51,43 @@ export default function ParticipantList({ } } } catch (error) { - console.error('Failed to refresh locations:', error); + console.error('Failed to ping all:', error); setRefreshMessage('Failed to ping'); } finally { setIsRefreshing(false); - // Clear message after 3 seconds setTimeout(() => setRefreshMessage(null), 3000); } }, [syncUrl, roomSlug, isRefreshing]); + + // Ping a single user + const handlePingUser = useCallback(async (participantId: string, participantName: string) => { + if (!syncUrl || pingingUser) return; + + setPingingUser(participantId); + setRefreshMessage(null); + + try { + const httpUrl = syncUrl.replace('wss://', 'https://').replace('ws://', 'http://'); + const response = await fetch(`${httpUrl}/push/request-location`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ roomSlug, participantId }), + }); + + const data = await response.json(); + if (data.success) { + setRefreshMessage(`Pinged ${participantName}`); + } else { + setRefreshMessage(`Couldn't reach ${participantName}`); + } + } catch (error) { + console.error('Failed to ping user:', error); + setRefreshMessage('Failed to ping'); + } finally { + setPingingUser(null); + setTimeout(() => setRefreshMessage(null), 3000); + } + }, [syncUrl, roomSlug, pingingUser]); const formatDistance = (participant: Participant, current: Participant | undefined) => { if (!participant.location || !current?.location) return null; @@ -103,25 +134,40 @@ export default function ParticipantList({

Friends ({deduplicatedParticipants.length})

- {/* Refresh locations button */} + {/* Ping all friends button */} {syncUrl && ( @@ -198,26 +244,57 @@ export default function ParticipantList({ )}
- {/* Navigate button */} - {!isMe && participant.location && ( - + {/* Action buttons */} + {!isMe && ( +
+ {/* Ping single user button */} + {syncUrl && ( + + )} + {/* Navigate button */} + {participant.location && ( + + )} +
)}
); diff --git a/sync-server/server.js b/sync-server/server.js index cc6cd88..8a445cd 100644 --- a/sync-server/server.js +++ b/sync-server/server.js @@ -470,10 +470,10 @@ const server = createServer(async (req, res) => { res.end(JSON.stringify({ error: 'Failed to send test' })); } } else if (pathname === '/push/request-location' && req.method === 'POST') { - // Manually trigger location request for a room + // Manually trigger location request for a room or specific participant // Uses WebSocket for online clients, push for offline/background try { - const { roomSlug } = await parseJsonBody(req); + const { roomSlug, participantId } = await parseJsonBody(req); if (!roomSlug) { res.writeHead(400, { 'Content-Type': 'application/json' }); @@ -488,16 +488,27 @@ const server = createServer(async (req, res) => { // Get room to access participant names for deduplication const room = rooms.get(roomSlug); - // First, send WebSocket message to connected clients, deduplicated by name + // If targeting a specific participant, get their name for deduplication + let targetName = null; + if (participantId && room?.participants?.[participantId]) { + targetName = room.participants[participantId].name; + } + + // Send WebSocket message to connected clients, deduplicated by name const locationRequestMsg = JSON.stringify({ type: 'request_location' }); const pingedNames = new Set(); for (const [ws, clientInfo] of clients.entries()) { if (clientInfo.roomSlug === roomSlug && ws.readyState === 1) { - // Get participant name for this client + // Get participant info for this client const participant = room?.participants?.[clientInfo.participantId]; const name = participant?.name; + // If targeting specific participant, only ping that name + if (targetName && name !== targetName) { + continue; + } + // Skip if we've already pinged this name if (name && pingedNames.has(name)) { console.log(`[${roomSlug}] Skipping duplicate ping for: ${name}`); @@ -512,7 +523,7 @@ const server = createServer(async (req, res) => { } } } - console.log(`[${roomSlug}] Location request via WebSocket: ${wsSent} clients (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 const subs = pushSubscriptions.get(roomSlug);