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

@@ -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>