diff --git a/app/static/css/style.css b/app/static/css/style.css
index 56059ca..cd62f62 100644
--- a/app/static/css/style.css
+++ b/app/static/css/style.css
@@ -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 {
diff --git a/app/static/js/app.js b/app/static/js/app.js
index 15d2844..76b1c52 100644
--- a/app/static/js/app.js
+++ b/app/static/js/app.js
@@ -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(`${routeLabel}: ${shortPath}`);
+ metaParts.push(`${routeLabel}: ${shortPath}`);
}
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(`${routeLabel}: ${shortPath}`);
+ metaParts.push(`${routeLabel}: ${shortPath}`);
}
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}SNR: ${snr} | Hops: ${hops}`;
- entry.title = 'Tap to copy route';
- entry.addEventListener('click', (e) => {
+
+ const body = document.createElement('span');
+ body.className = 'path-route';
+ body.innerHTML = `${fullRoute}SNR: ${snr} | Hops: ${hops}`;
+ 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 = 'Copied!';
- 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
*/
diff --git a/app/static/js/path-analyzer.js b/app/static/js/path-analyzer.js
index 1a692d6..2fbd252 100644
--- a/app/static/js/path-analyzer.js
+++ b/app/static/js/path-analyzer.js
@@ -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();
});
diff --git a/app/templates/index.html b/app/templates/index.html
index 40dfeaf..03aa6a4 100644
--- a/app/templates/index.html
+++ b/app/templates/index.html
@@ -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';
}
});
}