Add multipath tracking

This commit is contained in:
Jack Kingsman
2026-01-18 20:00:32 -08:00
parent 0fea2889b2
commit c4ef8ec9cd
30 changed files with 1115 additions and 311 deletions

View File

@@ -33,6 +33,7 @@ import type {
Conversation,
HealthStatus,
Message,
MessagePath,
RawPacket,
RadioConfig,
RadioConfigUpdate,
@@ -220,8 +221,8 @@ export function App() {
return updated;
});
},
onMessageAcked: (messageId: number, ackCount: number) => {
updateMessageAck(messageId, ackCount);
onMessageAcked: (messageId: number, ackCount: number, paths?: MessagePath[]) => {
updateMessageAck(messageId, ackCount, paths);
},
}),
[addMessageIfNew, trackNewMessage, incrementUnread, updateMessageAck, checkMention]

View File

@@ -1,9 +1,9 @@
import { useEffect, useLayoutEffect, useRef, useCallback, useState, type ReactNode } from 'react';
import type { Contact, Message, RadioConfig } from '../types';
import type { Contact, Message, MessagePath, RadioConfig } from '../types';
import { CONTACT_TYPE_REPEATER } from '../types';
import { formatTime, parseSenderFromText } from '../utils/messageParser';
import { pubkeysMatch } from '../utils/pubkey';
import { getHopCount, type SenderInfo } from '../utils/pathUtils';
import { formatHopCounts, type SenderInfo } from '../utils/pathUtils';
import { ContactAvatar } from './ContactAvatar';
import { PathModal } from './PathModal';
import { cn } from '@/lib/utils';
@@ -62,6 +62,40 @@ function renderTextWithMentions(text: string, radioName?: string): ReactNode {
return parts.length > 0 ? parts : text;
}
// Clickable hop count badge that opens the path modal
interface HopCountBadgeProps {
paths: MessagePath[];
onClick: () => void;
variant: 'header' | 'inline';
}
function HopCountBadge({ paths, onClick, variant }: HopCountBadgeProps) {
const hopInfo = formatHopCounts(paths);
// Single direct: "(d)", otherwise "(d/1/3 hops)"
const label =
hopInfo.allDirect && !hopInfo.hasMultiple
? `(${hopInfo.display})`
: `(${hopInfo.display} hops)`;
const className =
variant === 'header'
? 'font-normal text-muted-foreground/70 ml-1 text-[11px] cursor-pointer hover:text-primary hover:underline'
: 'text-[10px] text-muted-foreground/50 ml-1 cursor-pointer hover:text-primary hover:underline';
return (
<span
className={className}
onClick={(e) => {
e.stopPropagation();
onClick();
}}
title="View message path"
>
{label}
</span>
);
}
export function MessageList({
messages,
contacts,
@@ -78,7 +112,7 @@ export function MessageList({
const isInitialLoadRef = useRef<boolean>(true);
const [showScrollToBottom, setShowScrollToBottom] = useState(false);
const [selectedPath, setSelectedPath] = useState<{
path: string;
paths: MessagePath[];
senderInfo: SenderInfo;
} | null>(null);
@@ -336,27 +370,18 @@ export function MessageList({
<span className="font-normal text-muted-foreground/70 ml-2 text-[11px]">
{formatTime(msg.sender_timestamp || msg.received_at)}
</span>
{!msg.outgoing &&
msg.path &&
(getHopCount(msg.path) === 0 ? (
<span className="font-normal text-muted-foreground/70 ml-1 text-[11px]">
(direct)
</span>
) : (
<span
className="font-normal text-muted-foreground/70 ml-1 text-[11px] cursor-pointer hover:text-primary hover:underline"
onClick={(e) => {
e.stopPropagation();
setSelectedPath({
path: msg.path!,
senderInfo: getSenderInfo(msg, contact, sender),
});
}}
title="View message path"
>
({getHopCount(msg.path)} hop{getHopCount(msg.path) !== 1 ? 's' : ''})
</span>
))}
{!msg.outgoing && msg.paths && msg.paths.length > 0 && (
<HopCountBadge
paths={msg.paths}
variant="header"
onClick={() =>
setSelectedPath({
paths: msg.paths!,
senderInfo: getSenderInfo(msg, contact, sender),
})
}
/>
)}
</div>
)}
<div className="break-words whitespace-pre-wrap">
@@ -371,27 +396,18 @@ export function MessageList({
<span className="text-[10px] text-muted-foreground/50 ml-2">
{formatTime(msg.sender_timestamp || msg.received_at)}
</span>
{!msg.outgoing &&
msg.path &&
(getHopCount(msg.path) === 0 ? (
<span className="text-[10px] text-muted-foreground/50 ml-1">
(direct)
</span>
) : (
<span
className="text-[10px] text-muted-foreground/50 ml-1 cursor-pointer hover:text-primary hover:underline"
onClick={(e) => {
e.stopPropagation();
setSelectedPath({
path: msg.path!,
senderInfo: getSenderInfo(msg, contact, sender),
});
}}
title="View message path"
>
({getHopCount(msg.path)} hop{getHopCount(msg.path) !== 1 ? 's' : ''})
</span>
))}
{!msg.outgoing && msg.paths && msg.paths.length > 0 && (
<HopCountBadge
paths={msg.paths}
variant="inline"
onClick={() =>
setSelectedPath({
paths: msg.paths!,
senderInfo: getSenderInfo(msg, contact, sender),
})
}
/>
)}
</>
)}
{msg.outgoing && (msg.acked > 0 ? `${msg.acked > 1 ? msg.acked : ''}` : ' ?')}
@@ -431,7 +447,7 @@ export function MessageList({
<PathModal
open={true}
onClose={() => setSelectedPath(null)}
path={selectedPath.path}
paths={selectedPath.paths}
senderInfo={selectedPath.senderInfo}
contacts={contacts}
config={config ?? null}

View File

@@ -1,4 +1,4 @@
import type { Contact, RadioConfig } from '../types';
import type { Contact, RadioConfig, MessagePath } from '../types';
import {
Dialog,
DialogContent,
@@ -17,34 +17,59 @@ import {
type ResolvedPath,
type PathHop,
} from '../utils/pathUtils';
import { formatTime } from '../utils/messageParser';
import { getMapFocusHash } from '../utils/urlHash';
interface PathModalProps {
open: boolean;
onClose: () => void;
path: string;
paths: MessagePath[];
senderInfo: SenderInfo;
contacts: Contact[];
config: RadioConfig | null;
}
export function PathModal({ open, onClose, path, senderInfo, contacts, config }: PathModalProps) {
const resolved = resolvePath(path, senderInfo, contacts, config);
export function PathModal({ open, onClose, paths, senderInfo, contacts, config }: PathModalProps) {
// Resolve all paths
const resolvedPaths = paths.map((p) => ({
...p,
resolved: resolvePath(p.path, senderInfo, contacts, config),
}));
const hasSinglePath = paths.length === 1;
return (
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && onClose()}>
<DialogContent className="max-w-md max-h-[80vh] flex flex-col">
<DialogHeader>
<DialogTitle>Message Path</DialogTitle>
<DialogTitle>Message Path{!hasSinglePath && `s (${paths.length})`}</DialogTitle>
<DialogDescription>
This shows <em>one route</em> that this message traveled through the mesh network. Flood
messages may arrive via multiple paths, and routers may be incorrectly identified due to
prefix collisions between heard and non-heard router advertisements.
{hasSinglePath ? (
<>
This shows <em>one route</em> that this message traveled through the mesh network.
Routers may be incorrectly identified due to prefix collisions between heard and
non-heard router advertisements.
</>
) : (
<>
This message was received via <strong>{paths.length} different routes</strong>.
Routers may be incorrectly identified due to prefix collisions.
</>
)}
</DialogDescription>
</DialogHeader>
<div className="flex-1 overflow-y-auto py-2">
<PathVisualization resolved={resolved} senderInfo={senderInfo} />
<div className="flex-1 overflow-y-auto py-2 space-y-4">
{resolvedPaths.map((pathData, index) => (
<div key={index}>
{!hasSinglePath && (
<div className="text-xs text-muted-foreground font-medium mb-2 pb-1 border-b border-border">
Path {index + 1} received {formatTime(pathData.received_at)}
</div>
)}
<PathVisualization resolved={pathData.resolved} senderInfo={senderInfo} />
</div>
))}
</div>
<DialogFooter>

View File

@@ -100,7 +100,7 @@ function createLocalMessage(conversationKey: string, text: string, outgoing: boo
text,
sender_timestamp: now,
received_at: now,
path: null,
paths: null,
txt_type: 0,
signature: null,
outgoing,

View File

@@ -1,7 +1,7 @@
import { useState, useCallback, useEffect, useRef } from 'react';
import { toast } from '../components/ui/sonner';
import { api } from '../api';
import type { Conversation, Message } from '../types';
import type { Conversation, Message, MessagePath } from '../types';
const MESSAGE_PAGE_SIZE = 200;
@@ -19,7 +19,7 @@ export interface UseConversationMessagesResult {
fetchMessages: (showLoading?: boolean) => Promise<void>;
fetchOlderMessages: () => Promise<void>;
addMessageIfNew: (msg: Message) => boolean;
updateMessageAck: (messageId: number, ackCount: number) => void;
updateMessageAck: (messageId: number, ackCount: number, paths?: MessagePath[]) => void;
}
export function useConversationMessages(
@@ -145,18 +145,25 @@ export function useConversationMessages(
return true;
}, []);
// Update a message's ack count
const updateMessageAck = useCallback((messageId: number, ackCount: number) => {
setMessages((prev) => {
const idx = prev.findIndex((m) => m.id === messageId);
if (idx >= 0) {
const updated = [...prev];
updated[idx] = { ...prev[idx], acked: ackCount };
return updated;
}
return prev;
});
}, []);
// Update a message's ack count and paths
const updateMessageAck = useCallback(
(messageId: number, ackCount: number, paths?: MessagePath[]) => {
setMessages((prev) => {
const idx = prev.findIndex((m) => m.id === messageId);
if (idx >= 0) {
const updated = [...prev];
updated[idx] = {
...prev[idx],
acked: ackCount,
...(paths !== undefined && { paths }),
};
return updated;
}
return prev;
});
},
[]
);
return {
messages,

View File

@@ -99,7 +99,7 @@ function createLocalMessage(
text,
sender_timestamp: now,
received_at: now,
path: null,
paths: null,
txt_type: 0,
signature: null,
outgoing,

View File

@@ -268,7 +268,7 @@ describe('Integration: ACK Events', () => {
text: 'Hello',
sender_timestamp: 1700000000,
received_at: 1700000000,
path: null,
paths: null,
txt_type: 0,
signature: null,
outgoing: true,
@@ -301,7 +301,7 @@ describe('Integration: ACK Events', () => {
text: 'Hello',
sender_timestamp: 1700000000,
received_at: 1700000000,
path: null,
paths: null,
txt_type: 0,
signature: null,
outgoing: true,

View File

@@ -7,6 +7,7 @@ import {
getHopCount,
resolvePath,
formatDistance,
formatHopCounts,
} from '../utils/pathUtils';
import type { Contact, RadioConfig } from '../types';
import { CONTACT_TYPE_REPEATER, CONTACT_TYPE_CLIENT } from '../types';
@@ -573,3 +574,66 @@ describe('formatDistance', () => {
expect(formatDistance(0.001)).toBe('1m');
});
});
describe('formatHopCounts', () => {
it('returns empty for null paths', () => {
const result = formatHopCounts(null);
expect(result.display).toBe('');
expect(result.allDirect).toBe(true);
expect(result.hasMultiple).toBe(false);
});
it('returns empty for empty paths array', () => {
const result = formatHopCounts([]);
expect(result.display).toBe('');
expect(result.allDirect).toBe(true);
expect(result.hasMultiple).toBe(false);
});
it('formats single direct path as "d"', () => {
const result = formatHopCounts([{ path: '', received_at: 1700000000 }]);
expect(result.display).toBe('d');
expect(result.allDirect).toBe(true);
expect(result.hasMultiple).toBe(false);
});
it('formats single multi-hop path with hop count', () => {
const result = formatHopCounts([{ path: '1A2B', received_at: 1700000000 }]);
expect(result.display).toBe('2');
expect(result.allDirect).toBe(false);
expect(result.hasMultiple).toBe(false);
});
it('formats multiple paths sorted by hop count', () => {
const result = formatHopCounts([
{ path: '1A2B3C', received_at: 1700000000 }, // 3 hops
{ path: '', received_at: 1700000001 }, // direct
{ path: '1A', received_at: 1700000002 }, // 1 hop
{ path: '1A2B3C', received_at: 1700000003 }, // 3 hops
]);
expect(result.display).toBe('d/1/3/3');
expect(result.allDirect).toBe(false);
expect(result.hasMultiple).toBe(true);
});
it('formats multiple direct paths', () => {
const result = formatHopCounts([
{ path: '', received_at: 1700000000 },
{ path: '', received_at: 1700000001 },
]);
expect(result.display).toBe('d/d');
expect(result.allDirect).toBe(true);
expect(result.hasMultiple).toBe(true);
});
it('handles mixed paths with multiple direct routes', () => {
const result = formatHopCounts([
{ path: '1A', received_at: 1700000000 }, // 1 hop
{ path: '', received_at: 1700000001 }, // direct
{ path: '', received_at: 1700000002 }, // direct
]);
expect(result.display).toBe('d/d/1');
expect(result.allDirect).toBe(false);
expect(result.hasMultiple).toBe(true);
});
});

View File

@@ -72,7 +72,7 @@ describe('shouldIncrementUnread', () => {
text: 'Test',
sender_timestamp: null,
received_at: Date.now(),
path: null,
paths: null,
txt_type: 0,
signature: null,
outgoing: false,

View File

@@ -16,7 +16,7 @@ function createMessage(overrides: Partial<Message> = {}): Message {
text: 'Hello world',
sender_timestamp: 1700000000,
received_at: 1700000001,
path: null,
paths: null,
txt_type: 0,
signature: null,
outgoing: false,
@@ -110,3 +110,75 @@ describe('getMessageContentKey', () => {
expect(key).toContain('Hello: World! @user #channel');
});
});
describe('updateMessageAck logic', () => {
// Test the logic that updateMessageAck applies to messages
// This simulates what the setMessages callback does
function applyAckUpdate(
messages: Message[],
messageId: number,
ackCount: number,
paths?: { path: string; received_at: number }[]
): Message[] {
const idx = messages.findIndex((m) => m.id === messageId);
if (idx >= 0) {
const updated = [...messages];
updated[idx] = {
...messages[idx],
acked: ackCount,
...(paths !== undefined && { paths }),
};
return updated;
}
return messages;
}
it('updates ack count for existing message', () => {
const messages = [createMessage({ id: 42, acked: 0 })];
const updated = applyAckUpdate(messages, 42, 3);
expect(updated[0].acked).toBe(3);
});
it('updates paths when provided', () => {
const messages = [createMessage({ id: 42, acked: 0, paths: null })];
const newPaths = [
{ path: '1A2B', received_at: 1700000000 },
{ path: '1A3C', received_at: 1700000005 },
];
const updated = applyAckUpdate(messages, 42, 2, newPaths);
expect(updated[0].acked).toBe(2);
expect(updated[0].paths).toEqual(newPaths);
});
it('does not modify paths when not provided', () => {
const existingPaths = [{ path: '1A2B', received_at: 1700000000 }];
const messages = [createMessage({ id: 42, acked: 1, paths: existingPaths })];
const updated = applyAckUpdate(messages, 42, 2);
expect(updated[0].acked).toBe(2);
expect(updated[0].paths).toEqual(existingPaths); // Unchanged
});
it('returns unchanged array for unknown message id', () => {
const messages = [createMessage({ id: 42, acked: 0 })];
const updated = applyAckUpdate(messages, 999, 3);
expect(updated).toEqual(messages);
expect(updated[0].acked).toBe(0); // Unchanged
});
it('handles empty paths array', () => {
const messages = [createMessage({ id: 42, acked: 0, paths: null })];
const updated = applyAckUpdate(messages, 42, 1, []);
expect(updated[0].paths).toEqual([]);
});
});

View File

@@ -6,7 +6,7 @@
*/
import { describe, it, expect, vi } from 'vitest';
import type { HealthStatus, Contact, Channel, Message, RawPacket } from '../types';
import type { HealthStatus, Contact, Channel, Message, MessagePath, RawPacket } from '../types';
/**
* Parse and route a WebSocket message.
@@ -21,7 +21,7 @@ function parseWebSocketMessage(
onMessage?: (message: Message) => void;
onContact?: (contact: Contact) => void;
onRawPacket?: (packet: RawPacket) => void;
onMessageAcked?: (messageId: number, ackCount: number) => void;
onMessageAcked?: (messageId: number, ackCount: number, paths?: MessagePath[]) => void;
}
): { type: string; handled: boolean } {
try {
@@ -47,8 +47,12 @@ function parseWebSocketMessage(
handlers.onRawPacket?.(msg.data as RawPacket);
return { type: msg.type, handled: !!handlers.onRawPacket };
case 'message_acked': {
const ackData = msg.data as { message_id: number; ack_count: number };
handlers.onMessageAcked?.(ackData.message_id, ackData.ack_count);
const ackData = msg.data as {
message_id: number;
ack_count: number;
paths?: MessagePath[];
};
handlers.onMessageAcked?.(ackData.message_id, ackData.ack_count, ackData.paths);
return { type: msg.type, handled: !!handlers.onMessageAcked };
}
case 'pong':
@@ -90,7 +94,25 @@ describe('parseWebSocketMessage', () => {
expect(result.type).toBe('message_acked');
expect(result.handled).toBe(true);
expect(onMessageAcked).toHaveBeenCalledWith(42, 3);
expect(onMessageAcked).toHaveBeenCalledWith(42, 3, undefined);
});
it('routes message_acked with paths array', () => {
const onMessageAcked = vi.fn();
const paths = [
{ path: '1A2B', received_at: 1700000000 },
{ path: '1A3C', received_at: 1700000005 },
];
const data = JSON.stringify({
type: 'message_acked',
data: { message_id: 42, ack_count: 2, paths },
});
const result = parseWebSocketMessage(data, { onMessageAcked });
expect(result.type).toBe('message_acked');
expect(result.handled).toBe(true);
expect(onMessageAcked).toHaveBeenCalledWith(42, 2, paths);
});
it('routes new message to onMessage handler', () => {

View File

@@ -74,6 +74,14 @@ export interface Channel {
last_read_at: number | null;
}
/** A single path that a message took to reach us */
export interface MessagePath {
/** Hex-encoded routing path (2 chars per hop) */
path: string;
/** Unix timestamp when this path was received */
received_at: number;
}
export interface Message {
id: number;
type: 'PRIV' | 'CHAN';
@@ -82,8 +90,8 @@ export interface Message {
text: string;
sender_timestamp: number | null;
received_at: number;
/** Hex-encoded routing path (2 chars per hop). Null for outgoing messages. */
path: string | null;
/** List of routing paths this message arrived via. Null for outgoing messages. */
paths: MessagePath[] | null;
txt_type: number;
signature: string | null;
outgoing: boolean;

View File

@@ -1,5 +1,5 @@
import { useEffect, useRef, useCallback, useState } from 'react';
import type { HealthStatus, Contact, Channel, Message, RawPacket } from './types';
import type { HealthStatus, Contact, Channel, Message, MessagePath, RawPacket } from './types';
interface WebSocketMessage {
type: string;
@@ -18,7 +18,7 @@ interface UseWebSocketOptions {
onMessage?: (message: Message) => void;
onContact?: (contact: Contact) => void;
onRawPacket?: (packet: RawPacket) => void;
onMessageAcked?: (messageId: number, ackCount: number) => void;
onMessageAcked?: (messageId: number, ackCount: number, paths?: MessagePath[]) => void;
onError?: (error: ErrorEvent) => void;
}
@@ -83,8 +83,12 @@ export function useWebSocket(options: UseWebSocketOptions) {
options.onRawPacket?.(msg.data as RawPacket);
break;
case 'message_acked': {
const ackData = msg.data as { message_id: number; ack_count: number };
options.onMessageAcked?.(ackData.message_id, ackData.ack_count);
const ackData = msg.data as {
message_id: number;
ack_count: number;
paths?: MessagePath[];
};
options.onMessageAcked?.(ackData.message_id, ackData.ack_count, ackData.paths);
break;
}
case 'error':

View File

@@ -1,4 +1,4 @@
import type { Contact, RadioConfig } from '../types';
import type { Contact, RadioConfig, MessagePath } from '../types';
import { CONTACT_TYPE_REPEATER } from '../types';
export interface PathHop {
@@ -155,6 +155,33 @@ export function getHopCount(path: string | null | undefined): number {
return Math.floor(path.length / 2);
}
/**
* Format hop counts from multiple paths for display.
* Returns something like "d/1/3/3" for direct, 1-hop, 3-hop, 3-hop paths.
* Returns null if no paths or only direct.
*/
export function formatHopCounts(paths: MessagePath[] | null | undefined): {
display: string;
allDirect: boolean;
hasMultiple: boolean;
} {
if (!paths || paths.length === 0) {
return { display: '', allDirect: true, hasMultiple: false };
}
// Get hop counts for all paths and sort ascending
const hopCounts = paths.map((p) => getHopCount(p.path)).sort((a, b) => a - b);
const allDirect = hopCounts.every((h) => h === 0);
const hasMultiple = paths.length > 1;
// Format: "d" for 0, numbers for others
const parts = hopCounts.map((h) => (h === 0 ? 'd' : h.toString()));
const display = parts.join('/');
return { display, allDirect, hasMultiple };
}
/**
* Build complete path resolution with sender, hops, and receiver
*/