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,
|
||||
}: 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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Reference in New Issue