Skip to content

Commit 6af6bbc

Browse files
Yerazeclaude
andauthored
feat(meshcore): packet-flow mini map in route popup + accessible detail modals (#4277)
* feat(meshcore): packet-flow mini map in route popup + accessible detail modals Route-detail popup: when every relay hop resolves to a positioned contact, a small BaseMap-composed map traces the packet's geospatial flow with a dashed polyline and numbered, permanently-labeled hop markers (#1 Name); the sender and local node are added as endpoints when positioned. If any hop is unknown or unpositioned the map is omitted (a partial line would misrepresent the path). Powered by a new resolveRoute() in meshcorePath.ts that surfaces the chosen candidate's position/match count; the {ROUTE_NAMES} token's resolveRouteNames() is now a thin wrapper over it. Accessibility (#4276 review follow-up): new shared useDialogA11y hook (Escape-to-close, focus-on-mount + focus restore, Tab focus trap — mirroring common/Modal) applied to the MeshCore route-detail, MeshCore packet-decode, and MQTT packet-detail modals, all now marked up with role=dialog/aria-modal. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018e4dtLyWeYJYJ7SbgFvGG1 * fix(meshcore): review follow-ups — empty-prefix sender guard, scroll lock, aria-labelledby - Flow map: don't adopt an arbitrary contact as the sender when fromPublicKey is empty (startsWith('') matches every key) - useDialogA11y: lock body scroll while the dialog is open, matching common/Modal's contract - All three detail modals: aria-labelledby referencing the visible title instead of a duplicated aria-label 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 b8d267b commit 6af6bbc

10 files changed

Lines changed: 418 additions & 51 deletions

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
99
### Added
1010
- **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.
1111
- **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.
12+
- **MeshCore: packet-flow mini map in the route popup** — when every relay hop resolves to a positioned contact, the route-detail popup adds a small map tracing the packet's geospatial flow across the relays with numbered, labeled hop markers; the sender and local node are included as endpoints when their positions are known. (#4276 follow-up)
1213
- **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)
1314
- **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)
1415

