From bc7506b0d996fb1b53416be5d82964ad9e83e15c Mon Sep 17 00:00:00 2001 From: Jack Kingsman Date: Wed, 11 Mar 2026 20:49:37 -0700 Subject: [PATCH] Some misc frontend cleanup grossness --- frontend/src/components/MapView.tsx | 23 +++++--- frontend/src/hooks/useConversationMessages.ts | 4 +- frontend/src/test/mapView.test.tsx | 55 +++++++++++++++++++ .../test/useConversationMessages.race.test.ts | 23 ++++++++ 4 files changed, 95 insertions(+), 10 deletions(-) create mode 100644 frontend/src/test/mapView.test.tsx diff --git a/frontend/src/components/MapView.tsx b/frontend/src/components/MapView.tsx index 388e9eb..f7d3b29 100644 --- a/frontend/src/components/MapView.tsx +++ b/frontend/src/components/MapView.tsx @@ -14,7 +14,8 @@ interface MapViewProps { } // Calculate marker color based on how recently the contact was heard -function getMarkerColor(lastSeen: number): string { +function getMarkerColor(lastSeen: number | null | undefined): string { + if (lastSeen == null) return '#9ca3af'; const now = Date.now() / 1000; const age = now - lastSeen; const hour = 3600; @@ -94,16 +95,17 @@ function MapBoundsHandler({ } export function MapView({ contacts, focusedKey }: MapViewProps) { + const sevenDaysAgo = Date.now() / 1000 - 7 * 24 * 60 * 60; + // Filter to contacts with GPS coordinates, heard within the last 7 days. // Always include the focused contact so "view on map" links work for older nodes. const mappableContacts = useMemo(() => { - const sevenDaysAgo = Date.now() / 1000 - 7 * 24 * 60 * 60; return contacts.filter( (c) => isValidLocation(c.lat, c.lon) && (c.public_key === focusedKey || (c.last_seen != null && c.last_seen > sevenDaysAgo)) ); - }, [contacts, focusedKey]); + }, [contacts, focusedKey, sevenDaysAgo]); // Find the focused contact by key const focusedContact = useMemo(() => { @@ -111,6 +113,10 @@ export function MapView({ contacts, focusedKey }: MapViewProps) { return mappableContacts.find((c) => c.public_key === focusedKey) || null; }, [focusedKey, mappableContacts]); + const includesFocusedOutsideWindow = + focusedContact != null && + (focusedContact.last_seen == null || focusedContact.last_seen <= sevenDaysAgo); + // Track marker refs to open popup programmatically const markerRefs = useRef>({}); @@ -137,6 +143,7 @@ export function MapView({ contacts, focusedKey }: MapViewProps) { Showing {mappableContacts.length} contact{mappableContacts.length !== 1 ? 's' : ''} heard in the last 7 days + {includesFocusedOutsideWindow ? ' plus the focused contact' : ''}
@@ -175,8 +182,12 @@ export function MapView({ contacts, focusedKey }: MapViewProps) { {mappableContacts.map((contact) => { const isRepeater = contact.type === CONTACT_TYPE_REPEATER; - const color = getMarkerColor(contact.last_seen!); + const color = getMarkerColor(contact.last_seen); const displayName = contact.name || contact.public_key.slice(0, 12); + const lastHeardLabel = + contact.last_seen != null + ? formatTime(contact.last_seen) + : 'Never heard by this server'; return ( -
- Last heard: {formatTime(contact.last_seen!)} -
+
Last heard: {lastHeardLabel}
{contact.lat!.toFixed(5)}, {contact.lon!.toFixed(5)}
diff --git a/frontend/src/hooks/useConversationMessages.ts b/frontend/src/hooks/useConversationMessages.ts index e0ba482..00e21d9 100644 --- a/frontend/src/hooks/useConversationMessages.ts +++ b/frontend/src/hooks/useConversationMessages.ts @@ -232,14 +232,12 @@ export function useConversationMessages( if (latestReconcileRequestIdRef.current !== requestId) return; const dataWithPendingAck = data.map((msg) => applyPendingAck(msg)); + setHasOlderMessages(dataWithPendingAck.length >= MESSAGE_PAGE_SIZE); const merged = messageCache.reconcile(messagesRef.current, dataWithPendingAck); if (!merged) return; setMessages(merged); syncSeenContent(merged); - if (dataWithPendingAck.length >= MESSAGE_PAGE_SIZE) { - setHasOlderMessages(true); - } }) .catch((err) => { if (isAbortError(err)) return; diff --git a/frontend/src/test/mapView.test.tsx b/frontend/src/test/mapView.test.tsx new file mode 100644 index 0000000..88a7e6b --- /dev/null +++ b/frontend/src/test/mapView.test.tsx @@ -0,0 +1,55 @@ +import { forwardRef } from 'react'; +import { render, screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import { MapView } from '../components/MapView'; +import type { Contact } from '../types'; + +vi.mock('react-leaflet', () => ({ + MapContainer: ({ children }: { children: React.ReactNode }) =>
{children}
, + TileLayer: () => null, + CircleMarker: forwardRef< + HTMLDivElement, + { children: React.ReactNode; pathOptions?: { fillColor?: string } } + >(({ children, pathOptions }, ref) => ( +
+ {children} +
+ )), + Popup: ({ children }: { children: React.ReactNode }) =>
{children}
, + useMap: () => ({ + setView: vi.fn(), + fitBounds: vi.fn(), + }), +})); + +describe('MapView', () => { + it('renders a never-heard fallback for a focused contact without last_seen', () => { + const contact: Contact = { + public_key: 'aa'.repeat(32), + name: 'Mystery Node', + type: 1, + flags: 0, + last_path: null, + last_path_len: -1, + out_path_hash_mode: -1, + route_override_path: null, + route_override_len: null, + route_override_hash_mode: null, + last_advert: null, + lat: 40, + lon: -74, + last_seen: null, + on_radio: false, + last_contacted: null, + last_read_at: null, + first_seen: null, + }; + + render(); + + expect( + screen.getByText(/showing 1 contact heard in the last 7 days plus the focused contact/i) + ).toBeInTheDocument(); + expect(screen.getByText('Last heard: Never heard by this server')).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/test/useConversationMessages.race.test.ts b/frontend/src/test/useConversationMessages.race.test.ts index 38e3d44..4b89466 100644 --- a/frontend/src/test/useConversationMessages.race.test.ts +++ b/frontend/src/test/useConversationMessages.race.test.ts @@ -264,6 +264,29 @@ describe('useConversationMessages background reconcile ordering', () => { expect(result.current.messages[0].text).toBe('newer snapshot'); expect(result.current.messages[0].acked).toBe(2); }); + + it('clears stale hasOlderMessages when cached conversations reconcile to a short latest page', async () => { + const conv = createConversation(); + const cachedMessage = createMessage({ id: 42, text: 'cached snapshot' }); + + messageCache.set(conv.id, { + messages: [cachedMessage], + seenContent: new Set([ + `PRIV-${cachedMessage.conversation_key}-${cachedMessage.text}-${cachedMessage.sender_timestamp}`, + ]), + hasOlderMessages: true, + }); + + mockGetMessages.mockResolvedValueOnce([cachedMessage]); + + const { result } = renderHook(() => useConversationMessages(conv)); + + expect(result.current.messages).toHaveLength(1); + expect(result.current.hasOlderMessages).toBe(true); + + await waitFor(() => expect(mockGetMessages).toHaveBeenCalledTimes(1)); + await waitFor(() => expect(result.current.hasOlderMessages).toBe(false)); + }); }); describe('useConversationMessages forward pagination', () => {