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:
parent
ff45193ba2
commit
c34a84672a
|
|
@ -23,9 +23,11 @@ export default function ParticipantList({
|
||||||
onSetMeetingPoint,
|
onSetMeetingPoint,
|
||||||
}: ParticipantListProps) {
|
}: ParticipantListProps) {
|
||||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||||
|
const [pingingUser, setPingingUser] = useState<string | null>(null);
|
||||||
const [refreshMessage, setRefreshMessage] = 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;
|
if (!syncUrl || isRefreshing) return;
|
||||||
|
|
||||||
setIsRefreshing(true);
|
setIsRefreshing(true);
|
||||||
|
|
@ -49,14 +51,43 @@ export default function ParticipantList({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to refresh locations:', error);
|
console.error('Failed to ping all:', error);
|
||||||
setRefreshMessage('Failed to ping');
|
setRefreshMessage('Failed to ping');
|
||||||
} finally {
|
} finally {
|
||||||
setIsRefreshing(false);
|
setIsRefreshing(false);
|
||||||
// Clear message after 3 seconds
|
|
||||||
setTimeout(() => setRefreshMessage(null), 3000);
|
setTimeout(() => setRefreshMessage(null), 3000);
|
||||||
}
|
}
|
||||||
}, [syncUrl, roomSlug, isRefreshing]);
|
}, [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) => {
|
const formatDistance = (participant: Participant, current: Participant | undefined) => {
|
||||||
if (!participant.location || !current?.location) return null;
|
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">
|
<div className="flex items-center justify-between p-4 border-b border-white/10">
|
||||||
<h2 className="font-semibold">Friends ({deduplicatedParticipants.length})</h2>
|
<h2 className="font-semibold">Friends ({deduplicatedParticipants.length})</h2>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{/* Refresh locations button */}
|
{/* Ping all friends button */}
|
||||||
{syncUrl && (
|
{syncUrl && (
|
||||||
<button
|
<button
|
||||||
onClick={handleRefreshLocations}
|
onClick={handlePingAll}
|
||||||
disabled={isRefreshing}
|
disabled={isRefreshing}
|
||||||
className="p-1.5 rounded hover:bg-white/10 transition-colors disabled:opacity-50"
|
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
|
<svg
|
||||||
className={`w-5 h-5 ${isRefreshing ? 'animate-spin' : ''}`}
|
className={`w-5 h-5 ${isRefreshing ? 'animate-pulse' : ''}`}
|
||||||
fill="none"
|
fill="none"
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
>
|
>
|
||||||
|
{/* Bell with broadcast brackets */}
|
||||||
<path
|
<path
|
||||||
strokeLinecap="round"
|
strokeLinecap="round"
|
||||||
strokeLinejoin="round"
|
strokeLinejoin="round"
|
||||||
strokeWidth={2}
|
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>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -198,26 +244,57 @@ export default function ParticipantList({
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Navigate button */}
|
{/* Action buttons */}
|
||||||
{!isMe && participant.location && (
|
{!isMe && (
|
||||||
<button
|
<div className="flex items-center gap-1">
|
||||||
className="p-2 rounded-lg bg-white/10 hover:bg-white/20 transition-colors"
|
{/* Ping single user button */}
|
||||||
title="Navigate to"
|
{syncUrl && (
|
||||||
>
|
<button
|
||||||
<svg
|
onClick={(e) => {
|
||||||
className="w-4 h-4"
|
e.stopPropagation();
|
||||||
fill="none"
|
handlePingUser(participant.id, participant.name);
|
||||||
stroke="currentColor"
|
}}
|
||||||
viewBox="0 0 24 24"
|
disabled={pingingUser === participant.id}
|
||||||
>
|
className="p-2 rounded-lg bg-white/10 hover:bg-white/20 transition-colors disabled:opacity-50"
|
||||||
<path
|
title={`Ping ${participant.name}`}
|
||||||
strokeLinecap="round"
|
>
|
||||||
strokeLinejoin="round"
|
<svg
|
||||||
strokeWidth={2}
|
className={`w-4 h-4 ${pingingUser === participant.id ? 'animate-pulse' : ''}`}
|
||||||
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"
|
fill="none"
|
||||||
/>
|
stroke="currentColor"
|
||||||
</svg>
|
viewBox="0 0 24 24"
|
||||||
</button>
|
>
|
||||||
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -470,10 +470,10 @@ const server = createServer(async (req, res) => {
|
||||||
res.end(JSON.stringify({ error: 'Failed to send test' }));
|
res.end(JSON.stringify({ error: 'Failed to send test' }));
|
||||||
}
|
}
|
||||||
} 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
|
// Manually trigger location request for a room or specific participant
|
||||||
// Uses WebSocket for online clients, push for offline/background
|
// Uses WebSocket for online clients, push for offline/background
|
||||||
try {
|
try {
|
||||||
const { roomSlug } = await parseJsonBody(req);
|
const { roomSlug, participantId } = await parseJsonBody(req);
|
||||||
|
|
||||||
if (!roomSlug) {
|
if (!roomSlug) {
|
||||||
res.writeHead(400, { 'Content-Type': 'application/json' });
|
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
|
// Get room to access participant names for deduplication
|
||||||
const room = rooms.get(roomSlug);
|
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 locationRequestMsg = JSON.stringify({ type: 'request_location' });
|
||||||
const pingedNames = new Set();
|
const pingedNames = new Set();
|
||||||
|
|
||||||
for (const [ws, clientInfo] of clients.entries()) {
|
for (const [ws, clientInfo] of clients.entries()) {
|
||||||
if (clientInfo.roomSlug === roomSlug && ws.readyState === 1) {
|
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 participant = room?.participants?.[clientInfo.participantId];
|
||||||
const name = participant?.name;
|
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
|
// Skip if we've already pinged this name
|
||||||
if (name && pingedNames.has(name)) {
|
if (name && pingedNames.has(name)) {
|
||||||
console.log(`[${roomSlug}] Skipping duplicate ping for: ${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
|
// Then, send push notifications to offline subscribers
|
||||||
const subs = pushSubscriptions.get(roomSlug);
|
const subs = pushSubscriptions.get(roomSlug);
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue