mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-04-30 10:35:17 +02:00
Add multipath tracking
This commit is contained in:
@@ -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]
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -99,7 +99,7 @@ function createLocalMessage(
|
||||
text,
|
||||
sender_timestamp: now,
|
||||
received_at: now,
|
||||
path: null,
|
||||
paths: null,
|
||||
txt_type: 0,
|
||||
signature: null,
|
||||
outgoing,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user