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'; } }); }