Add route discovery

This commit is contained in:
Jack Kingsman
2026-03-13 17:53:23 -07:00
parent 3a4ea8022b
commit 1299a301c1
16 changed files with 678 additions and 10 deletions
+3
View File
@@ -331,11 +331,13 @@ export function App() {
handleSetChannelFloodScopeOverride,
handleSenderClick,
handleTrace,
handlePathDiscovery,
handleBlockKey,
handleBlockName,
} = useConversationActions({
activeConversation,
activeConversationRef,
setContacts,
setChannels,
addMessageIfNew,
jumpToBottom,
@@ -407,6 +409,7 @@ export function App() {
loadingNewer,
messageInputRef,
onTrace: handleTrace,
onPathDiscovery: handlePathDiscovery,
onToggleFavorite: handleToggleFavorite,
onDeleteContact: handleDeleteContact,
onDeleteChannel: handleDeleteChannel,
+5
View File
@@ -19,6 +19,7 @@ import type {
RadioConfigUpdate,
RadioDiscoveryResponse,
RadioDiscoveryTarget,
PathDiscoveryResponse,
RepeaterAclResponse,
RepeaterAdvertIntervalsResponse,
RepeaterLoginResponse,
@@ -153,6 +154,10 @@ export const api = {
fetchJson<TraceResponse>(`/contacts/${publicKey}/trace`, {
method: 'POST',
}),
requestPathDiscovery: (publicKey: string) =>
fetchJson<PathDiscoveryResponse>(`/contacts/${publicKey}/path-discovery`, {
method: 'POST',
}),
setContactRoutingOverride: (publicKey: string, route: string) =>
fetchJson<{ status: string; public_key: string }>(`/contacts/${publicKey}/routing-override`, {
method: 'POST',
+40 -3
View File
@@ -1,14 +1,22 @@
import { useEffect, useRef, useState } from 'react';
import { Bell, Globe2, Info, Star, Trash2 } from 'lucide-react';
import { Bell, Globe2, Info, Route, Star, Trash2 } from 'lucide-react';
import { toast } from './ui/sonner';
import { DirectTraceIcon } from './DirectTraceIcon';
import { ContactPathDiscoveryModal } from './ContactPathDiscoveryModal';
import { isFavorite } from '../utils/favorites';
import { handleKeyboardActivate } from '../utils/a11y';
import { stripRegionScopePrefix } from '../utils/regionScope';
import { isPrefixOnlyContact } from '../utils/pubkey';
import { ContactAvatar } from './ContactAvatar';
import { ContactStatusInfo } from './ContactStatusInfo';
import type { Channel, Contact, Conversation, Favorite, RadioConfig } from '../types';
import type {
Channel,
Contact,
Conversation,
Favorite,
PathDiscoveryResponse,
RadioConfig,
} from '../types';
interface ChatHeaderProps {
conversation: Conversation;
@@ -20,6 +28,7 @@ interface ChatHeaderProps {
notificationsEnabled: boolean;
notificationsPermission: NotificationPermission | 'unsupported';
onTrace: () => void;
onPathDiscovery: (publicKey: string) => Promise<PathDiscoveryResponse>;
onToggleNotifications: () => void;
onToggleFavorite: (type: 'channel' | 'contact', id: string) => void;
onSetChannelFloodScopeOverride?: (key: string, floodScopeOverride: string) => void;
@@ -39,6 +48,7 @@ export function ChatHeader({
notificationsEnabled,
notificationsPermission,
onTrace,
onPathDiscovery,
onToggleNotifications,
onToggleFavorite,
onSetChannelFloodScopeOverride,
@@ -49,10 +59,12 @@ export function ChatHeader({
}: ChatHeaderProps) {
const [showKey, setShowKey] = useState(false);
const [contactStatusInline, setContactStatusInline] = useState(true);
const [pathDiscoveryOpen, setPathDiscoveryOpen] = useState(false);
const keyTextRef = useRef<HTMLSpanElement | null>(null);
useEffect(() => {
setShowKey(false);
setPathDiscoveryOpen(false);
}, [conversation.id]);
const activeChannel =
@@ -272,6 +284,21 @@ export function ChatHeader({
</span>
</span>
<div className="flex items-center justify-end gap-0.5 flex-shrink-0">
{conversation.type === 'contact' && (
<button
className="p-1 rounded hover:bg-accent text-lg leading-none transition-colors disabled:cursor-not-allowed disabled:opacity-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
onClick={() => setPathDiscoveryOpen(true)}
title={
activeContactIsPrefixOnly
? 'Path Discovery unavailable until the full contact key is known'
: 'Path Discovery. Send a routed probe and inspect the forward and return paths'
}
aria-label="Path Discovery"
disabled={activeContactIsPrefixOnly}
>
<Route className="h-4 w-4 text-muted-foreground" aria-hidden="true" />
</button>
)}
{conversation.type === 'contact' && (
<button
className="p-1 rounded hover:bg-accent text-lg leading-none transition-colors disabled:cursor-not-allowed disabled:opacity-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
@@ -279,7 +306,7 @@ export function ChatHeader({
title={
activeContactIsPrefixOnly
? 'Direct Trace unavailable until the full contact key is known'
: 'Direct Trace'
: 'Direct Trace. Send a zero-hop packet to thie contact and display out and back SNR'
}
aria-label="Direct Trace"
disabled={activeContactIsPrefixOnly}
@@ -371,6 +398,16 @@ export function ChatHeader({
</button>
)}
</div>
{conversation.type === 'contact' && activeContact && (
<ContactPathDiscoveryModal
open={pathDiscoveryOpen}
onClose={() => setPathDiscoveryOpen(false)}
contact={activeContact}
contacts={contacts}
radioName={config?.name ?? null}
onDiscover={onPathDiscovery}
/>
)}
</header>
);
}
@@ -0,0 +1,213 @@
import { useMemo, useState } from 'react';
import type { Contact, PathDiscoveryResponse, PathDiscoveryRoute } from '../types';
import {
findContactsByPrefix,
formatRouteLabel,
getEffectiveContactRoute,
hasRoutingOverride,
parsePathHops,
} from '../utils/pathUtils';
import { Button } from './ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from './ui/dialog';
interface ContactPathDiscoveryModalProps {
open: boolean;
onClose: () => void;
contact: Contact;
contacts: Contact[];
radioName: string | null;
onDiscover: (publicKey: string) => Promise<PathDiscoveryResponse>;
}
function formatPathHashMode(mode: number): string {
if (mode === 0) return '1-byte hops';
if (mode === 1) return '2-byte hops';
if (mode === 2) return '3-byte hops';
return 'Unknown hop width';
}
function renderRouteNodes(
route: PathDiscoveryRoute,
startLabel: string,
endLabel: string,
contacts: Contact[]
): string {
if (route.path_len <= 0 || !route.path) {
return `${startLabel} -> ${endLabel}`;
}
const hops = parsePathHops(route.path, route.path_len).map((prefix) => {
const matches = findContactsByPrefix(prefix, contacts, true);
if (matches.length === 1) {
return matches[0].name || `${matches[0].public_key.slice(0, prefix.length)}`;
}
if (matches.length > 1) {
return `${prefix}…?`;
}
return `${prefix}`;
});
return [startLabel, ...hops, endLabel].join(' -> ');
}
function RouteCard({
label,
route,
chain,
}: {
label: string;
route: PathDiscoveryRoute;
chain: string;
}) {
const rawPath = parsePathHops(route.path, route.path_len).join(' -> ') || 'direct';
return (
<div className="rounded-md border border-border bg-muted/20 p-3">
<div className="flex items-center justify-between gap-3">
<h4 className="text-sm font-semibold">{label}</h4>
<span className="text-[11px] text-muted-foreground">
{formatRouteLabel(route.path_len, true)}
</span>
</div>
<p className="mt-2 text-sm">{chain}</p>
<div className="mt-2 flex flex-wrap gap-x-3 gap-y-1 text-[11px] text-muted-foreground">
<span>Raw: {rawPath}</span>
<span>{formatPathHashMode(route.path_hash_mode)}</span>
</div>
</div>
);
}
export function ContactPathDiscoveryModal({
open,
onClose,
contact,
contacts,
radioName,
onDiscover,
}: ContactPathDiscoveryModalProps) {
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [result, setResult] = useState<PathDiscoveryResponse | null>(null);
const effectiveRoute = useMemo(() => getEffectiveContactRoute(contact), [contact]);
const hasForcedRoute = hasRoutingOverride(contact);
const learnedRouteSummary = useMemo(() => {
if (contact.last_path_len === -1) {
return 'Flood';
}
const hops = parsePathHops(contact.last_path, contact.last_path_len);
return hops.length > 0
? `${formatRouteLabel(contact.last_path_len, true)} (${hops.join(' -> ')})`
: formatRouteLabel(contact.last_path_len, true);
}, [contact.last_path, contact.last_path_len]);
const forcedRouteSummary = useMemo(() => {
if (!hasForcedRoute) {
return null;
}
if (effectiveRoute.pathLen === -1) {
return 'Flood';
}
const hops = parsePathHops(effectiveRoute.path, effectiveRoute.pathLen);
return hops.length > 0
? `${formatRouteLabel(effectiveRoute.pathLen, true)} (${hops.join(' -> ')})`
: formatRouteLabel(effectiveRoute.pathLen, true);
}, [effectiveRoute, hasForcedRoute]);
const forwardChain = result
? renderRouteNodes(
result.forward_path,
radioName || 'Local radio',
contact.name || contact.public_key.slice(0, 12),
contacts
)
: null;
const returnChain = result
? renderRouteNodes(
result.return_path,
contact.name || contact.public_key.slice(0, 12),
radioName || 'Local radio',
contacts
)
: null;
const handleDiscover = async () => {
setLoading(true);
setError(null);
try {
const discovered = await onDiscover(contact.public_key);
setResult(discovered);
} catch (err) {
setError(err instanceof Error ? err.message : 'Unknown error');
} finally {
setLoading(false);
}
};
return (
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && onClose()}>
<DialogContent className="sm:max-w-[560px]">
<DialogHeader>
<DialogTitle>Path Discovery</DialogTitle>
<DialogDescription>
Send a routed probe to this contact and wait for the round-trip path response. The
learned forward route will be saved back onto the contact if a response comes back.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="rounded-md border border-border bg-muted/20 p-3 text-sm">
<div className="font-medium">{contact.name || contact.public_key.slice(0, 12)}</div>
<div className="mt-1 text-muted-foreground">
Current learned route: {learnedRouteSummary}
</div>
{forcedRouteSummary && (
<div className="mt-1 text-destructive">
Current forced route: {forcedRouteSummary}
</div>
)}
</div>
{hasForcedRoute && (
<div className="rounded-md border border-warning/30 bg-warning/10 px-3 py-2 text-sm text-warning">
A forced route override is currently set for this contact. Path discovery will update
the learned route data, but it will not replace the forced path. Clearing the forced
route afterward is enough to make the newly discovered learned path take effect. You
only need to rerun path discovery if you want a fresher route sample.
</div>
)}
{error && (
<div className="rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
{error}
</div>
)}
{result && forwardChain && returnChain && (
<div className="space-y-3">
<RouteCard label="Forward Path" route={result.forward_path} chain={forwardChain} />
<RouteCard label="Return Path" route={result.return_path} chain={returnChain} />
</div>
)}
</div>
<DialogFooter className="gap-2 sm:justify-between">
<Button variant="secondary" onClick={onClose}>
Close
</Button>
<Button onClick={handleDiscover} disabled={loading}>
{loading ? 'Running...' : 'Run path discovery'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -11,6 +11,7 @@ import type {
Favorite,
HealthStatus,
Message,
PathDiscoveryResponse,
RawPacket,
RadioConfig,
} from '../types';
@@ -46,6 +47,7 @@ interface ConversationPaneProps {
loadingNewer: boolean;
messageInputRef: Ref<MessageInputHandle>;
onTrace: () => Promise<void>;
onPathDiscovery: (publicKey: string) => Promise<PathDiscoveryResponse>;
onToggleFavorite: (type: 'channel' | 'contact', id: string) => Promise<void>;
onDeleteContact: (publicKey: string) => Promise<void>;
onDeleteChannel: (key: string) => Promise<void>;
@@ -109,6 +111,7 @@ export function ConversationPane({
loadingNewer,
messageInputRef,
onTrace,
onPathDiscovery,
onToggleFavorite,
onDeleteContact,
onDeleteChannel,
@@ -205,6 +208,7 @@ export function ConversationPane({
radioLon={config?.lon ?? null}
radioName={config?.name ?? null}
onTrace={onTrace}
onPathDiscovery={onPathDiscovery}
onToggleNotifications={onToggleNotifications}
onToggleFavorite={onToggleFavorite}
onDeleteContact={onDeleteContact}
@@ -225,6 +229,7 @@ export function ConversationPane({
notificationsEnabled={notificationsEnabled}
notificationsPermission={notificationsPermission}
onTrace={onTrace}
onPathDiscovery={onPathDiscovery}
onToggleNotifications={onToggleNotifications}
onToggleFavorite={onToggleFavorite}
onSetChannelFloodScopeOverride={onSetChannelFloodScopeOverride}
+29 -3
View File
@@ -1,13 +1,15 @@
import { useState } from 'react';
import { toast } from './ui/sonner';
import { Button } from './ui/button';
import { Bell, Star, Trash2 } from 'lucide-react';
import { Bell, Route, Star, Trash2 } from 'lucide-react';
import { DirectTraceIcon } from './DirectTraceIcon';
import { RepeaterLogin } from './RepeaterLogin';
import { useRepeaterDashboard } from '../hooks/useRepeaterDashboard';
import { isFavorite } from '../utils/favorites';
import { handleKeyboardActivate } from '../utils/a11y';
import { ContactStatusInfo } from './ContactStatusInfo';
import type { Contact, Conversation, Favorite } from '../types';
import type { Contact, Conversation, Favorite, PathDiscoveryResponse } from '../types';
import { TelemetryPane } from './repeater/RepeaterTelemetryPane';
import { NeighborsPane } from './repeater/RepeaterNeighborsPane';
import { AclPane } from './repeater/RepeaterAclPane';
@@ -17,6 +19,7 @@ import { LppTelemetryPane } from './repeater/RepeaterLppTelemetryPane';
import { OwnerInfoPane } from './repeater/RepeaterOwnerInfoPane';
import { ActionsPane } from './repeater/RepeaterActionsPane';
import { ConsolePane } from './repeater/RepeaterConsolePane';
import { ContactPathDiscoveryModal } from './ContactPathDiscoveryModal';
// Re-export for backwards compatibility (used by repeaterFormatters.test.ts)
export { formatDuration, formatClockDrift } from './repeater/repeaterPaneShared';
@@ -34,6 +37,7 @@ interface RepeaterDashboardProps {
radioLon: number | null;
radioName: string | null;
onTrace: () => void;
onPathDiscovery: (publicKey: string) => Promise<PathDiscoveryResponse>;
onToggleNotifications: () => void;
onToggleFavorite: (type: 'channel' | 'contact', id: string) => void;
onDeleteContact: (publicKey: string) => void;
@@ -48,12 +52,14 @@ export function RepeaterDashboard({
notificationsPermission,
radioLat,
radioLon,
radioName: _radioName,
radioName,
onTrace,
onPathDiscovery,
onToggleNotifications,
onToggleFavorite,
onDeleteContact,
}: RepeaterDashboardProps) {
const [pathDiscoveryOpen, setPathDiscoveryOpen] = useState(false);
const {
loggedIn,
loginLoading,
@@ -122,6 +128,16 @@ export function RepeaterDashboard({
{anyLoading ? 'Loading...' : 'Load All'}
</Button>
)}
{contact && (
<button
className="p-1 rounded hover:bg-accent text-lg leading-none transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
onClick={() => setPathDiscoveryOpen(true)}
title="Path Discovery. Send a routed probe and inspect the forward and return paths"
aria-label="Path Discovery"
>
<Route className="h-4 w-4 text-muted-foreground" aria-hidden="true" />
</button>
)}
<button
className="p-1 rounded hover:bg-accent text-lg leading-none transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
onClick={onTrace}
@@ -184,6 +200,16 @@ export function RepeaterDashboard({
<Trash2 className="h-4 w-4" aria-hidden="true" />
</button>
</div>
{contact && (
<ContactPathDiscoveryModal
open={pathDiscoveryOpen}
onClose={() => setPathDiscoveryOpen(false)}
contact={contact}
contacts={contacts}
radioName={radioName}
onDiscover={onPathDiscovery}
/>
)}
</header>
{/* Body */}
+15 -1
View File
@@ -3,11 +3,13 @@ import { api } from '../api';
import * as messageCache from '../messageCache';
import { toast } from '../components/ui/sonner';
import type { MessageInputHandle } from '../components/MessageInput';
import type { Channel, Conversation, Message } from '../types';
import type { Channel, Contact, Conversation, Message, PathDiscoveryResponse } from '../types';
import { mergeContactIntoList } from '../utils/contactMerge';
interface UseConversationActionsArgs {
activeConversation: Conversation | null;
activeConversationRef: MutableRefObject<Conversation | null>;
setContacts: React.Dispatch<React.SetStateAction<Contact[]>>;
setChannels: React.Dispatch<React.SetStateAction<Channel[]>>;
addMessageIfNew: (msg: Message) => boolean;
jumpToBottom: () => void;
@@ -25,6 +27,7 @@ interface UseConversationActionsResult {
) => Promise<void>;
handleSenderClick: (sender: string) => void;
handleTrace: () => Promise<void>;
handlePathDiscovery: (publicKey: string) => Promise<PathDiscoveryResponse>;
handleBlockKey: (key: string) => Promise<void>;
handleBlockName: (name: string) => Promise<void>;
}
@@ -32,6 +35,7 @@ interface UseConversationActionsResult {
export function useConversationActions({
activeConversation,
activeConversationRef,
setContacts,
setChannels,
addMessageIfNew,
jumpToBottom,
@@ -126,6 +130,15 @@ export function useConversationActions({
}
}, [activeConversation]);
const handlePathDiscovery = useCallback(
async (publicKey: string) => {
const result = await api.requestPathDiscovery(publicKey);
setContacts((prev) => mergeContactIntoList(prev, result.contact));
return result;
},
[setContacts]
);
const handleBlockKey = useCallback(
async (key: string) => {
await handleToggleBlockedKey(key);
@@ -150,6 +163,7 @@ export function useConversationActions({
handleSetChannelFloodScopeOverride,
handleSenderClick,
handleTrace,
handlePathDiscovery,
handleBlockKey,
handleBlockName,
};
+19
View File
@@ -200,6 +200,25 @@ describe('fetchJson (via api methods)', () => {
expect.objectContaining({ 'Content-Type': 'application/json' })
);
});
it('omits Content-Type on POST requests without a body', async () => {
installMockFetch();
mockFetch.mockResolvedValueOnce({
ok: true,
json: () =>
Promise.resolve({
contact: null,
forward_path: { path: '', path_len: 0, path_hash_mode: 0 },
return_path: { path: '', path_len: 0, path_hash_mode: 0 },
}),
});
await api.requestPathDiscovery('aa'.repeat(32));
const [, options] = mockFetch.mock.calls[0];
expect(options.method).toBe('POST');
expect(options.headers).not.toHaveProperty('Content-Type');
});
});
describe('HTTP methods and body', () => {
@@ -1,8 +1,8 @@
import { fireEvent, render, screen, within } from '@testing-library/react';
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import { ChatHeader } from '../components/ChatHeader';
import type { Channel, Conversation, Favorite } from '../types';
import type { Channel, Contact, Conversation, Favorite, PathDiscoveryResponse } from '../types';
function makeChannel(key: string, name: string, isHashtag: boolean): Channel {
return { key, name, is_hashtag: isHashtag, on_radio: false, last_read_at: null };
@@ -18,6 +18,9 @@ const baseProps = {
notificationsEnabled: false,
notificationsPermission: 'granted' as const,
onTrace: noop,
onPathDiscovery: vi.fn(async () => {
throw new Error('unused');
}) as (_: string) => Promise<PathDiscoveryResponse>,
onToggleNotifications: noop,
onToggleFavorite: noop,
onSetChannelFloodScopeOverride: noop,
@@ -166,6 +169,89 @@ describe('ChatHeader key visibility', () => {
expect(onToggleNotifications).toHaveBeenCalledTimes(1);
});
it('opens path discovery modal for contacts and runs the request on demand', async () => {
const pubKey = '21'.repeat(32);
const contact: Contact = {
public_key: pubKey,
name: 'Alice',
type: 1,
flags: 0,
last_path: 'AA',
last_path_len: 1,
out_path_hash_mode: 0,
last_advert: null,
lat: null,
lon: null,
last_seen: null,
on_radio: false,
last_contacted: null,
last_read_at: null,
first_seen: null,
};
const conversation: Conversation = { type: 'contact', id: pubKey, name: 'Alice' };
const onPathDiscovery = vi.fn().mockResolvedValue({
contact,
forward_path: { path: 'AA', path_len: 1, path_hash_mode: 0 },
return_path: { path: '', path_len: 0, path_hash_mode: 0 },
} satisfies PathDiscoveryResponse);
render(
<ChatHeader
{...baseProps}
conversation={conversation}
channels={[]}
contacts={[contact]}
onPathDiscovery={onPathDiscovery}
/>
);
fireEvent.click(screen.getByRole('button', { name: 'Path Discovery' }));
expect(await screen.findByRole('dialog')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Run path discovery' }));
await waitFor(() => {
expect(onPathDiscovery).toHaveBeenCalledWith(pubKey);
});
});
it('shows an override warning in the path discovery modal when forced routing is set', async () => {
const pubKey = '31'.repeat(32);
const contact: Contact = {
public_key: pubKey,
name: 'Alice',
type: 1,
flags: 0,
last_path: 'AA',
last_path_len: 1,
out_path_hash_mode: 0,
route_override_path: 'BBDD',
route_override_len: 2,
route_override_hash_mode: 0,
last_advert: null,
lat: null,
lon: null,
last_seen: null,
on_radio: false,
last_contacted: null,
last_read_at: null,
first_seen: null,
};
const conversation: Conversation = { type: 'contact', id: pubKey, name: 'Alice' };
render(
<ChatHeader {...baseProps} conversation={conversation} channels={[]} contacts={[contact]} />
);
fireEvent.click(screen.getByRole('button', { name: 'Path Discovery' }));
expect(await screen.findByRole('dialog')).toBeInTheDocument();
expect(screen.getByText(/current learned route: 1 hop \(AA\)/i)).toBeInTheDocument();
expect(screen.getByText(/current forced route: 2 hops \(BB -> DD\)/i)).toBeInTheDocument();
expect(screen.getByText(/forced route override is currently set/i)).toBeInTheDocument();
expect(screen.getByText(/clearing the forced route afterward is enough/i)).toBeInTheDocument();
});
it('prompts for regional override when globe button is clicked', () => {
const key = 'CD'.repeat(16);
const channel = makeChannel(key, '#flightless', true);
@@ -117,6 +117,9 @@ function createProps(overrides: Partial<React.ComponentProps<typeof Conversation
loadingNewer: false,
messageInputRef: { current: null },
onTrace: vi.fn(async () => {}),
onPathDiscovery: vi.fn(async () => {
throw new Error('unused');
}),
onToggleFavorite: vi.fn(async () => {}),
onDeleteContact: vi.fn(async () => {}),
onDeleteChannel: vi.fn(async () => {}),
@@ -109,6 +109,9 @@ const defaultProps = {
radioLon: null,
radioName: null,
onTrace: vi.fn(),
onPathDiscovery: vi.fn(async () => {
throw new Error('unused');
}),
onToggleNotifications: vi.fn(),
onToggleFavorite: vi.fn(),
onDeleteContact: vi.fn(),
@@ -2,10 +2,11 @@ import { act, renderHook } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { useConversationActions } from '../hooks/useConversationActions';
import type { Channel, Conversation, Message } from '../types';
import type { Channel, Contact, Conversation, Message, PathDiscoveryResponse } from '../types';
const mocks = vi.hoisted(() => ({
api: {
requestPathDiscovery: vi.fn(),
requestTrace: vi.fn(),
resendChannelMessage: vi.fn(),
sendChannelMessage: vi.fn(),
@@ -65,6 +66,7 @@ function createArgs(overrides: Partial<Parameters<typeof useConversationActions>
return {
activeConversation,
activeConversationRef: { current: activeConversation },
setContacts: vi.fn(),
setChannels: vi.fn(),
addMessageIfNew: vi.fn(() => true),
jumpToBottom: vi.fn(),
@@ -143,4 +145,48 @@ describe('useConversationActions', () => {
expect(args.messageInputRef.current?.appendText).toHaveBeenCalledWith('@[Alice] ');
});
it('merges returned contact data after path discovery', async () => {
const contactKey = 'aa'.repeat(32);
const discoveredContact: Contact = {
public_key: contactKey,
name: 'Alice',
type: 1,
flags: 0,
last_path: 'AABB',
last_path_len: 2,
out_path_hash_mode: 0,
last_advert: null,
lat: null,
lon: null,
last_seen: null,
on_radio: false,
last_contacted: null,
last_read_at: null,
first_seen: null,
};
const response: PathDiscoveryResponse = {
contact: discoveredContact,
forward_path: { path: 'AABB', path_len: 2, path_hash_mode: 0 },
return_path: { path: 'CC', path_len: 1, path_hash_mode: 0 },
};
mocks.api.requestPathDiscovery.mockResolvedValue(response);
const setContacts = vi.fn();
const args = createArgs({
activeConversation: { type: 'contact', id: contactKey, name: 'Alice' },
activeConversationRef: { current: { type: 'contact', id: contactKey, name: 'Alice' } },
setContacts,
});
const { result } = renderHook(() => useConversationActions(args));
await act(async () => {
await result.current.handlePathDiscovery(contactKey);
});
expect(mocks.api.requestPathDiscovery).toHaveBeenCalledWith(contactKey);
expect(setContacts).toHaveBeenCalledTimes(1);
const updater = setContacts.mock.calls[0][0] as (contacts: Contact[]) => Contact[];
expect(updater([])).toEqual([discoveredContact]);
});
});
+12
View File
@@ -432,6 +432,18 @@ export interface TraceResponse {
path_len: number;
}
export interface PathDiscoveryRoute {
path: string;
path_len: number;
path_hash_mode: number;
}
export interface PathDiscoveryResponse {
contact: Contact;
forward_path: PathDiscoveryRoute;
return_path: PathDiscoveryRoute;
}
export interface UnreadCounts {
counts: Record<string, number>;
mentions: Record<string, boolean>;