feat(pathanalyzer): chat route click opens the analyzer map deep-linked

Clicking a route in the chat path popup now opens Path Analyzer on the
Map view with that message selected and that exact echo path drawn,
instead of copying the route to the clipboard. Copy stays available as
a small per-route clipboard icon in the popup.

Deep link flow: popup click stores {packet_hash, path hex} in
window.paDeepLink; the modal show handler builds the iframe URL with
?hash=&path=; the analyzer resolves the message after load (widening
the time range once to 7 days if needed), matches the echo by raw path
hex (fallback: shortest), and switches to the map. A plain menu open
still loads the analyzer without any deep link.

Verified live via Playwright: clicked the 3rd (non-shortest) route of a
multi-route message - the map opened with exactly that echo selected,
including the 3-to-7-day widening retry (message was 4 days old).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
MarekWo
2026-07-23 07:10:31 +02:00
parent bb67c4d3ea
commit d8d3427dc9
4 changed files with 121 additions and 11 deletions
+19
View File
@@ -1557,6 +1557,25 @@ main {
border-bottom: 1px solid rgba(255, 255, 255, 0.15);
word-break: break-all;
cursor: pointer;
display: flex;
align-items: flex-start;
gap: 0.4rem;
}
.path-popup .path-route {
flex: 1 1 auto;
min-width: 0;
}
.path-popup .path-copy {
flex: 0 0 auto;
padding: 0.1rem 0.15rem;
opacity: 0.7;
cursor: pointer;
}
.path-popup .path-copy:hover {
opacity: 1;
}
.path-popup .path-entry:active {
+47 -9
View File
@@ -1187,7 +1187,7 @@ function updateMessageMetaDOM(wrapper, meta) {
: segments.join('\u2192');
const pathsData = encodeURIComponent(JSON.stringify(paths));
const routeLabel = paths.length > 1 ? `Route (${paths.length})` : 'Route';
metaParts.push(`<span class="path-info" onclick="showPathsPopup(this, '${pathsData}')">${routeLabel}: ${shortPath}</span>`);
metaParts.push(`<span class="path-info" onclick="showPathsPopup(this, '${pathsData}', '${meta.packet_hash || ''}')">${routeLabel}: ${shortPath}</span>`);
}
const metaInfo = metaParts.join(' | ');
@@ -1329,7 +1329,7 @@ function createMessageElement(msg) {
: segments.join('\u2192');
const pathsData = encodeURIComponent(JSON.stringify(msg.paths));
const routeLabel = msg.paths.length > 1 ? `Route (${msg.paths.length})` : 'Route';
metaParts.push(`<span class="path-info" onclick="showPathsPopup(this, '${pathsData}')">${routeLabel}: ${shortPath}</span>`);
metaParts.push(`<span class="path-info" onclick="showPathsPopup(this, '${pathsData}', '${msg.packet_hash || ''}')">${routeLabel}: ${shortPath}</span>`);
}
const metaInfo = metaParts.join(' | ');
@@ -1693,7 +1693,7 @@ async function blockContactFromChat(senderName) {
/**
* Show paths popup on tap (mobile-friendly, shows all routes)
*/
function showPathsPopup(element, encodedPaths) {
function showPathsPopup(element, encodedPaths, packetHash) {
// Remove any existing popup
const existing = document.querySelector('.path-popup');
if (existing) existing.remove();
@@ -1717,16 +1717,42 @@ function showPathsPopup(element, encodedPaths) {
const hops = segments.length;
const entry = document.createElement('div');
entry.className = 'path-entry';
entry.innerHTML = `${fullRoute}<span class="path-detail">SNR: ${snr} | Hops: ${hops}</span>`;
entry.title = 'Tap to copy route';
entry.addEventListener('click', (e) => {
const body = document.createElement('span');
body.className = 'path-route';
body.innerHTML = `${fullRoute}<span class="path-detail">SNR: ${snr} | Hops: ${hops}</span>`;
entry.appendChild(body);
const copyBtn = document.createElement('i');
copyBtn.className = 'bi bi-clipboard path-copy';
copyBtn.title = 'Copy route';
copyBtn.addEventListener('click', (e) => {
e.stopPropagation();
navigator.clipboard.writeText(commaRoute).then(() => {
const orig = entry.innerHTML;
entry.innerHTML = '<span style="opacity:0.8">Copied!</span>';
setTimeout(() => { entry.innerHTML = orig; }, 1000);
copyBtn.className = 'bi bi-clipboard-check path-copy';
setTimeout(() => { copyBtn.className = 'bi bi-clipboard path-copy'; }, 1000);
});
});
entry.appendChild(copyBtn);
if (packetHash && p.path && segments.length > 0) {
entry.title = 'Show this route on the Path Analyzer map';
entry.addEventListener('click', (e) => {
e.stopPropagation();
popup.remove();
openPathInAnalyzer(packetHash, p.path);
});
} else {
// No packet hash (or direct message): keep the copy behavior
entry.title = 'Tap to copy route';
entry.addEventListener('click', (e) => {
e.stopPropagation();
navigator.clipboard.writeText(commaRoute).then(() => {
copyBtn.className = 'bi bi-clipboard-check path-copy';
setTimeout(() => { copyBtn.className = 'bi bi-clipboard path-copy'; }, 1000);
});
});
}
popup.appendChild(entry);
});
@@ -1757,6 +1783,18 @@ function showPathsPopup(element, encodedPaths) {
});
}
/**
* Open the Path Analyzer modal deep-linked to one message + echo path.
* The modal's show handler (index.html) reads window.paDeepLink and builds
* the iframe URL from it.
*/
function openPathInAnalyzer(packetHash, pathHex) {
const modalEl = document.getElementById('pathAnalyzerModal');
if (!modalEl) return;
window.paDeepLink = { hash: packetHash, path: pathHex };
bootstrap.Modal.getOrCreateInstance(modalEl).show();
}
/**
* Load connection status
*/
+48 -1
View File
@@ -88,6 +88,8 @@ let paCurrentView = 'messages'; // 'messages' | 'stats' | 'routes' | 'map'
let paContacts = []; // /api/contacts/cached?format=full
let paStatsSort = { key: 'relayed', dir: -1 };
let paRoutesSort = { key: 'echoes', dir: -1 };
let paDeepLink = null; // ?hash=..&path=.. from a chat path popup
let paContactsReady = Promise.resolve();
async function paLoadContacts() {
try {
@@ -978,6 +980,45 @@ async function paLoadMessages() {
} else {
paRender();
}
if (paDeepLink) paApplyDeepLink();
}
// Deep link from the chat path popup: jump to the map view with the
// linked message selected and the linked echo path drawn.
async function paApplyDeepLink() {
const dl = paDeepLink;
const hash = (dl.hash || '').toLowerCase();
const msg = paMessages.find(m => (m.packet_hash || '').toLowerCase() === hash);
if (!msg) {
// The chat can show messages older than the default window - widen
// to the max range once before giving up
const daysSel = document.getElementById('paDaysSelect');
if (!dl.retried && daysSel.value !== '7') {
dl.retried = true;
daysSel.value = '7';
paLoadMessages(); // re-enters paApplyDeepLink when done
return;
}
paDeepLink = null;
showNotification('This message is no longer in the analyzer data (max 7 days).', 'warning');
return;
}
paDeepLink = null;
await paContactsReady; // map needs contacts to resolve hop positions
let echoIdx = msg.echoView.findIndex(e =>
e.hops > 0 && (e.path || '').toLowerCase() === (dl.path || '').toLowerCase());
if (echoIdx === -1) echoIdx = paShortestEchoIdx(msg);
if (echoIdx === null) {
showNotification('This message has no routed echoes to draw.', 'warning');
return;
}
paMapSelection = { msgId: msg.id, echoIdx: echoIdx };
paSwitchView('map');
}
// ================================================================
@@ -1024,6 +1065,12 @@ function paClearFilters() {
document.addEventListener('DOMContentLoaded', () => {
loadUiSettings();
const qs = new URLSearchParams(window.location.search);
if (qs.get('hash')) {
paDeepLink = { hash: qs.get('hash'), path: qs.get('path') || '' };
}
document.getElementById('paDaysSelect').addEventListener('change', paLoadMessages);
document.getElementById('paRefreshBtn').addEventListener('click', paLoadMessages);
@@ -1068,6 +1115,6 @@ document.addEventListener('DOMContentLoaded', () => {
});
});
paLoadContacts();
paContactsReady = paLoadContacts();
paLoadMessages();
});
+7 -1
View File
@@ -350,7 +350,13 @@
pathAnalyzerModal.addEventListener('show.bs.modal', function () {
const pathAnalyzerFrame = document.getElementById('pathAnalyzerFrame');
if (pathAnalyzerFrame) {
pathAnalyzerFrame.src = '/path-analyzer';
// Deep link from a chat path popup (openPathInAnalyzer):
// preselect the message + echo on the map view
const dl = window.paDeepLink;
window.paDeepLink = null;
pathAnalyzerFrame.src = dl
? `/path-analyzer?hash=${encodeURIComponent(dl.hash)}&path=${encodeURIComponent(dl.path)}`
: '/path-analyzer';
}
});
}