1516
### Changed
17+
- **Packet/route detail modals are now accessible dialogs** — the MeshCore packet-decode, MQTT packet-detail, and MeshCore route-detail modals gained `role="dialog"`/`aria-modal` semantics, Escape-to-close, a Tab focus trap, and focus restore to the triggering element (shared `useDialogA11y` hook, mirroring `common/Modal`). (#4276 follow-up)
1618
- **Protobufs: submodule pinned to a 2.8-preview commit (develop@ba16bfc)** — unlocks implementing Meshtastic 2.8 features (MeshBeacon, XEdDSA signing display, warm-tier NodeDB) ahead of the stable protobufs tag. Fully backward compatible for 2.7 users: the one breaking upstream change in the range (removal of the v2.7.x `TrafficManagementConfig` bool-toggle fields) is neutralized by a load-time schema patch that restores those fields, keeping Traffic Management encode/decode byte-for-byte identical to v2.7.26 against shipping 2.7-alpha firmware (regression-tested at the wire level). The pin will be retargeted to the official 2.8 tag when it lands. (#3548, #3854, #3923)
1719
- **Maps: obscured-GPS marker offset now scales with cell crowding and is distance-capped** — refines the within-cell declutter offset for low-precision (`precision_bits`) markers. The jitter magnitude now grows *logarithmically* with how many nodes share a precision cell (a busier cell spreads wider; a barely-shared one nudges only slightly), and is capped at the size of a 15-bit cell (~1,456 m). Previously the offset scaled to the node's own cell, so a very coarse-precision node (bits 1–14) could be flung kilometers — or, at the extreme, continent-scale — from the point it reported. Markers at 15+ bits are unchanged, and the drawn accuracy rectangle stays honest (only the marker offset is bounded, so it still lands inside the square). Because a node is only offset when it shares a cell with others of the *same* precision, two overlapping but differently-sized accuracy regions are never pushed closer together than their true centers. (#4155)
1820

docs/features/meshcore.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,8 @@ Received channel and DM messages show a route line beneath the body, derived fro
165165

166166
**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.
167167

168+
When **every** relay hop resolves to a contact with a known position, the popup also shows a **Packet flow** mini map: a dashed line tracing the packet geospatially across the relays, each hop marked and labeled (`#1 Hilltop`, `#2 Downtown`, …). The sender and your local node are added as endpoints when their positions are known. If any hop is unknown or unpositioned the map is omitted entirely — a partial line would misrepresent the path.
169+
168170
Room-server posts and messages with no recoverable path show no route line.
169171

170172
### Send bar

src/components/MQTT/MqttPacketDetailModal.tsx

Lines changed: 15 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
import React, { useCallback, useEffect, useState } from 'react';
1111
import { useTranslation } from 'react-i18next';
1212
import { useCsrfFetch } from '../../hooks/useCsrfFetch';
13+
import { useDialogA11y } from '../../hooks/useDialogA11y';
1314
import type { MqttGroupedPacket, MqttReception } from './mqttPacketTypes';
1415

1516
interface Props {
@@ -55,6 +56,8 @@ function formatHeard(ts: number): string {
5556

5657
const MqttPacketDetailModal: React.FC<Props> = ({ packet, prefix, csrfFetch, nodeName, onClose }) => {
5758
const { t } = useTranslation();
59+
// Escape / focus-trap / focus-restore + dialog semantics (shared a11y hook).
60+
const { contentRef, onKeyDown } = useDialogA11y(onClose);
5861

5962
const [receptions, setReceptions] = useState<MqttReception[]>([]);
6063
const [loadingReceptions, setLoadingReceptions] = useState(false);
@@ -94,16 +97,6 @@ const MqttPacketDetailModal: React.FC<Props> = ({ packet, prefix, csrfFetch, nod
9497
return () => { cancelled = true; };
9598
}, [canFetch, packet.packetId, packet.fromNode, csrfFetch, prefix]);
9699

97-
// Close on Escape, matching the MeshCore modal's overlay-click-to-close
98-
// behavior plus keyboard dismissal.
99-
useEffect(() => {
100-
const handleKeyDown = (e: KeyboardEvent) => {
101-
if (e.key === 'Escape') onClose();
102-
};
103-
document.addEventListener('keydown', handleKeyDown);
104-
return () => document.removeEventListener('keydown', handleKeyDown);
105-
}, [onClose]);
106-
107100
const renderNodeRef = useCallback((n: number | null, id: string | null): string => {
108101
if (n === BROADCAST_NODE_NUM || id === '!ffffffff') return t('common.broadcast', 'Broadcast');
109102
const name = nodeName(n);
@@ -114,10 +107,19 @@ const MqttPacketDetailModal: React.FC<Props> = ({ packet, prefix, csrfFetch, nod
114107
const showOutcomeBadge = packet.encrypted && !packet.portnumName && ENCRYPTED_OUTCOME_BADGES.has(packet.ingestOutcome);
115108

116109
return (
117-
<div className="mqpm-modal" onClick={onClose}>
118-
<div className="mqpm-modal-content" onClick={(e) => e.stopPropagation()}>
110+
<div className="mqpm-modal" onClick={onClose} role="presentation">
111+
<div
112+
className="mqpm-modal-content"
113+
ref={contentRef}
114+
role="dialog"
115+
aria-modal="true"
116+
aria-labelledby="mqpm-detail-title"
117+
tabIndex={-1}
118+
onKeyDown={onKeyDown}
119+
onClick={(e) => e.stopPropagation()}
120+
>
119121
<div className="mqpm-modal-header">
120-
<h4>{t('mqtt.packets.detailTitle', 'Packet Detail')}</h4>
122+
<h4 id="mqpm-detail-title">{t('mqtt.packets.detailTitle', 'Packet Detail')}</h4>
121123
<button className="mqpm-modal-close" onClick={onClose} aria-label={t('common.close', 'Close')}>×</button>
122124
</div>
123125

src/components/MeshCore/MeshCoreMessageRouteModal.test.tsx

Lines changed: 104 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,11 @@
55
* relay-hash chain opens this popup, which shows message details and expands
66
* each relay hash to the matching repeater / room-server name (mirroring the
77
* {ROUTE_NAMES} automation token — unknown hashes stay raw, collisions are
8-
* annotated as a best guess).
8+
* annotated as a best guess). When every hop resolves to a positioned
9+
* contact, a mini map traces the packet flow with labeled hop markers
10+
* (BaseMap/react-leaflet stubbed — mirrors the MeshCoreMap.test.tsx style).
911
*/
12+
import type { ReactNode } from 'react';
1013
import { describe, it, expect, vi } from 'vitest';
1114
import { render, screen, fireEvent } from '@testing-library/react';
1215

@@ -21,6 +24,17 @@ vi.mock('react-i18next', () => ({
2124
initReactI18next: { type: '3rdParty', init: () => {} },
2225
}));
2326

27+
vi.mock('../map/BaseMap', () => ({
28+
BaseMap: ({ children }: { children?: ReactNode }) => <div data-testid="base-map">{children}</div>,
29+
}));
30+
31+
vi.mock('react-leaflet', () => ({
32+
CircleMarker: ({ children }: { children?: ReactNode }) => <div data-testid="flow-marker">{children}</div>,
33+
Polyline: () => <div data-testid="flow-line" />,
34+
Tooltip: ({ children }: { children?: ReactNode }) => <span data-testid="flow-label">{children}</span>,
35+
useMap: () => ({ setView: vi.fn(), fitBounds: vi.fn() }),
36+
}));
37+
2438
import MeshCoreMessageRouteModal from './MeshCoreMessageRouteModal';
2539
import type { MeshCoreMessage } from './hooks/useMeshCore';
2640
import type { MeshCoreContact } from '../../utils/meshcoreHelpers';
@@ -114,4 +128,93 @@ describe('MeshCoreMessageRouteModal', () => {
114128
fireEvent.click(container.querySelector('.mcpm-modal')!);
115129
expect(onClose).toHaveBeenCalledTimes(2);
116130
});
131+
132+
it('is an accessible dialog: role/aria set, focused on mount, Escape closes', () => {
133+
const onClose = vi.fn();
134+
const { container } = render(
135+
<MeshCoreMessageRouteModal
136+
message={msg()}
137+
fromLabel="Sender"
138+
contacts={contacts}
139+
onClose={onClose}
140+
/>,
141+
);
142+
const content = container.querySelector('.mcpm-modal-content')!;
143+
expect(content.getAttribute('role')).toBe('dialog');
144+
expect(content.getAttribute('aria-modal')).toBe('true');
145+
expect(document.activeElement).toBe(content);
146+
fireEvent.keyDown(document, { key: 'Escape' });
147+
expect(onClose).toHaveBeenCalledTimes(1);
148+
});
149+
150+
describe('packet-flow map', () => {
151+
const positioned: MeshCoreContact[] = [
152+
{ publicKey: 'a3' + 'b'.repeat(62), advType: 2, advName: 'Hilltop', latitude: 30.0, longitude: -90.0 },
153+
{ publicKey: '7f' + 'c'.repeat(62), advType: 3, advName: 'Downtown Room', latitude: 30.0, longitude: -89.9 },
154+
];
155+
156+
it('renders the flow map with numbered hop labels when all hops have positions', () => {
157+
render(
158+
<MeshCoreMessageRouteModal
159+
message={msg()}
160+
fromLabel="Sender"
161+
contacts={positioned}
162+
onClose={() => {}}
163+
/>,
164+
);
165+
expect(screen.getByTestId('base-map')).toBeTruthy();
166+
expect(screen.getByTestId('flow-line')).toBeTruthy();
167+
const labels = screen.getAllByTestId('flow-label').map((el) => el.textContent);
168+
expect(labels).toContain('#1 Hilltop');
169+
expect(labels).toContain('#2 Downtown Room');
170+
});
171+
172+
it('prepends the sender and appends the local node when they have positions', () => {
173+
const withEndpoints: MeshCoreContact[] = [
174+
...positioned,
175+
{ publicKey: 'deadbeefcafe' + 'e'.repeat(52), advType: 1, advName: 'Sender Node', latitude: 30.0, longitude: -90.1 },
176+
{ publicKey: '99' + 'f'.repeat(62), advType: 1, advName: 'Base (local)', latitude: 30.0, longitude: -89.8 },
177+
];
178+
render(
179+
<MeshCoreMessageRouteModal
180+
message={msg()}
181+
fromLabel="Sender"
182+
contacts={withEndpoints}
183+
onClose={() => {}}
184+
/>,
185+
);
186+
const labels = screen.getAllByTestId('flow-label').map((el) => el.textContent);
187+
expect(labels[0]).toBe('Sender');
188+
expect(labels[labels.length - 1]).toBe('You');
189+
expect(labels).toHaveLength(4);
190+
});
191+
192+
it('shows no map when any hop lacks a position', () => {
193+
const partial: MeshCoreContact[] = [
194+
{ publicKey: 'a3' + 'b'.repeat(62), advType: 2, advName: 'Hilltop', latitude: 30.0, longitude: -90.0 },
195+
{ publicKey: '7f' + 'c'.repeat(62), advType: 3, advName: 'Downtown Room' }, // no position
196+
];
197+
render(
198+
<MeshCoreMessageRouteModal
199+
message={msg()}
200+
fromLabel="Sender"
201+
contacts={partial}
202+
onClose={() => {}}
203+
/>,
204+
);
205+
expect(screen.queryByTestId('base-map')).toBeNull();
206+
});
207+
208+
it('shows no map when a hop is unknown, even if others are positioned', () => {
209+
render(
210+
<MeshCoreMessageRouteModal
211+
message={msg({ routePath: 'a3,ff' })}
212+
fromLabel="Sender"
213+
contacts={positioned}
214+
onClose={() => {}}
215+
/>,
216+
);
217+
expect(screen.queryByTestId('base-map')).toBeNull();
218+
});
219+
});
117220
});

0 commit comments

Comments
 (0)