feat: Bell icon for pings and individual user ping support

- 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 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-01-02 13:32:25 +01:00
parent ff45193ba2
commit c34a84672a
2 changed files with 121 additions and 33 deletions

View File

@ -23,9 +23,11 @@ export default function ParticipantList({
onSetMeetingPoint,
}: ParticipantListProps) {
const [isRefreshing, setIsRefreshing] = useState(false);
const [pingingUser, setPingingUser] = useState<string | null>(null);
const [refreshMessage, setRefreshMessage] = useState<string | null>(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({
<div className="flex items-center justify-between p-4 border-b border-white/10">
<h2 className="font-semibold">Friends ({deduplicatedParticipants.length})</h2>
<div className="flex items-center gap-2">
{/* Refresh locations button */}
{/* Ping all friends button */}
{syncUrl && (
<button
onClick={handleRefreshLocations}
onClick={handlePingAll}
disabled={isRefreshing}
className="p-1.5 rounded hover:bg-white/10 transition-colors disabled:opacity-50"
title="Ping friends for location updates"
title="Ping all friends for location updates"
>
<svg
className={`w-5 h-5 ${isRefreshing ? 'animate-spin' : ''}`}
className={`w-5 h-5 ${isRefreshing ? 'animate-pulse' : ''}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
{/* Bell with broadcast brackets */}
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9"
/>
{/* Left bracket */}
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M2 8c0-1 .5-2 1.5-3M2 16c0 1 .5 2 1.5 3"
/>
{/* Right bracket */}
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M22 8c0-1-.5-2-1.5-3M22 16c0 1-.5 2-1.5 3"
/>
</svg>
</button>
@ -198,26 +244,57 @@ export default function ParticipantList({
)}
</div>
{/* Navigate button */}
{!isMe && participant.location && (
<button
className="p-2 rounded-lg bg-white/10 hover:bg-white/20 transition-colors"
title="Navigate to"
>
<svg
className="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 20l-5.447-2.724A1 1 0 013 16.382V5.618a1 1 0 011.447-.894L9 7m0 13l6-3m-6 3V7m6 10l4.553 2.276A1 1 0 0021 18.382V7.618a1 1 0 00-.553-.894L15 4m0 13V4m0 0L9 7"
/>
</svg>
</button>
{/* Action buttons */}
{!isMe && (
<div className="flex items-center gap-1">
{/* Ping single user button */}
{syncUrl && (
<button
onClick={(e) => {
e.stopPropagation();
handlePingUser(participant.id, participant.name);
}}
disabled={pingingUser === participant.id}
className="p-2 rounded-lg bg-white/10 hover:bg-white/20 transition-colors disabled:opacity-50"
title={`Ping ${participant.name}`}
>
<svg
className={`w-4 h-4 ${pingingUser === participant.id ? 'animate-pulse' : ''}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9"
/>
</svg>
</button>
)}
{/* Navigate button */}
{participant.location && (
<button
className="p-2 rounded-lg bg-white/10 hover:bg-white/20 transition-colors"
title="Navigate to"
>
<svg
className="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 20l-5.447-2.724A1 1 0 013 16.382V5.618a1 1 0 011.447-.894L9 7m0 13l6-3m-6 3V7m6 10l4.553 2.276A1 1 0 0021 18.382V7.618a1 1 0 00-.553-.894L15 4m0 13V4m0 0L9 7"
/>
</svg>
</button>
)}
</div>
)}
</div>
);

View File

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