From 5c413bf949ed5aa1d5b5d06becffb5e6d92b18da Mon Sep 17 00:00:00 2001 From: Jack Kingsman Date: Sat, 7 Mar 2026 19:11:04 -0800 Subject: [PATCH] Phase 5: Frontend path rendering --- frontend/src/components/ContactInfoPane.tsx | 9 ++++- frontend/src/components/PathModal.tsx | 4 +- frontend/src/types.ts | 4 +- frontend/src/utils/pathUtils.ts | 44 +++++++++++++++------ 4 files changed, 44 insertions(+), 17 deletions(-) diff --git a/frontend/src/components/ContactInfoPane.tsx b/frontend/src/components/ContactInfoPane.tsx index 3228b8a..65b9cf9 100644 --- a/frontend/src/components/ContactInfoPane.tsx +++ b/frontend/src/components/ContactInfoPane.tsx @@ -1,7 +1,12 @@ import { useEffect, useState } from 'react'; import { api } from '../api'; import { formatTime } from '../utils/messageParser'; -import { isValidLocation, calculateDistance, formatDistance } from '../utils/pathUtils'; +import { + isValidLocation, + calculateDistance, + formatDistance, + parsePathHops, +} from '../utils/pathUtils'; import { getMapFocusHash } from '../utils/urlHash'; import { isFavorite } from '../utils/favorites'; import { handleKeyboardActivate } from '../utils/a11y'; @@ -413,7 +418,7 @@ export function ContactInfoPane({ className="flex justify-between items-center text-sm" > - {p.path ? p.path.match(/.{2}/g)!.join(' โ†’ ') : '(direct)'} + {p.path ? parsePathHops(p.path, p.path_len).join(' โ†’ ') : '(direct)'} {p.heard_count}x ยท {formatTime(p.last_seen)} diff --git a/frontend/src/components/PathModal.tsx b/frontend/src/components/PathModal.tsx index a60320a..727e734 100644 --- a/frontend/src/components/PathModal.tsx +++ b/frontend/src/components/PathModal.tsx @@ -52,7 +52,7 @@ export function PathModal({ const resolvedPaths = hasPaths ? paths.map((p) => ({ ...p, - resolved: resolvePath(p.path, senderInfo, contacts, config), + resolved: resolvePath(p.path, senderInfo, contacts, config, p.path_len), })) : []; @@ -90,7 +90,7 @@ export function PathModal({ {/* Raw path summary */}
{paths.map((p, index) => { - const hops = parsePathHops(p.path); + const hops = parsePathHops(p.path, p.path_len); const rawPath = hops.length > 0 ? hops.join('->') : 'direct'; return (
diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 10f65ea..291aee0 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -150,10 +150,12 @@ export interface ChannelDetail { /** A single path that a message took to reach us */ export interface MessagePath { - /** Hex-encoded routing path (2 chars per hop) */ + /** Hex-encoded routing path */ path: string; /** Unix timestamp when this path was received */ received_at: number; + /** Hop count (number of intermediate nodes). Null for legacy data (infer as len(path)/2). */ + path_len?: number | null; } export interface Message { diff --git a/frontend/src/utils/pathUtils.ts b/frontend/src/utils/pathUtils.ts index 712a2b5..2fc5ca1 100644 --- a/frontend/src/utils/pathUtils.ts +++ b/frontend/src/utils/pathUtils.ts @@ -2,7 +2,7 @@ import type { Contact, RadioConfig, MessagePath } from '../types'; import { CONTACT_TYPE_REPEATER } from '../types'; export interface PathHop { - prefix: string; // 2-char hex prefix (e.g., "1A") + prefix: string; // Hex hop identifier (e.g., "1A" for 1-byte, "1A2B" for 2-byte) matches: Contact[]; // Matched repeaters (empty=unknown, multiple=ambiguous) distanceFromPrev: number | null; // km from previous hop } @@ -30,22 +30,37 @@ export interface SenderInfo { } /** - * Split hex string into 2-char hops + * Split hex path string into per-hop chunks. + * + * When hopCount is provided (from path_len metadata), the bytes-per-hop is + * derived from the hex length divided by the hop count. This correctly handles + * multi-byte hop identifiers (1, 2, or 3 bytes per hop). + * + * Falls back to 2-char (1-byte) chunks when hopCount is missing or doesn't + * divide evenly โ€” matching legacy behavior. */ -export function parsePathHops(path: string | null | undefined): string[] { +export function parsePathHops(path: string | null | undefined, hopCount?: number | null): string[] { if (!path || path.length === 0) { return []; } const normalized = path.toUpperCase(); - const hops: string[] = []; - for (let i = 0; i < normalized.length; i += 2) { - if (i + 1 < normalized.length) { - hops.push(normalized.slice(i, i + 2)); + // Derive chars-per-hop from metadata when available + let charsPerHop = 2; // default: 1-byte hops + if (hopCount && hopCount > 0) { + const derived = normalized.length / hopCount; + // Accept only valid even widths (2, 4, 6) that divide evenly + if (derived >= 2 && derived % 2 === 0 && derived * hopCount === normalized.length) { + charsPerHop = derived; } } + const hops: string[] = []; + for (let i = 0; i + charsPerHop <= normalized.length; i += charsPerHop) { + hops.push(normalized.slice(i, i + charsPerHop)); + } + return hops; } @@ -146,12 +161,16 @@ function sortContactsByDistance( } /** - * Get simple hop count from path string + * Get hop count from path, using explicit metadata when available. */ -function getHopCount(path: string | null | undefined): number { +function getHopCount(path: string | null | undefined, hopCount?: number | null): number { + if (hopCount != null && hopCount >= 0) { + return hopCount; + } if (!path || path.length === 0) { return 0; } + // Legacy fallback: assume 1-byte (2 hex chars) per hop return Math.floor(path.length / 2); } @@ -170,7 +189,7 @@ export function formatHopCounts(paths: MessagePath[] | null | undefined): { } // Get hop counts for all paths and sort ascending - const hopCounts = paths.map((p) => getHopCount(p.path)).sort((a, b) => a - b); + const hopCounts = paths.map((p) => getHopCount(p.path, p.path_len)).sort((a, b) => a - b); const allDirect = hopCounts.every((h) => h === 0); const hasMultiple = paths.length > 1; @@ -189,9 +208,10 @@ export function resolvePath( path: string | null | undefined, sender: SenderInfo, contacts: Contact[], - config: RadioConfig | null + config: RadioConfig | null, + hopCount?: number | null ): ResolvedPath { - const hopPrefixes = parsePathHops(path); + const hopPrefixes = parsePathHops(path, hopCount); // Build sender info const senderPrefix = sender.publicKeyOrPrefix.toUpperCase().slice(0, 2);