Add recently traced contacts as a sort category in the trace pane. Closes #286.

This commit is contained in:
Jack Kingsman
2026-06-11 21:55:51 -07:00
parent e1e0b48437
commit 01c4dd1df7
2 changed files with 250 additions and 21 deletions
+123 -20
View File
@@ -26,11 +26,14 @@ import {
import { Input } from './ui/input';
import { cn } from '@/lib/utils';
type TraceSortMode = 'alpha' | 'recent' | 'distance';
type TraceSortMode = 'alpha' | 'recent' | 'distance' | 'traced';
type CustomHopBytes = 1 | 2 | 4;
const RECENT_TRACES_KEY = 'remoteterm-recent-traces';
const MAX_RECENT_TRACES = 5;
const RECENT_NODES_KEY = 'remoteterm-recent-trace-nodes';
const MAX_RECENT_NODES = 30;
const MAX_RENDERED_REPEATERS = 60;
interface SavedTraceHop {
kind: 'repeater' | 'custom';
@@ -71,6 +74,57 @@ function saveRecentTrace(trace: SavedTrace): void {
}
}
function repeaterKeysFromHops(hops: SavedTraceHop[]): string[] {
return [
...new Set(
hops
.filter((hop) => hop.kind === 'repeater' && hop.publicKey)
.map((hop) => hop.publicKey as string)
),
];
}
function loadRecentNodeKeys(): string[] {
try {
const raw = localStorage.getItem(RECENT_NODES_KEY);
if (raw) {
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) return [];
return [
...new Set(
parsed
.map((entry) =>
typeof entry === 'string' ? entry : ((entry?.publicKey as string) ?? null)
)
.filter((key): key is string => typeof key === 'string' && key.length > 0)
),
].slice(0, MAX_RECENT_NODES);
}
// No usage history yet: seed from already-stored recent traces so the
// Recent Traced sort works immediately for users with existing history.
return repeaterKeysFromHops(loadRecentTraces().flatMap((trace) => trace.hops)).slice(
0,
MAX_RECENT_NODES
);
} catch {
return [];
}
}
function saveRecentNodeKeys(hops: SavedTraceHop[]): void {
try {
// MRU order: repeaters from this trace first, then prior history.
const fresh = repeaterKeysFromHops(hops);
const rest = loadRecentNodeKeys().filter((key) => !fresh.includes(key));
localStorage.setItem(
RECENT_NODES_KEY,
JSON.stringify([...fresh, ...rest].slice(0, MAX_RECENT_NODES))
);
} catch {
// localStorage may be disabled
}
}
type TraceDraftHop =
| { id: string; kind: 'repeater'; publicKey: string }
| { id: string; kind: 'custom'; hopHex: string; hopBytes: CustomHopBytes };
@@ -200,6 +254,7 @@ export function TracePane({ contacts, config, onRunTracePath }: TracePaneProps)
const [customHopError, setCustomHopError] = useState<string | null>(null);
const [recentTraces, setRecentTraces] = useState<SavedTrace[]>(loadRecentTraces);
const [recentTracesOpen, setRecentTracesOpen] = useState(false);
const [recentNodeKeys, setRecentNodeKeys] = useState<string[]>(loadRecentNodeKeys);
const activeRunTokenRef = useRef(0);
const repeaters = useMemo(() => {
@@ -220,9 +275,16 @@ export function TracePane({ contacts, config, onRunTracePath }: TracePaneProps)
[repeaters]
);
const tracedIndexByKey = useMemo(
() => new Map(recentNodeKeys.map((key, index) => [key, index])),
[recentNodeKeys]
);
const canSortByDistance = !!config && isValidLocation(config.lat, config.lon);
const filteredRepeaters = useMemo(() => {
const query = searchQuery.trim().toLowerCase();
const matching = query
let matching = query
? repeaters.filter(
(contact) =>
(contact.name ?? '').toLowerCase().includes(query) ||
@@ -230,7 +292,27 @@ export function TracePane({ contacts, config, onRunTracePath }: TracePaneProps)
)
: repeaters;
// Traced shows only repeaters actually used in traces; Dist. shows only
// repeaters with a computable distance (when the local radio has one).
if (sortMode === 'traced') {
matching = matching.filter((contact) => tracedIndexByKey.has(contact.public_key));
}
const distanceByKey =
sortMode === 'distance'
? new Map(matching.map((contact) => [contact.public_key, getDistanceKm(contact, config)]))
: null;
if (distanceByKey && canSortByDistance) {
matching = matching.filter((contact) => distanceByKey.get(contact.public_key) !== null);
}
return [...matching].sort((left, right) => {
if (sortMode === 'traced') {
const leftIndex = tracedIndexByKey.get(left.public_key) ?? Infinity;
const rightIndex = tracedIndexByKey.get(right.public_key) ?? Infinity;
if (leftIndex !== rightIndex) {
return leftIndex - rightIndex;
}
}
if (sortMode === 'recent') {
const leftTs = getHeardTimestamp(left);
const rightTs = getHeardTimestamp(right);
@@ -238,9 +320,9 @@ export function TracePane({ contacts, config, onRunTracePath }: TracePaneProps)
return rightTs - leftTs;
}
}
if (sortMode === 'distance') {
const leftDistance = getDistanceKm(left, config);
const rightDistance = getDistanceKm(right, config);
if (distanceByKey) {
const leftDistance = distanceByKey.get(left.public_key) ?? null;
const rightDistance = distanceByKey.get(right.public_key) ?? null;
if (leftDistance !== null && rightDistance !== null && leftDistance !== rightDistance) {
return leftDistance - rightDistance;
}
@@ -251,11 +333,15 @@ export function TracePane({ contacts, config, onRunTracePath }: TracePaneProps)
getContactDisplayName(right.name, right.public_key, right.last_advert)
);
});
}, [config, repeaters, searchQuery, sortMode]);
}, [canSortByDistance, config, repeaters, searchQuery, sortMode, tracedIndexByKey]);
const visibleRepeaters = useMemo(
() => filteredRepeaters.slice(0, MAX_RENDERED_REPEATERS),
[filteredRepeaters]
);
const localRadioName = config?.name || 'Local radio';
const localRadioKey = config?.public_key ?? null;
const canSortByDistance = !!config && isValidLocation(config.lat, config.lon);
const customHopBytesLocked = useMemo(
() => draftHops.find((hop) => hop.kind === 'custom')?.hopBytes ?? null,
[draftHops]
@@ -338,6 +424,13 @@ export function TracePane({ contacts, config, onRunTracePath }: TracePaneProps)
clearPendingResult();
};
const recordTraceRun = (hops: SavedTraceHop[]) => {
saveRecentTrace({ hops, ranAt: Date.now() });
setRecentTraces(loadRecentTraces());
saveRecentNodeKeys(hops);
setRecentNodeKeys(loadRecentNodeKeys());
};
const handleLoadRecentTrace = async (trace: SavedTrace) => {
const hops: TraceDraftHop[] = trace.hops.map((h, i) => {
if (h.kind === 'repeater' && h.publicKey) {
@@ -376,10 +469,8 @@ export function TracePane({ contacts, config, onRunTracePath }: TracePaneProps)
if (activeRunTokenRef.current !== runToken) return;
setResult(traceResult);
// Re-save to bump this trace to the top of recents
const savedTrace: SavedTrace = { hops: trace.hops, ranAt: Date.now() };
saveRecentTrace(savedTrace);
setRecentTraces(loadRecentTraces());
// Re-save to bump this trace and its nodes to the top of recents
recordTraceRun(trace.hops);
} catch (err) {
if (activeRunTokenRef.current !== runToken) return;
setError(err instanceof Error ? err.message : 'Unknown error');
@@ -426,9 +517,7 @@ export function TracePane({ contacts, config, onRunTracePath }: TracePaneProps)
displayName: `${hop.hopHex.toUpperCase()} (${hop.hopBytes}B)`,
};
});
const trace: SavedTrace = { hops: savedHops, ranAt: Date.now() };
saveRecentTrace(trace);
setRecentTraces(loadRecentTraces());
recordTraceRun(savedHops);
} catch (err) {
if (activeRunTokenRef.current !== runToken) {
return;
@@ -490,16 +579,18 @@ export function TracePane({ contacts, config, onRunTracePath }: TracePaneProps)
<div className="mt-3 flex flex-wrap gap-2">
{(
[
['alpha', 'Alpha'],
['recent', 'Recent Heard'],
['distance', 'Distance'],
['alpha', 'A/Z', 'Sort alphabetically'],
['recent', 'Heard', 'Most recently heard first'],
['traced', 'Traced', 'Most recently used in traces first'],
['distance', 'Dist.', 'Closest first'],
] as const
).map(([value, label]) => (
).map(([value, label, description]) => (
<Button
key={value}
type="button"
size="sm"
variant={sortMode === value ? 'default' : 'outline'}
title={description}
onClick={() => setSortMode(value)}
>
{label}
@@ -517,11 +608,17 @@ export function TracePane({ contacts, config, onRunTracePath }: TracePaneProps)
<div className="max-h-[40vh] overflow-y-auto p-2 lg:min-h-0 lg:max-h-none lg:flex-1">
{filteredRepeaters.length === 0 ? (
<div className="rounded-md border border-dashed border-border px-3 py-6 text-center text-sm text-muted-foreground">
No repeaters matched this search.
{sortMode === 'traced' && recentNodeKeys.length === 0
? 'No repeaters have been used in traces yet. Run a trace and its repeaters will show up here.'
: sortMode === 'traced'
? 'No known repeaters match your recent trace history.'
: sortMode === 'distance' && canSortByDistance
? 'No repeaters with a known distance matched this search.'
: 'No repeaters matched this search.'}
</div>
) : (
<div className="space-y-2">
{filteredRepeaters.map((contact) => {
{visibleRepeaters.map((contact) => {
const displayName = getContactDisplayName(
contact.name,
contact.public_key,
@@ -577,6 +674,12 @@ export function TracePane({ contacts, config, onRunTracePath }: TracePaneProps)
</div>
);
})}
{filteredRepeaters.length > MAX_RENDERED_REPEATERS ? (
<p className="px-1 pt-1 text-center text-[0.6875rem] text-muted-foreground">
Showing the first {MAX_RENDERED_REPEATERS} of {filteredRepeaters.length}{' '}
repeaters. Search to narrow the list.
</p>
) : null}
</div>
)}
</div>
+127 -1
View File
@@ -1,5 +1,5 @@
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { TracePane } from '../components/TracePane';
import type { Contact, RadioConfig, RadioTraceResponse } from '../types';
@@ -45,6 +45,10 @@ const config: RadioConfig = {
};
describe('TracePane', () => {
beforeEach(() => {
localStorage.clear();
});
it('shows only full-key repeaters and filters by name or key', () => {
render(
<TracePane
@@ -247,6 +251,128 @@ describe('TracePane', () => {
expect(screen.getByText(/custom hops are locked to 1-byte prefixes/i)).toBeInTheDocument();
});
it('Traced lists only trace-used repeaters in MRU order, persisted locally (issue #286)', async () => {
const relayA = makeContact('11'.repeat(32), 'Relay Alpha');
const relayB = makeContact('22'.repeat(32), 'Relay Beta');
const relayC = makeContact('33'.repeat(32), 'Relay Charlie');
const onRunTracePath = vi.fn(
async (): Promise<RadioTraceResponse> => ({
path_len: 0,
timeout_seconds: 6,
nodes: [],
})
);
const { unmount } = render(
<TracePane
config={config}
onRunTracePath={onRunTracePath}
contacts={[relayA, relayB, relayC]}
/>
);
const rowNames = () =>
screen
.queryAllByRole('button', { name: /^add repeater/i })
.map((row) => row.getAttribute('aria-label'));
// No history yet: Traced shows an explanatory empty state, not the full list.
fireEvent.click(screen.getByRole('button', { name: 'Traced' }));
expect(screen.getByText(/no repeaters have been used in traces yet/i)).toBeInTheDocument();
expect(rowNames()).toEqual([]);
// Build and run a trace with B from the A/Z list.
fireEvent.click(screen.getByRole('button', { name: 'A/Z' }));
fireEvent.click(screen.getByRole('button', { name: /^add repeater relay beta/i }));
fireEvent.click(screen.getByRole('button', { name: /send trace/i }));
await waitFor(() => expect(onRunTracePath).toHaveBeenCalledTimes(1));
// Traced lists only B; untraced A and C are filtered out.
fireEvent.click(screen.getByRole('button', { name: 'Traced' }));
expect(rowNames()).toEqual(['Add repeater Relay Beta']);
// A second trace with C bumps it above B.
fireEvent.click(screen.getByRole('button', { name: 'A/Z' }));
fireEvent.click(screen.getByRole('button', { name: /remove relay beta/i }));
fireEvent.click(screen.getByRole('button', { name: /^add repeater relay charlie/i }));
fireEvent.click(screen.getByRole('button', { name: /send trace/i }));
await waitFor(() => expect(onRunTracePath).toHaveBeenCalledTimes(2));
fireEvent.click(screen.getByRole('button', { name: 'Traced' }));
expect(rowNames()).toEqual(['Add repeater Relay Charlie', 'Add repeater Relay Beta']);
// Order persists across remounts via localStorage.
unmount();
render(
<TracePane
config={config}
onRunTracePath={onRunTracePath}
contacts={[relayA, relayB, relayC]}
/>
);
fireEvent.click(screen.getByRole('button', { name: 'Traced' }));
expect(rowNames()).toEqual(['Add repeater Relay Charlie', 'Add repeater Relay Beta']);
});
it('seeds Traced from stored recent traces when no usage history exists', () => {
const relayA = makeContact('11'.repeat(32), 'Relay Alpha');
const relayB = makeContact('22'.repeat(32), 'Relay Beta');
localStorage.setItem(
'remoteterm-recent-traces',
JSON.stringify([
{
ranAt: 1,
hops: [
{ kind: 'repeater', publicKey: relayB.public_key, displayName: 'Relay Beta' },
{ kind: 'custom', hopHex: 'ae', hopBytes: 1, displayName: 'AE (1B)' },
],
},
])
);
render(<TracePane config={config} onRunTracePath={vi.fn()} contacts={[relayA, relayB]} />);
fireEvent.click(screen.getByRole('button', { name: 'Traced' }));
expect(
screen
.getAllByRole('button', { name: /^add repeater/i })
.map((row) => row.getAttribute('aria-label'))
).toEqual(['Add repeater Relay Beta']);
});
it('Dist. hides repeaters without a known distance when the radio has a location', () => {
const located = makeContact('11'.repeat(32), 'Relay Located', CONTACT_TYPE_REPEATER, {
lat: 10.1,
lon: 20.1,
});
const unlocated = makeContact('22'.repeat(32), 'Relay Mystery');
render(<TracePane config={config} onRunTracePath={vi.fn()} contacts={[located, unlocated]} />);
// A/Z shows both.
expect(screen.getByText('Relay Located')).toBeInTheDocument();
expect(screen.getByText('Relay Mystery')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Dist.' }));
expect(screen.getByText('Relay Located')).toBeInTheDocument();
expect(screen.queryByText('Relay Mystery')).not.toBeInTheDocument();
// Without a local radio location, the filter is skipped (note explains instead).
fireEvent.change(screen.getByLabelText('Search repeaters'), { target: { value: 'mystery' } });
expect(screen.getByText(/no repeaters with a known distance matched/i)).toBeInTheDocument();
});
it('caps the rendered repeater list and reports the overflow', () => {
const contacts = Array.from({ length: 70 }, (_, i) =>
makeContact(i.toString(16).padStart(2, '0').repeat(32), `Relay ${String(i).padStart(3, '0')}`)
);
render(<TracePane config={config} onRunTracePath={vi.fn()} contacts={contacts} />);
expect(screen.getAllByRole('button', { name: /^add repeater/i })).toHaveLength(60);
expect(screen.getByText(/showing the first 60 of 70 repeaters/i)).toBeInTheDocument();
});
it('drops an in-flight result after the draft path changes', async () => {
const relayA = makeContact('11'.repeat(32), 'Relay Alpha');
const relayB = makeContact('22'.repeat(32), 'Relay Beta');