diff --git a/public/hop-display.js b/public/hop-display.js index 1396177..19c8ba9 100644 --- a/public/hop-display.js +++ b/public/hop-display.js @@ -7,11 +7,58 @@ window.HopDisplay = (function() { return String(s).replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"'); } + // Dismiss any open conflict popover + function dismissPopover() { + const old = document.querySelector('.hop-conflict-popover'); + if (old) old.remove(); + } + + // Global click handler to dismiss popovers + let _listenerAttached = false; + function ensureGlobalListener() { + if (_listenerAttached) return; + _listenerAttached = true; + document.addEventListener('click', (e) => { + if (!e.target.closest('.hop-conflict-popover') && !e.target.closest('.hop-conflict-btn')) { + dismissPopover(); + } + }); + } + + function showConflictPopover(btn, h, conflicts, globalFallback) { + dismissPopover(); + ensureGlobalListener(); + + const regional = conflicts.filter(c => c.regional); + const shown = regional.length > 0 ? regional : conflicts; + + let html = `
${escapeHtml(h)} — ${shown.length} candidate${shown.length > 1 ? 's' : ''}${regional.length > 0 ? ' in region' : ' (global fallback)'}
`; + html += '
'; + for (const c of shown) { + const name = escapeHtml(c.name || c.pubkey?.slice(0, 16) || '?'); + const dist = c.distKm != null ? `${c.distKm}km` : ''; + const pk = c.pubkey ? c.pubkey.slice(0, 12) + '…' : ''; + html += ` + ${name} + ${dist} + ${pk} + `; + } + html += '
'; + + const popover = document.createElement('div'); + popover.className = 'hop-conflict-popover'; + popover.innerHTML = html; + document.body.appendChild(popover); + + // Position near the button + const rect = btn.getBoundingClientRect(); + popover.style.top = (rect.bottom + window.scrollY + 4) + 'px'; + popover.style.left = Math.max(8, Math.min(rect.left + window.scrollX - 60, window.innerWidth - 280)) + 'px'; + } + /** * Render a hop prefix as HTML with conflict info. - * @param {string} h - hex hop prefix - * @param {Object} [entry] - resolved hop entry from HopResolver or hopNameCache - * @param {Object} [opts] - { link: true, truncate: 0 } */ function renderHop(h, entry, opts) { opts = opts || {}; @@ -22,33 +69,20 @@ window.HopDisplay = (function() { const pubkey = entry.pubkey || h; const ambiguous = entry.ambiguous || false; const conflicts = entry.conflicts || []; - const totalGlobal = entry.totalGlobal || conflicts.length; - const totalRegional = entry.totalRegional || 0; const globalFallback = entry.globalFallback || false; const unreliable = entry.unreliable || false; const display = opts.hexMode ? h : (name ? escapeHtml(opts.truncate ? name.slice(0, opts.truncate) : name) : h); - // Build tooltip — only show regional candidates (global is noise) + // Simple title for the hop link itself let title = h; - if (conflicts.length > 0) { - const regional = conflicts.filter(c => c.regional); - const shown = regional.length > 0 ? regional : conflicts; // fall back to all if no regional - const lines = shown.map(c => { - let line = c.name || c.pubkey?.slice(0, 12) || '?'; - if (c.distKm != null) line += ` (${c.distKm}km)`; - return line; - }); - const label = regional.length > 0 ? `${regional.length} in region` : `${conflicts.length} global`; - title = `${h} — ${label}:\n${lines.join('\n')}`; - } - if (unreliable) title += '\n✗ Unreliable — too far from neighbors'; - if (globalFallback) title += '\n⚑ No regional candidates'; + if (unreliable) title += ' — unreliable'; // Badge — only count regional conflicts const regionalConflicts = conflicts.filter(c => c.regional); const badgeCount = regionalConflicts.length > 0 ? regionalConflicts.length : (globalFallback ? conflicts.length : 0); + const conflictData = escapeHtml(JSON.stringify({ h, conflicts, globalFallback })); const warnBadge = badgeCount > 1 - ? `⚠${badgeCount}` + ? ` ` : ''; const cls = [ @@ -60,16 +94,13 @@ window.HopDisplay = (function() { ].filter(Boolean).join(' '); if (opts.link !== false) { - return `${display}${warnBadge}`; + return `${display}${warnBadge}`; } - return `${display}${warnBadge}`; + return `${display}${warnBadge}`; } /** * Render a full path as HTML. - * @param {string[]} hops - array of hex prefixes - * @param {Object} cache - hop name cache (hop → entry) - * @param {Object} [opts] - { link: true, separator: ' → ' } */ function renderPath(hops, cache, opts) { opts = opts || {}; @@ -78,5 +109,13 @@ window.HopDisplay = (function() { return hops.filter(Boolean).map(h => renderHop(h, cache[h], opts)).join(sep); } - return { renderHop, renderPath }; + // Called from inline onclick + function _showFromBtn(btn) { + try { + const data = JSON.parse(btn.dataset.conflict); + showConflictPopover(btn, data.h, data.conflicts, data.globalFallback); + } catch (e) { console.error('Conflict popover error:', e); } + } + + return { renderHop, renderPath, _showFromBtn }; })(); diff --git a/public/index.html b/public/index.html index 473548b..6a9ad31 100644 --- a/public/index.html +++ b/public/index.html @@ -22,7 +22,7 @@ - + - + diff --git a/public/style.css b/public/style.css index 8422712..00e356c 100644 --- a/public/style.css +++ b/public/style.css @@ -1225,6 +1225,21 @@ button.ch-item.ch-item-encrypted .ch-badge { filter: grayscale(0.6); } /* Ambiguous hop indicator */ .hop-ambiguous { border-bottom: 1px dashed #f59e0b; } .hop-warn { font-size: 0.7em; margin-left: 2px; vertical-align: super; color: #f59e0b; } +.hop-conflict-btn { background: #f59e0b; color: #000; border: none; border-radius: 4px; font-size: 11px; + font-weight: 700; padding: 1px 5px; cursor: pointer; vertical-align: middle; margin-left: 3px; line-height: 1.2; } +.hop-conflict-btn:hover { background: #d97706; } +.hop-conflict-popover { position: absolute; z-index: 9999; background: var(--surface-1); border: 1px solid var(--border); + border-radius: 8px; box-shadow: 0 8px 24px rgba(0,0,0,0.25); width: 260px; max-height: 300px; overflow-y: auto; } +.hop-conflict-header { padding: 10px 12px; font-size: 12px; font-weight: 700; border-bottom: 1px solid var(--border); + color: var(--text-muted); } +.hop-conflict-list { padding: 4px 0; } +.hop-conflict-item { display: flex; align-items: center; gap: 8px; padding: 8px 12px; text-decoration: none; + color: var(--text); font-size: 13px; border-bottom: 1px solid var(--border); } +.hop-conflict-item:last-child { border-bottom: none; } +.hop-conflict-item:hover { background: var(--hover-bg); } +.hop-conflict-name { font-weight: 600; flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.hop-conflict-dist { font-size: 11px; color: var(--text-muted); font-family: var(--mono); white-space: nowrap; } +.hop-conflict-pk { font-size: 10px; color: var(--text-muted); font-family: var(--mono); } .hop-unreliable { opacity: 0.5; text-decoration: line-through; } .hop-global-fallback { border-bottom: 1px dashed #ef4444; } .hop-current { font-weight: 700 !important; color: var(--accent) !important; }