Skip to content

Commit f796f09

Browse files
Yerazeclaude
andauthored
feat(meshcore): {ROUTE_NAMES}/{HASH_SIZE} tokens + clickable route-detail popup (#4276)
* feat(meshcore): add {ROUTE_NAMES} and {HASH_SIZE} automation reply tokens {ROUTE_NAMES} expands the relay-hash chain like {ROUTE} but resolves each hop hash to the matching repeater/room-server name from the contact list. Matching is limited to relay infrastructure (advType 2/3) because only forwarding-enabled nodes ever append to a MeshCore path — each forwarder appends the leading bytes of its public key at the width the ORIGINAL SENDER stamped into path_len's top two bits (verified against ripplebiz/MeshCore Mesh::routeRecvPacket / Identity::copyHashTo). On a hash collision (multiple repeaters share the prefix) the expansion best-guesses the candidate with the smallest summed haversine distance to the two neighbouring hops' resolved positions; unknown hashes stay as raw hex to keep replies compact on airtime-limited channels. {HASH_SIZE} expands to the per-hop path-hash width in bytes (1-3), inferred from the hop hex width; '—' for direct/no-path messages. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018e4dtLyWeYJYJ7SbgFvGG1 * feat(meshcore): clickable route-hash chain opens route-detail popup In the MeshCore channel/DM message stream the relay-hash chain on a received message is now a button; clicking it opens a small modal with message details (sender, time, hops, scope, text) and the route expanded per-hop into repeater / room-server names — the visual counterpart of the {ROUTE_NAMES} automation token, sharing the same resolveRouteNames helper (unknown hashes stay raw hex, collisions annotated as a best guess). Reuses the packet-monitor modal styling (mcpm-*). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018e4dtLyWeYJYJ7SbgFvGG1 * fix(meshcore): use UiIcon arrows in route modal (no-hardcoded-ui-glyph) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018e4dtLyWeYJYJ7SbgFvGG1 * docs(meshcore): document route-detail popup and new automation tokens Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018e4dtLyWeYJYJ7SbgFvGG1 --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 88bb9cc commit f796f09

12 files changed

Lines changed: 504 additions & 6 deletions

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
77
## [Unreleased]
88

99
### Added
10+
- **MeshCore: `{ROUTE_NAMES}` and `{HASH_SIZE}` automation reply tokens**`{ROUTE_NAMES}` expands the relay-hash chain like `{ROUTE}` but resolves each hop to the matching repeater/room-server name from the contact list (unknown hashes stay raw hex; when several repeaters share a hash prefix, the one closest to the neighbouring hops' positions is picked as a best guess). `{HASH_SIZE}` reports the per-hop path-hash width in bytes (1–3) that the original sender stamped into the packet. Available in Auto-Acknowledge and Auto-Responder templates.
11+
- **MeshCore: clickable message route with repeater names** — the relay-hash chain on a received channel/DM message is now clickable and opens a route-detail popup showing the message's reception details and the route expanded per hop into repeater/room-server names, using the same resolution (and collision best-guess) as the `{ROUTE_NAMES}` token.
1012
- **Map Analysis: Terrain Link Profile tool** — pick two points (nodes or arbitrary map locations) to get a terrain elevation profile, line-of-sight, and Fresnel-zone chart, plus a full RF link budget (FSPL, antenna gains/heights, cable loss, RX sensitivity) with a clear/marginal/obstructed verdict, comparable to site.meshtastic.org's link-planning view. Frequency and RX sensitivity auto-fill per source (Meshtastic region/modem-preset, MeshCore radio config) with a "from &lt;source&gt;" provenance hint, always overridable by hand; the picked link on the map recolors to match the verdict once computed. (#4111, #4143, #4147)
1113
- **Elevation source settings** — a new admin-only **Elevation / Terrain** settings section enables/disables the Link Profile tool's terrain data and lets you point it at a custom DEM source (tile-template or Open-Topo-Data-compatible JSON API), with a Test button reporting the detected source type, sample elevation, and latency. Defaults to the public AWS Terrarium (SRTM-derived) tile set; all fetches happen server-side through the existing SSRF-guarded outbound path. (#4111)
1214

docs/features/meshcore.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,8 @@ Received channel and DM messages show a route line beneath the body, derived fro
163163
- **📍 direct** — heard with zero hops (direct RF).
164164
- **🛰 N hops · a3 → 7f → 02** — relayed; the chain lists each relay's hash in order.
165165

166+
**Clicking the hash chain** opens a route-detail popup with the message's reception details (sender, time, hops, scope, text) and the route expanded per hop into **repeater / room-server names** from the contact list. Only relay infrastructure ever appears in a MeshCore path — each forwarder appends the leading bytes of its public key at the hash width the original sender chose (1–3 bytes) — so matching is limited to those contacts. Unknown hashes stay as raw hex; when several repeaters share a hash prefix, the popup best-guesses the one closest to the neighbouring hops' positions and labels it as such.
167+
166168
Room-server posts and messages with no recoverable path show no route line.
167169

168170
### Send bar
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
/**
2+
* @vitest-environment jsdom
3+
*
4+
* Tests for the message route-detail modal: clicking a received message's
5+
* relay-hash chain opens this popup, which shows message details and expands
6+
* each relay hash to the matching repeater / room-server name (mirroring the
7+
* {ROUTE_NAMES} automation token — unknown hashes stay raw, collisions are
8+
* annotated as a best guess).
9+
*/
10+
import { describe, it, expect, vi } from 'vitest';
11+
import { render, screen, fireEvent } from '@testing-library/react';
12+
13+
vi.mock('react-i18next', () => ({
14+
useTranslation: () => ({
15+
t: (key: string, fallback?: string | Record<string, unknown>) => {
16+
if (typeof fallback === 'string') return fallback;
17+
return key;
18+
},
19+
}),
20+
Trans: ({ children }: { children?: unknown }) => children,
21+
initReactI18next: { type: '3rdParty', init: () => {} },
22+
}));
23+
24+
import MeshCoreMessageRouteModal from './MeshCoreMessageRouteModal';
25+
import type { MeshCoreMessage } from './hooks/useMeshCore';
26+
import type { MeshCoreContact } from '../../utils/meshcoreHelpers';
27+
28+
const msg = (over: Partial<MeshCoreMessage> = {}): MeshCoreMessage => ({
29+
id: 'm1',
30+
fromPublicKey: 'deadbeefcafe',
31+
fromName: 'Sender',
32+
text: 'test ping',
33+
timestamp: Date.now(),
34+
hopCount: 2,
35+
routePath: 'a3,7f',
36+
...over,
37+
});
38+
39+
const contacts: MeshCoreContact[] = [
40+
{ publicKey: 'a3' + 'b'.repeat(62), advType: 2, advName: 'Hilltop' },
41+
{ publicKey: '7f' + 'c'.repeat(62), advType: 3, advName: 'Downtown Room' },
42+
];
43+
44+
describe('MeshCoreMessageRouteModal', () => {
45+
it('shows message details and resolves relay hashes to repeater names', () => {
46+
render(
47+
<MeshCoreMessageRouteModal
48+
message={msg()}
49+
fromLabel="Sender"
50+
contacts={contacts}
51+
onClose={() => {}}
52+
/>,
53+
);
54+
expect(screen.getByText('Message Route')).toBeTruthy();
55+
expect(screen.getByText('Sender')).toBeTruthy();
56+
expect(screen.getByText('test ping')).toBeTruthy();
57+
// Per-hop rows resolve to names; the summary row joins them (icon arrows).
58+
const resolved = document.querySelector('.mc-route-resolved');
59+
expect(resolved?.textContent).toContain('Hilltop');
60+
expect(resolved?.textContent).toContain('Downtown Room');
61+
// Hash width inferred from the hop hex length (2 chars = 1 byte).
62+
expect(screen.getByText('1 B')).toBeTruthy();
63+
});
64+
65+
it('leaves unknown hashes raw and annotates collisions as a best guess', () => {
66+
const twins: MeshCoreContact[] = [
67+
{ publicKey: 'a3' + 'b'.repeat(62), advType: 2, advName: 'Twin One' },
68+
{ publicKey: 'a3' + 'c'.repeat(62), advType: 2, advName: 'Twin Two' },
69+
];
70+
render(
71+
<MeshCoreMessageRouteModal
72+
message={msg({ routePath: 'a3,ff' })}
73+
fromLabel="Sender"
74+
contacts={twins}
75+
onClose={() => {}}
76+
/>,
77+
);
78+
// Collision → alphabetical first (no positions) + best-guess note.
79+
expect(screen.getAllByText(/best guess of/).length).toBeGreaterThan(0);
80+
// Unknown hash "ff" falls back to raw hex in the summary.
81+
const resolved = document.querySelector('.mc-route-resolved');
82+
expect(resolved?.textContent).toContain('Twin One');
83+
expect(resolved?.textContent).toContain('ff');
84+
// Per-hop row text is split around the arrow icon — match on substring.
85+
expect(screen.getAllByText(/unknown repeater/).length).toBeGreaterThan(0);
86+
});
87+
88+
it('shows the direct fallback when the message has no relay path', () => {
89+
render(
90+
<MeshCoreMessageRouteModal
91+
message={msg({ routePath: null, hopCount: 0 })}
92+
fromLabel="Sender"
93+
contacts={contacts}
94+
onClose={() => {}}
95+
/>,
96+
);
97+
expect(screen.getByText('Direct (no relays)')).toBeTruthy();
98+
});
99+
100+
it('invokes onClose from the close button and backdrop, not the content', () => {
101+
const onClose = vi.fn();
102+
const { container } = render(
103+
<MeshCoreMessageRouteModal
104+
message={msg()}
105+
fromLabel="Sender"
106+
contacts={contacts}
107+
onClose={onClose}
108+
/>,
109+
);
110+
fireEvent.click(container.querySelector('.mcpm-modal-content')!);
111+
expect(onClose).not.toHaveBeenCalled();
112+
fireEvent.click(screen.getByLabelText('Close'));
113+
expect(onClose).toHaveBeenCalledTimes(1);
114+
fireEvent.click(container.querySelector('.mcpm-modal')!);
115+
expect(onClose).toHaveBeenCalledTimes(2);
116+
});
117+
});
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
/**
2+
* MeshCoreMessageRouteModal — shown when the relay-hash chain on a received
3+
* MeshCore message is clicked. Presents the message's reception details and
4+
* expands each relay hash to the matching repeater / room-server name from
5+
* the contact list (mirroring the {ROUTE_NAMES} automation token): only
6+
* relay infrastructure ever appears in a path, unknown hashes stay raw, and
7+
* hash collisions resolve to the candidate nearest the neighbouring hops.
8+
* Reuses the packet-monitor modal styling (mcpm-*).
9+
*/
10+
import React, { useMemo } from 'react';
11+
import { useTranslation } from 'react-i18next';
12+
import type { MeshCoreMessage } from './hooks/useMeshCore';
13+
import type { MeshCoreContact } from '../../utils/meshcoreHelpers';
14+
import {
15+
parsePathHops,
16+
pathHashBytesOf,
17+
repeaterHopOptions,
18+
resolveRouteNames,
19+
} from '../../utils/meshcorePath';
20+
import { UiIcon } from '../icons';
21+
import './MeshCorePacketMonitor.css';
22+
23+
interface Props {
24+
message: MeshCoreMessage;
25+
/** Sender label already resolved by the stream (name or key prefix). */
26+
fromLabel: string;
27+
contacts: MeshCoreContact[];
28+
onClose: () => void;
29+
}
30+
31+
const Row: React.FC<{ label: string; children: React.ReactNode; mono?: boolean; wrap?: boolean }> = ({ label, children, mono, wrap }) => (
32+
<div className="mcpm-dl-row">
33+
<span className="mcpm-dl-label">{label}</span>
34+
<span className={`mcpm-dl-value${mono ? ' mcpm-mono' : ''}${wrap ? ' mcpm-dl-wrap' : ''}`}>{children}</span>
35+
</div>
36+
);
37+
38+
const MeshCoreMessageRouteModal: React.FC<Props> = ({ message, fromLabel, contacts, onClose }) => {
39+
const { t } = useTranslation();
40+
41+
const hops = useMemo(() => parsePathHops(message.routePath), [message.routePath]);
42+
const width = pathHashBytesOf(hops);
43+
const rows = useMemo(() => {
44+
const names = resolveRouteNames(hops, contacts);
45+
const options = repeaterHopOptions(contacts, width);
46+
return hops.map((hash, i) => ({
47+
hash,
48+
name: names[i],
49+
matchCount: options.filter((o) => o.hopByte === hash).length,
50+
}));
51+
}, [hops, contacts, width]);
52+
53+
const scopeStr = message.scopeName
54+
? message.scopeName
55+
: typeof message.scopeCode === 'number' && message.scopeCode !== 0
56+
? `#${message.scopeCode.toString(16).padStart(4, '0')}`
57+
: '—';
58+
59+
return (
60+
<div className="mcpm-modal" onClick={onClose}>
61+
<div className="mcpm-modal-content" onClick={(e) => e.stopPropagation()}>
62+
<div className="mcpm-modal-header">
63+
<h4>{t('meshcore.route_detail_title', 'Message Route')}</h4>
64+
<button className="mcpm-modal-close" onClick={onClose} aria-label={t('common.close', 'Close')}>×</button>
65+
</div>
66+
67+
<div className="mcpm-modal-body">
68+
<section className="mcpm-dl-section">
69+
<h5>{t('meshcore.route_detail_message', 'Message')}</h5>
70+
<Row label={t('meshcore.route_detail_from', 'From')}>{fromLabel}</Row>
71+
<Row label={t('meshcore.route_detail_time', 'Time')} mono>{new Date(message.timestamp).toLocaleString()}</Row>
72+
<Row label={t('meshcore.route_detail_hops', 'Hops')} mono>{typeof message.hopCount === 'number' ? message.hopCount : '—'}</Row>
73+
<Row label={t('meshcore.route_detail_scope', 'Scope')}>{scopeStr}</Row>
74+
{message.text && (
75+
<Row label={t('meshcore.route_detail_text', 'Text')} wrap>{message.text}</Row>
76+
)}
77+
</section>
78+
79+
<section className="mcpm-dl-section">
80+
<h5>{t('meshcore.route_detail_route', 'Route')}</h5>
81+
<Row label={t('meshcore.packets.hashWidth', 'Hash width')} mono>{width} B</Row>
82+
{rows.length === 0 ? (
83+
<Row label={t('meshcore.packets.routing', 'Routing')}>{t('meshcore.packets.directNoRelay', 'Direct (no relays)')}</Row>
84+
) : (
85+
<>
86+
{rows.map((r, i) => (
87+
<Row key={`${r.hash}-${i}`} label={`#${i + 1}`} mono>
88+
{r.hash}
89+
{' '}<UiIcon name="forward" size={12} />{' '}
90+
{r.matchCount === 0
91+
? t('meshcore.route_detail_unknown', 'unknown repeater')
92+
: r.name}
93+
{r.matchCount > 1 && (
94+
<span className="mc-route-best-guess">
95+
{' '}({t('meshcore.route_detail_best_guess', 'best guess of')} {r.matchCount} {t('meshcore.route_detail_matches', 'matches')})
96+
</span>
97+
)}
98+
</Row>
99+
))}
100+
<Row label={t('meshcore.route_detail_resolved', 'Resolved')} wrap>
101+
<span className="mc-route-resolved">
102+
{rows.map((r, i) => (
103+
<React.Fragment key={`${r.hash}-${i}`}>
104+
{r.matchCount === 0 ? r.hash : r.name}
105+
{i < rows.length - 1 && <> <UiIcon name="forward" size={12} /> </>}
106+
</React.Fragment>
107+
))}
108+
</span>
109+
</Row>
110+
</>
111+
)}
112+
</section>
113+
</div>
114+
</div>
115+
</div>
116+
);
117+
};
118+
119+
export default MeshCoreMessageRouteModal;

src/components/MeshCore/MeshCoreMessageStream.test.tsx

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -259,3 +259,38 @@ describe('MeshCoreMessageStream focus restore (#3823)', () => {
259259
expect(document.activeElement).not.toBe(input);
260260
});
261261
});
262+
263+
describe('MeshCoreMessageStream route-detail popup', () => {
264+
it('opens the route modal with repeater names when the hash chain is clicked', () => {
265+
const message: MeshCoreMessage = {
266+
...msg('r1', Date.now(), 'ping'),
267+
hopCount: 2,
268+
routePath: 'a3,7f',
269+
};
270+
const contacts = [
271+
{ publicKey: 'a3' + 'b'.repeat(62), advType: 2, advName: 'Hilltop' },
272+
{ publicKey: '7f' + 'c'.repeat(62), advType: 3, advName: 'Downtown Room' },
273+
];
274+
const { container } = render(
275+
<MeshCoreMessageStream messages={[message]} contacts={contacts} onSend={async () => true} />,
276+
);
277+
// No modal until the chain is clicked.
278+
expect(container.querySelector('.mcpm-modal')).toBeNull();
279+
fireEvent.click(container.querySelector('.mc-route-chain-link')!);
280+
expect(container.querySelector('.mcpm-modal')).not.toBeNull();
281+
const resolved = container.querySelector('.mc-route-resolved');
282+
expect(resolved?.textContent).toContain('Hilltop');
283+
expect(resolved?.textContent).toContain('Downtown Room');
284+
// Close via the backdrop.
285+
fireEvent.click(container.querySelector('.mcpm-modal')!);
286+
expect(container.querySelector('.mcpm-modal')).toBeNull();
287+
});
288+
289+
it('renders no clickable chain for direct messages', () => {
290+
const message: MeshCoreMessage = { ...msg('r2', Date.now(), 'ping'), hopCount: 0 };
291+
const { container } = render(
292+
<MeshCoreMessageStream messages={[message]} onSend={async () => true} />,
293+
);
294+
expect(container.querySelector('.mc-route-chain-link')).toBeNull();
295+
});
296+
});

