From b1cd6e1aa95739c4c92b65704d7dc4984494710b Mon Sep 17 00:00:00 2001 From: Jack Kingsman Date: Thu, 16 Apr 2026 11:58:39 -0700 Subject: [PATCH] Add link to node from map display. Closes #189. --- frontend/src/App.tsx | 1 + frontend/src/components/ConversationPane.tsx | 19 +++++- frontend/src/components/MapView.tsx | 27 ++++++++- frontend/src/test/conversationPane.test.tsx | 1 + frontend/src/test/mapView.test.tsx | 64 +++++++++++++++++++- 5 files changed, 108 insertions(+), 4 deletions(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index c6a2ab9..8e277a7 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -588,6 +588,7 @@ export function App() { onDeleteChannel: handleDeleteChannel, onSetChannelFloodScopeOverride: handleSetChannelFloodScopeOverride, onSetChannelPathHashModeOverride: handleSetChannelPathHashModeOverride, + onSelectConversation: handleSelectConversationWithTargetReset, onOpenContactInfo: handleOpenContactInfo, onOpenChannelInfo: handleOpenChannelInfo, onSenderClick: handleSenderClick, diff --git a/frontend/src/components/ConversationPane.tsx b/frontend/src/components/ConversationPane.tsx index 0ca62d2..dc8a66d 100644 --- a/frontend/src/components/ConversationPane.tsx +++ b/frontend/src/components/ConversationPane.tsx @@ -20,7 +20,11 @@ import type { } from '../types'; import type { RawPacketStatsSessionState } from '../utils/rawPacketStats'; import { CONTACT_TYPE_REPEATER, CONTACT_TYPE_ROOM } from '../types'; -import { isPrefixOnlyContact, isUnknownFullKeyContact } from '../utils/pubkey'; +import { + getContactDisplayName, + isPrefixOnlyContact, + isUnknownFullKeyContact, +} from '../utils/pubkey'; const RepeaterDashboard = lazy(() => import('./RepeaterDashboard').then((m) => ({ default: m.RepeaterDashboard })) @@ -65,6 +69,7 @@ interface ConversationPaneProps { channelKey: string, pathHashModeOverride: number | null ) => Promise; + onSelectConversation: (conversation: Conversation) => void; onOpenContactInfo: (publicKey: string, fromChannel?: boolean) => void; onOpenChannelInfo: (channelKey: string) => void; onSenderClick: (sender: string) => void; @@ -137,6 +142,7 @@ export function ConversationPane({ onDeleteChannel, onSetChannelFloodScopeOverride, onSetChannelPathHashModeOverride, + onSelectConversation, onOpenContactInfo, onOpenChannelInfo, onSenderClick, @@ -197,6 +203,17 @@ export function ConversationPane({ focusedKey={activeConversation.mapFocusKey} rawPackets={rawPackets} config={config} + onSelectContact={(contact) => + onSelectConversation({ + type: 'contact', + id: contact.public_key, + name: getContactDisplayName( + contact.name, + contact.public_key, + contact.last_advert + ), + }) + } /> diff --git a/frontend/src/components/MapView.tsx b/frontend/src/components/MapView.tsx index 651f9a6..e7909e0 100644 --- a/frontend/src/components/MapView.tsx +++ b/frontend/src/components/MapView.tsx @@ -21,6 +21,9 @@ interface MapViewProps { focusedKey?: string | null; rawPackets?: RawPacket[]; config?: RadioConfig | null; + /** When provided, the contact name in each popup becomes a clickable link + * that opens the conversation for that contact (DM, repeater, or room). */ + onSelectContact?: (contact: Contact) => void; } // --- Tile layer presets --- @@ -379,7 +382,13 @@ function ParticleOverlay({ particles }: { particles: MapParticle[] }) { // --- Main component --- -export function MapView({ contacts, focusedKey, rawPackets, config }: MapViewProps) { +export function MapView({ + contacts, + focusedKey, + rawPackets, + config, + onSelectContact, +}: MapViewProps) { const [sevenDaysAgo] = useState(() => Date.now() / 1000 - 7 * 24 * 60 * 60); const [darkMap, setDarkMap] = useState(getSavedDarkMap); const tile = darkMap ? TILE_DARK : TILE_LIGHT; @@ -839,7 +848,21 @@ export function MapView({ contacts, focusedKey, rawPackets, config }: MapViewPro 🛜 )} - {displayName} + {onSelectContact ? ( + + ) : ( + displayName + )}
Last heard: {lastHeardLabel}
diff --git a/frontend/src/test/conversationPane.test.tsx b/frontend/src/test/conversationPane.test.tsx index 71858b4..84939b9 100644 --- a/frontend/src/test/conversationPane.test.tsx +++ b/frontend/src/test/conversationPane.test.tsx @@ -145,6 +145,7 @@ function createProps(overrides: Partial {}), onDeleteChannel: vi.fn(async () => {}), onSetChannelFloodScopeOverride: vi.fn(async () => {}), + onSelectConversation: vi.fn(), onOpenContactInfo: vi.fn(), onOpenChannelInfo: vi.fn(), onSenderClick: vi.fn(), diff --git a/frontend/src/test/mapView.test.tsx b/frontend/src/test/mapView.test.tsx index e9ed9f6..3cb2ed4 100644 --- a/frontend/src/test/mapView.test.tsx +++ b/frontend/src/test/mapView.test.tsx @@ -1,5 +1,5 @@ import { forwardRef } from 'react'; -import { render, screen } from '@testing-library/react'; +import { fireEvent, render, screen } from '@testing-library/react'; import { describe, expect, it, vi } from 'vitest'; import { MapView } from '../components/MapView'; import type { Contact } from '../types'; @@ -54,6 +54,68 @@ describe('MapView', () => { expect(screen.getByText('Last heard: Never heard by this server')).toBeInTheDocument(); }); + it('invokes onSelectContact when the popup name is clicked', () => { + const contact: Contact = { + public_key: 'cc'.repeat(32), + name: 'Clickable', + type: 1, + flags: 0, + direct_path: null, + direct_path_len: -1, + direct_path_hash_mode: -1, + route_override_path: null, + route_override_len: null, + route_override_hash_mode: null, + last_advert: null, + lat: 42, + lon: -72, + last_seen: Math.floor(Date.now() / 1000), + on_radio: false, + favorite: false, + last_contacted: null, + last_read_at: null, + first_seen: null, + }; + const onSelectContact = vi.fn(); + + render(); + + const link = screen.getByRole('button', { name: 'Clickable' }); + expect(link).toHaveAttribute('title', 'Open conversation with Clickable'); + fireEvent.click(link); + + expect(onSelectContact).toHaveBeenCalledWith(contact); + }); + + it('renders the popup name as plain text when no onSelectContact is provided', () => { + const contact: Contact = { + public_key: 'dd'.repeat(32), + name: 'Static', + type: 1, + flags: 0, + direct_path: null, + direct_path_len: -1, + direct_path_hash_mode: -1, + route_override_path: null, + route_override_len: null, + route_override_hash_mode: null, + last_advert: null, + lat: 42, + lon: -72, + last_seen: Math.floor(Date.now() / 1000), + on_radio: false, + favorite: false, + last_contacted: null, + last_read_at: null, + first_seen: null, + }; + + render(); + + expect(screen.queryByRole('button', { name: /open conversation with static/i })).toBeNull(); + expect(screen.getByText('Static')).toBeInTheDocument(); + }); + it('keeps the 7-day cutoff stable for the lifetime of the mounted map', () => { vi.useFakeTimers(); try {