Add link to node from map display. Closes #189.

This commit is contained in:
Jack Kingsman
2026-04-16 11:58:39 -07:00
parent 56fc589e0b
commit b1cd6e1aa9
5 changed files with 108 additions and 4 deletions

View File

@@ -588,6 +588,7 @@ export function App() {
onDeleteChannel: handleDeleteChannel,
onSetChannelFloodScopeOverride: handleSetChannelFloodScopeOverride,
onSetChannelPathHashModeOverride: handleSetChannelPathHashModeOverride,
onSelectConversation: handleSelectConversationWithTargetReset,
onOpenContactInfo: handleOpenContactInfo,
onOpenChannelInfo: handleOpenChannelInfo,
onSenderClick: handleSenderClick,

View File

@@ -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<void>;
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
),
})
}
/>
</Suspense>
</div>

View File

@@ -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
🛜
</span>
)}
{displayName}
{onSelectContact ? (
<button
type="button"
className="p-0 bg-transparent border-0 font-inherit text-primary underline hover:text-primary/80 cursor-pointer"
onClick={(event) => {
event.stopPropagation();
onSelectContact(contact);
}}
title={`Open conversation with ${displayName}`}
>
{displayName}
</button>
) : (
displayName
)}
</div>
<div className="text-xs text-gray-500 mt-1">Last heard: {lastHeardLabel}</div>
<div className="text-xs text-gray-400 mt-1 font-mono">

View File

@@ -145,6 +145,7 @@ function createProps(overrides: Partial<React.ComponentProps<typeof Conversation
onDeleteContact: vi.fn(async () => {}),
onDeleteChannel: vi.fn(async () => {}),
onSetChannelFloodScopeOverride: vi.fn(async () => {}),
onSelectConversation: vi.fn(),
onOpenContactInfo: vi.fn(),
onOpenChannelInfo: vi.fn(),
onSenderClick: vi.fn(),

View File

@@ -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(<MapView contacts={[contact]} onSelectContact={onSelectContact} />);
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(<MapView contacts={[contact]} />);
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 {