src/components/MeshCore/MeshCoreMessageStream.tsx

Lines changed: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { MeshCoreContact } from '../../utils/meshcoreHelpers';
55
import { getMessageDateSeparator, shouldShowDateSeparator } from '../../utils/datetime';
66
import { getUtf8ByteLength, formatByteCount } from '../../utils/text';
77
import LinkPreview from '../LinkPreview';
8+
import MeshCoreMessageRouteModal from './MeshCoreMessageRouteModal';
89
import { UiIcon } from '../icons';
910

1011
interface MeshCoreMessageStreamProps {
@@ -81,6 +82,9 @@ export const MeshCoreMessageStream: React.FC<MeshCoreMessageStreamProps> = ({
8182
const [showJumpToBottom, setShowJumpToBottom] = useState(false);
8283
// Message ids whose "heard repeaters" list (#3700) is expanded.
8384
const [expandedHeardBy, setExpandedHeardBy] = useState<Set<string>>(new Set());
85+
// Message whose relay-hash chain was clicked — opens the route-detail modal
86+
// that expands each hash to the matching repeater name.
87+
const [routeDetail, setRouteDetail] = useState<MeshCoreMessage | null>(null);
8488
const listRef = useRef<HTMLDivElement>(null);
8589

8690
const toggleHeardBy = useCallback((id: string) => {
@@ -397,11 +401,18 @@ export const MeshCoreMessageStream: React.FC<MeshCoreMessageStreamProps> = ({
397401
<><UiIcon name="route" size={13} /> {m.hopCount} {m.hopCount === 1 ? t('meshcore.hop', 'hop') : t('meshcore.hops', 'hops')}</>
398402
)}
399403
{m.hopCount !== 0 && m.routePath && (
400-
<> · {m.routePath.split(',').filter(Boolean).map((hop, hopIndex, hops) => (
401-
<React.Fragment key={`${hop}-${hopIndex}`}>
402-
{hop}{hopIndex < hops.length - 1 && <> <UiIcon name="forward" size={12} /> </>}
403-
</React.Fragment>
404-
))}</>
404+
<> · <button
405+
type="button"
406+
className="mc-route-chain-link"
407+
title={t('meshcore.route_detail_tooltip', 'Show route details with repeater names')}
408+
onClick={() => setRouteDetail(m)}
409+
>
410+
{m.routePath.split(',').filter(Boolean).map((hop, hopIndex, hops) => (
411+
<React.Fragment key={`${hop}-${hopIndex}`}>
412+
{hop}{hopIndex < hops.length - 1 && <> <UiIcon name="forward" size={12} /> </>}
413+
</React.Fragment>
414+
))}
415+
</button></>
405416
)}
406417
</div>
407418
)}
@@ -458,6 +469,14 @@ export const MeshCoreMessageStream: React.FC<MeshCoreMessageStreamProps> = ({
458469
{byteCounter.text}
459470
</div>
460471
)}
472+
{routeDetail && (
473+
<MeshCoreMessageRouteModal
474+
message={routeDetail}
475+
fromLabel={routeDetail.fromName ?? nameForKey(routeDetail.fromPublicKey) ?? `${routeDetail.fromPublicKey.substring(0, 8)}…`}
476+
contacts={contacts ?? []}
477+
onClose={() => setRouteDetail(null)}
478+
/>
479+
)}
461480
</div>
462481
);
463482
};

src/components/MeshCore/MeshCorePage.css

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -818,6 +818,20 @@
818818
/* Hop count + relay route for received messages (#3742). */
819819
.mc-message-route { color: var(--ctp-subtext0); font-size: 0.75rem; margin-top: 0.15rem; font-family: var(--font-mono, monospace); }
820820
.mc-message-scope { color: var(--ctp-subtext0); font-size: 0.75rem; margin-top: 0.1rem; font-family: var(--font-mono, monospace); }
821+
/* Clickable relay-hash chain — opens the route-detail modal (repeater names). */
822+
.mc-route-chain-link {
823+
background: none;
824+
border: none;
825+
padding: 0;
826+
margin: 0;
827+
color: inherit;
828+
font: inherit;
829+
cursor: pointer;
830+
text-decoration: underline dotted;
831+
text-underline-offset: 2px;
832+
}
833+
.mc-route-chain-link:hover { color: var(--ctp-blue); }
834+
.mc-route-best-guess { color: var(--ctp-subtext0); font-style: italic; }
821835

822836
.mc-mention {
823837
color: var(--ctp-blue);

src/components/MeshCore/meshcoreAutomationTokens.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,8 @@ export const MESHCORE_REPLY_TOKENS: AutomationTokenInfo[] = [
3939
{ token: '{HOPS}', description: 'Hop count the message travelled' },
4040
{ token: '{NUMBER_HOPS}', description: 'Same as {HOPS}' },
4141
{ token: '{ROUTE}', description: 'Relay-hash chain the message took (e.g. a3→7f)' },
42+
{ token: '{ROUTE_NAMES}', description: 'Relay chain resolved to repeater names where known (e.g. Hilltop→7f)' },
43+
{ token: '{HASH_SIZE}', description: 'Per-hop path-hash width in bytes (1–3), as set by the sender' },
4244
{ token: '{SCOPE}', description: 'Region/scope the message was sent with' },
4345
];
4446

0 commit comments

Comments
 (0)