fix: Deduplicate participants by name across map and list

Added useMemo-based deduplication that keeps the most recently seen
participant for each name, preventing duplicate markers on the map
and duplicate entries in the friends list.

🤖 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 2025-12-29 20:21:52 +01:00
parent 796fd2c727
commit 47dea7c9c3
2 changed files with 39 additions and 15 deletions

View File

@ -1,6 +1,6 @@
'use client';
import { useEffect, useRef, useState } from 'react';
import { useEffect, useRef, useState, useMemo } from 'react';
import maplibregl from 'maplibre-gl';
import 'maplibre-gl/dist/maplibre-gl.css';
import type { Participant, MapViewport, Waypoint, RouteSegment } from '@/types';
@ -83,6 +83,18 @@ export default function MapView({
const [mapLoaded, setMapLoaded] = useState(false);
const hasCenteredOnUserRef = useRef(false);
// Deduplicate participants by name, keeping the most recently seen one
const dedupedParticipants = useMemo(() => {
const byName = new Map<string, Participant>();
for (const p of participants) {
const existing = byName.get(p.name);
if (!existing || p.lastSeen > existing.lastSeen) {
byName.set(p.name, p);
}
}
return Array.from(byName.values());
}, [participants]);
// Initialize map
useEffect(() => {
if (!mapContainer.current || map.current) return;
@ -160,13 +172,13 @@ export default function MapView({
useEffect(() => {
if (!map.current || !mapLoaded) return;
console.log('MapView: Updating markers for', participants.length, 'participants');
participants.forEach((p) => {
console.log('MapView: Updating markers for', dedupedParticipants.length, 'participants');
dedupedParticipants.forEach((p) => {
console.log(' -', p.name, p.id, 'location:', p.location ? `${p.location.latitude}, ${p.location.longitude}` : 'none');
});
const currentMarkers = markersRef.current;
const participantIds = new Set(participants.map((p) => p.id));
const participantIds = new Set(dedupedParticipants.map((p) => p.id));
// Remove markers for participants who left
currentMarkers.forEach((marker, id) => {
@ -177,7 +189,7 @@ export default function MapView({
});
// Add/update markers for current participants
participants.forEach((participant) => {
dedupedParticipants.forEach((participant) => {
if (!participant.location) return;
const { latitude, longitude } = participant.location;
@ -221,7 +233,7 @@ export default function MapView({
// Auto-center on current user's first location
if (autoCenterOnUser && !hasCenteredOnUserRef.current && currentUserId) {
const currentUser = participants.find(p => p.id === currentUserId);
const currentUser = dedupedParticipants.find(p => p.id === currentUserId);
if (currentUser?.location && map.current) {
const { latitude, longitude } = currentUser.location;
if (isValidCoordinate(latitude, longitude)) {
@ -234,7 +246,7 @@ export default function MapView({
}
}
}
}, [participants, mapLoaded, currentUserId, onParticipantClick, autoCenterOnUser]);
}, [dedupedParticipants, mapLoaded, currentUserId, onParticipantClick, autoCenterOnUser]);
// Fly to location when zoomToLocation changes
useEffect(() => {
@ -348,9 +360,9 @@ export default function MapView({
// Fit bounds to show all participants
const fitToParticipants = () => {
if (!map.current || participants.length === 0) return;
if (!map.current || dedupedParticipants.length === 0) return;
const locatedParticipants = participants.filter(
const locatedParticipants = dedupedParticipants.filter(
(p) => p.location && isValidCoordinate(p.location.latitude, p.location.longitude)
);
if (locatedParticipants.length === 0) return;
@ -393,7 +405,7 @@ export default function MapView({
)}
{/* Fit all button */}
{participants.some((p) => p.location) && (
{dedupedParticipants.some((p) => p.location) && (
<button
onClick={fitToParticipants}
className="absolute bottom-4 right-4 bg-rmaps-dark/90 text-white px-3 py-2 rounded-lg text-sm hover:bg-rmaps-dark transition-colors"

View File

@ -1,6 +1,6 @@
'use client';
import { useState, useCallback } from 'react';
import React, { useState, useCallback, useMemo } from 'react';
import type { Participant } from '@/types';
interface ParticipantListProps {
@ -83,13 +83,25 @@ export default function ParticipantList({
return `${(distance / 1000).toFixed(1)}km`;
};
const currentParticipant = participants.find((p) => p.name === currentUserId);
// Deduplicate participants by name, keeping the most recently seen one
const deduplicatedParticipants = useMemo(() => {
const byName = new Map<string, typeof participants[0]>();
for (const p of participants) {
const existing = byName.get(p.name);
if (!existing || p.lastSeen > existing.lastSeen) {
byName.set(p.name, p);
}
}
return Array.from(byName.values());
}, [participants]);
const currentParticipant = deduplicatedParticipants.find((p) => p.name === currentUserId);
return (
<div className="room-panel h-full md:h-auto md:max-h-[calc(100vh-4rem)] rounded-t-2xl md:rounded-2xl md:m-4 overflow-hidden flex flex-col">
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-white/10">
<h2 className="font-semibold">Friends ({participants.length})</h2>
<h2 className="font-semibold">Friends ({deduplicatedParticipants.length})</h2>
<div className="flex items-center gap-2">
{/* Refresh locations button */}
{syncUrl && (
@ -139,13 +151,13 @@ export default function ParticipantList({
{/* Participant list */}
<div className="flex-1 overflow-y-auto p-2">
{participants.length === 0 ? (
{deduplicatedParticipants.length === 0 ? (
<div className="text-center text-white/40 py-8">
No one else is here yet
</div>
) : (
<div className="space-y-1">
{participants.map((participant) => {
{deduplicatedParticipants.map((participant) => {
const isMe = participant.name === currentUserId;
const distance = !isMe
? formatDistance(participant, currentParticipant)