/* === CoreScope — channels.js === */ 'use strict'; (function () { let channels = []; let selectedHash = null; let messages = []; let wsHandler = null; let autoScroll = true; let nodeCache = {}; let selectedNode = null; let observerIataById = {}; let observerIataByName = {}; let messageRequestId = 0; var _nodeCacheTTL = 5 * 60 * 1000; // 5 minutes function getSelectedRegionsSnapshot() { var rp = RegionFilter.getRegionParam(); return rp ? rp.split(',').filter(Boolean) : null; } function normalizeObserverNameKey(name) { if (!name) return ''; return String(name).trim().toLowerCase(); } function shouldProcessWSMessageForRegion(msg, selectedRegions, observerRegionsById, observerRegionsByName) { if (!selectedRegions || !selectedRegions.length) return true; if (observerRegionsById && observerRegionsById.byId) { observerRegionsByName = observerRegionsById.byName || {}; observerRegionsById = observerRegionsById.byId || {}; } observerRegionsById = observerRegionsById || {}; observerRegionsByName = observerRegionsByName || {}; var observerId = msg?.data?.packet?.observer_id || msg?.data?.observer_id || null; var observerRegion = observerId ? observerRegionsById[observerId] : null; if (!observerRegion) { var observerName = msg?.data?.packet?.observer_name || msg?.data?.observer_name || msg?.data?.observer || null; var observerNameKey = normalizeObserverNameKey(observerName); if (observerName) observerRegion = observerRegionsByName[observerName]; if (!observerRegion && observerNameKey) observerRegion = observerRegionsByName[observerNameKey]; } if (!observerRegion) return false; return selectedRegions.indexOf(observerRegion) !== -1; } async function loadObserverRegions() { try { var data = await api('/observers', { ttl: CLIENT_TTL.observers }); var list = data && data.observers ? data.observers : []; var byId = {}; var byName = {}; for (var i = 0; i < list.length; i++) { var o = list[i]; var id = o.id || o.observer_id; var name = o.name || o.observer_name; if (!o.iata) continue; if (id) byId[id] = o.iata; if (name) { byName[name] = o.iata; var key = normalizeObserverNameKey(name); if (key) byName[key] = o.iata; } } observerIataById = byId; observerIataByName = byName; } catch {} } function beginMessageRequest(hash, regionParam) { return { id: ++messageRequestId, hash: hash, regionParam: regionParam || '' }; } function isStaleMessageRequest(req) { if (!req) return true; var currentRegion = RegionFilter.getRegionParam() || ''; if (req.id !== messageRequestId) return true; if (selectedHash !== req.hash) return true; if (currentRegion !== req.regionParam) return true; return false; } function reconcileSelectionAfterChannelRefresh() { if (!selectedHash || channels.some(ch => ch.hash === selectedHash)) return false; selectedHash = null; messages = []; history.replaceState(null, '', '#/channels'); renderChannelList(); const header = document.getElementById('chHeader'); if (header) header.querySelector('.ch-header-text').textContent = 'Select a channel'; const msgEl = document.getElementById('chMessages'); if (msgEl) msgEl.innerHTML = '
Choose a channel from the sidebar to view messages
'; document.getElementById('chScrollBtn')?.classList.add('hidden'); return true; } async function lookupNode(name) { var cached = nodeCache[name]; if (cached !== undefined) { if (cached && cached.fetchedAt && (Date.now() - cached.fetchedAt < _nodeCacheTTL)) return cached.data; if (cached && !cached.fetchedAt) return cached; // legacy null entries } try { const data = await api('/nodes/search?q=' + encodeURIComponent(name), { ttl: CLIENT_TTL.channelMessages }); // Try exact match first, then case-insensitive, then contains const nodes = data.nodes || []; const match = nodes.find(n => n.name === name) || nodes.find(n => n.name && n.name.toLowerCase() === name.toLowerCase()) || nodes.find(n => n.name && n.name.toLowerCase().includes(name.toLowerCase())) || nodes[0] || null; nodeCache[name] = { data: match, fetchedAt: Date.now() }; return match; } catch { nodeCache[name] = null; return null; } } async function showNodeTooltip(e, name) { const node = await lookupNode(name); let existing = document.getElementById('chNodeTooltip'); if (existing) existing.remove(); if (!node) return; const tip = document.createElement('div'); tip.id = 'chNodeTooltip'; tip.className = 'ch-node-tooltip'; tip.setAttribute('role', 'tooltip'); const roleKey = node.role || (node.is_repeater ? 'repeater' : node.is_room ? 'room' : node.is_sensor ? 'sensor' : 'companion'); const role = (ROLE_EMOJI[roleKey] || '●') + ' ' + (ROLE_LABELS[roleKey] || roleKey); const lastActivity = node.last_heard || node.last_seen; const lastSeen = lastActivity ? timeAgo(lastActivity) : 'unknown'; tip.innerHTML = `
${escapeHtml(node.name)}
${role}
Last seen: ${lastSeen}
${(node.public_key || '').slice(0, 16)}…
`; document.body.appendChild(tip); var trigger = e.target.closest('[data-node]') || e.target; trigger.setAttribute('aria-describedby', 'chNodeTooltip'); const rect = trigger.getBoundingClientRect(); tip.style.left = Math.min(rect.left, window.innerWidth - 220) + 'px'; tip.style.top = (rect.bottom + 4) + 'px'; } function hideNodeTooltip() { var trigger = document.querySelector('[aria-describedby="chNodeTooltip"]'); if (trigger) trigger.removeAttribute('aria-describedby'); const tip = document.getElementById('chNodeTooltip'); if (tip) tip.remove(); } let _focusTrapCleanup = null; let _nodePanelTrigger = null; function trapFocus(container) { function handler(e) { if (e.key === 'Escape') { closeNodeDetail(); return; } if (e.key !== 'Tab') return; const focusable = container.querySelectorAll('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'); if (!focusable.length) return; const first = focusable[0], last = focusable[focusable.length - 1]; if (e.shiftKey) { if (document.activeElement === first) { e.preventDefault(); last.focus(); } } else { if (document.activeElement === last) { e.preventDefault(); first.focus(); } } } container.addEventListener('keydown', handler); return function () { container.removeEventListener('keydown', handler); }; } async function showNodeDetail(name) { _nodePanelTrigger = document.activeElement; if (_focusTrapCleanup) { _focusTrapCleanup(); _focusTrapCleanup = null; } var _capturedHash = selectedHash; const node = await lookupNode(name); selectedNode = name; var _chBase = _capturedHash ? '#/channels/' + encodeURIComponent(_capturedHash) : '#/channels'; history.replaceState(null, '', _chBase + '?node=' + encodeURIComponent(name)); let panel = document.getElementById('chNodePanel'); if (!panel) { panel = document.createElement('div'); panel.id = 'chNodePanel'; panel.className = 'ch-node-panel'; document.querySelector('.ch-main').appendChild(panel); } panel.classList.add('open'); if (!node) { panel.innerHTML = `
${escapeHtml(name)}
No node record found — this sender has only been seen in channel messages, not via adverts.
`; _focusTrapCleanup = trapFocus(panel); panel.querySelector('.ch-node-close')?.focus(); return; } try { const detail = await api('/nodes/' + encodeURIComponent(node.public_key), { ttl: CLIENT_TTL.nodeDetail }); const n = detail.node; const adverts = detail.recentAdverts || []; const roleKey = n.role || (n.is_repeater ? 'repeater' : n.is_room ? 'room' : n.is_sensor ? 'sensor' : 'companion'); const role = (ROLE_EMOJI[roleKey] || '●') + ' ' + (ROLE_LABELS[roleKey] || roleKey); const lastActivity = n.last_heard || n.last_seen; const lastSeen = lastActivity ? timeAgo(lastActivity) : 'unknown'; panel.innerHTML = `
${escapeHtml(n.name || 'Unknown')}
Role ${role}
Last Seen ${lastSeen}
Adverts ${n.advert_count || 0}
${n.lat && n.lon ? `
Location ${Number(n.lat).toFixed(4)}, ${Number(n.lon).toFixed(4)}
` : ''}
Key ${n.public_key}
${adverts.length ? `
Recent Adverts ${adverts.slice(0, 5).map(a => `
${timeAgo(a.timestamp)} · SNR ${a.snr != null ? a.snr + 'dB' : '?'}
`).join('')}
` : ''} View full node detail →
`; _focusTrapCleanup = trapFocus(panel); panel.querySelector('.ch-node-close')?.focus(); } catch (e) { panel.innerHTML = `
${escapeHtml(name)}
Failed to load
`; _focusTrapCleanup = trapFocus(panel); panel.querySelector('.ch-node-close')?.focus(); } } function closeNodeDetail() { if (_focusTrapCleanup) { _focusTrapCleanup(); _focusTrapCleanup = null; } const panel = document.getElementById('chNodePanel'); if (panel) panel.classList.remove('open'); selectedNode = null; var _chRestoreUrl = selectedHash ? '#/channels/' + encodeURIComponent(selectedHash) : '#/channels'; history.replaceState(null, '', _chRestoreUrl); if (_nodePanelTrigger && typeof _nodePanelTrigger.focus === 'function') { _nodePanelTrigger.focus(); _nodePanelTrigger = null; } } // WCAG AA compliant colors — ≥4.5:1 contrast on both white and dark backgrounds // Channel badge colors (white text on colored background) const CHANNEL_COLORS = [ '#1d4ed8', '#b91c1c', '#15803d', '#b45309', '#7e22ce', '#0e7490', '#a16207', '#0f766e', '#be185d', '#1e40af', ]; // Sender name colors — must be readable on --card-bg (light: ~#fff, dark: ~#1e293b) // Using CSS vars via inline style would be ideal, but these are reasonable middle-ground // Light mode bg ~white: need dark enough. Dark mode bg ~#1e293b: need light enough. // Solution: use medium-bright saturated colors that work on both. const SENDER_COLORS_LIGHT = [ '#16a34a', '#2563eb', '#db2777', '#ca8a04', '#7c3aed', '#0d9488', '#ea580c', '#c026d3', '#0284c7', '#dc2626', '#059669', '#4f46e5', '#e11d48', '#d97706', '#9333ea', ]; const SENDER_COLORS_DARK = [ '#4ade80', '#60a5fa', '#f472b6', '#facc15', '#a78bfa', '#2dd4bf', '#fb923c', '#e879f9', '#38bdf8', '#f87171', '#34d399', '#818cf8', '#fb7185', '#fbbf24', '#c084fc', ]; function hashCode(str) { let h = 0; for (let i = 0; i < str.length; i++) h = ((h << 5) - h + str.charCodeAt(i)) | 0; return Math.abs(h); } function formatHashHex(hash) { return typeof hash === 'number' ? '0x' + hash.toString(16).toUpperCase().padStart(2, '0') : hash; } function getChannelColor(hash) { return CHANNEL_COLORS[hashCode(String(hash)) % CHANNEL_COLORS.length]; } function getSenderColor(name) { const isDark = document.documentElement.getAttribute('data-theme') === 'dark' || (!document.documentElement.getAttribute('data-theme') && window.matchMedia('(prefers-color-scheme: dark)').matches); const palette = isDark ? SENDER_COLORS_DARK : SENDER_COLORS_LIGHT; return palette[hashCode(String(name)) % palette.length]; } function escapeHtml(str) { if (!str) return ''; return str.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); } function truncate(str, len) { if (!str) return ''; return str.length > len ? str.slice(0, len) + '…' : str; } function formatSecondsAgo(sec) { if (sec < 0) sec = 0; if (sec < 60) return sec + 's ago'; if (sec < 3600) return Math.floor(sec / 60) + 'm ago'; if (sec < 86400) return Math.floor(sec / 3600) + 'h ago'; return Math.floor(sec / 86400) + 'd ago'; } function highlightMentions(text) { if (!text) return ''; return escapeHtml(text).replace(/@\[([^\]]+)\]/g, function(_, name) { const safeId = btoa(encodeURIComponent(name)); return '@' + name + ''; }); } let regionChangeHandler = null; // --- Client-side channel decryption (#725 M2) --- // Check if input is a valid hex string (32 hex chars = 16 bytes) function isHexKey(val) { return /^[0-9a-fA-F]{32}$/.test(val); } // Show status message in the add-channel form (#759) var statusTimer = null; function showAddStatus(msg, type) { var el = document.getElementById('chAddStatus'); if (!el) return; el.textContent = msg; el.className = 'ch-add-status ch-add-status--' + (type || 'info'); el.style.display = ''; clearTimeout(statusTimer); if (type !== 'loading') { statusTimer = setTimeout(function () { el.style.display = 'none'; }, 5000); } } // #1087 Bug 3: single canonical persistence helper. Both the Generate // path and the PSK Add path route writes through this function so the // localStorage write happens synchronously inside the submit handler — // not as a side effect of subsequent UI events. // // The previous code spread storeKey() calls across multiple branches, // and the persistence path could be skipped entirely if the modal was // closed before mergeUserChannels() ran. Hence the original symptom: // a freshly-added channel disappeared on refresh, then "reappeared" // when ANOTHER channel was added (because the second add wrote the // entire current state, including #1). // // Returns true iff the key was successfully stored AND a re-read // confirms it landed in localStorage. Returns false on quota / other // storage failure so callers can surface an error. function persistAddedChannel(channelName, keyHex, label) { if (!channelName || !keyHex) return false; try { ChannelDecrypt.storeKey(channelName, keyHex, label); } catch (e) { return false; } // Verify the write by re-reading. localStorage can silently drop // writes under quota pressure, and we want callers to know. try { var keys = (typeof ChannelDecrypt.getStoredKeys === 'function') ? ChannelDecrypt.getStoredKeys() : JSON.parse(localStorage.getItem('corescope_channel_keys') || '{}'); if (!keys || keys[channelName] !== keyHex) return false; // Polish MINOR-3: also verify the label round-tripped when one was supplied. // Labels live in a separate storage bucket and could fail independently // of the key write — caller deserves to know if the friendly name didn't land. var trimmed = (typeof label === 'string') ? label.trim() : ''; if (trimmed) { var stored = (typeof ChannelDecrypt.getLabel === 'function') ? ChannelDecrypt.getLabel(channelName) : ((typeof ChannelDecrypt.getLabels === 'function') ? (ChannelDecrypt.getLabels()[channelName] || '') : ''); if (stored !== trimmed) return false; } return true; } catch (e) { return false; } } // Add a user channel by name (#channelname) or hex key. // `label` (#1020) is an optional friendly name shown in the sidebar instead // of "psk:" — stored alongside the key in localStorage. async function addUserChannel(val, label) { var displayName = val.startsWith('#') ? val : (isHexKey(val) ? val.substring(0, 8) + '…' : '#' + val); showAddStatus('Decrypting ' + displayName + ' messages…', 'loading'); var channelName, keyHex; try { if (val.startsWith('#')) { channelName = val; var keyBytes = await ChannelDecrypt.deriveKey(channelName); keyHex = ChannelDecrypt.bytesToHex(keyBytes); } else if (isHexKey(val)) { keyHex = val.toLowerCase(); channelName = 'psk:' + keyHex.substring(0, 8); } else { // Try with # prefix if user forgot channelName = '#' + val; var keyBytes2 = await ChannelDecrypt.deriveKey(channelName); keyHex = ChannelDecrypt.bytesToHex(keyBytes2); } // #1020/#1087: persist optional user-supplied label alongside the key // through the canonical helper (verified read-back). if (!persistAddedChannel(channelName, keyHex, label)) { showAddStatus('Failed to save channel — browser storage may be full', 'error'); return; } // Compute channel hash byte to find matching encrypted channels var keyBytes3 = ChannelDecrypt.hexToBytes(keyHex); var hashByte = await ChannelDecrypt.computeChannelHash(keyBytes3); // Add to sidebar or merge with existing encrypted channel mergeUserChannels(); renderChannelList(); // Auto-select and start decrypting var targetHash = 'user:' + channelName; // Check if there's an existing encrypted channel with this hash byte var existingEncrypted = channels.find(function (ch) { return ch.encrypted && String(ch.hash) === String(hashByte); }); if (existingEncrypted) { targetHash = existingEncrypted.hash; } var selectResult = await selectChannel(targetHash, { userKey: keyHex, channelHashByte: hashByte, channelName: channelName }); // #1020: derive count from selectChannel's reported result, not from a // DOM scrape that can race with rendering. var msgCount = (selectResult && typeof selectResult.messageCount === 'number') ? selectResult.messageCount : (Array.isArray(messages) ? messages.length : 0); var displayLabel = (typeof label === 'string' && label.trim()) ? label.trim() : (channelName.startsWith('psk:') ? 'Custom channel (' + channelName.substring(4) + ')' : channelName); if (selectResult && selectResult.wrongKey) { showAddStatus('Key does not match any packets for ' + displayLabel, 'error'); } else if (msgCount > 0) { showAddStatus('Added ' + displayLabel + ' — ' + msgCount + ' messages decrypted', 'success'); } else { showAddStatus('Added ' + displayLabel + ' — no messages found yet', 'warn'); } } catch (err) { showAddStatus('Failed to decrypt', 'error'); } } // Merge user-stored keys into the channel list. // If a stored key matches a server-known channel, mark that channel as // userAdded so the ✕ button appears — otherwise the user has no way to // remove a key they added but that the server already knows about. function mergeUserChannels() { var keys = ChannelDecrypt.getStoredKeys(); var labels = (typeof ChannelDecrypt.getLabels === 'function') ? ChannelDecrypt.getLabels() : {}; var names = Object.keys(keys); for (var i = 0; i < names.length; i++) { var name = names[i]; var label = labels[name] || ''; var matched = false; for (var j = 0; j < channels.length; j++) { var ch = channels[j]; if (ch.name === name || ch.hash === name || ch.hash === ('user:' + name)) { ch.userAdded = true; if (label) ch.userLabel = label; matched = true; break; } } if (!matched) { channels.push({ hash: 'user:' + name, name: name, userLabel: label, messageCount: 0, lastActivityMs: 0, lastSender: '', lastMessage: 'Encrypted — click to decrypt', encrypted: true, userAdded: true }); } } } // Fetch and decrypt GRP_TXT packets client-side (M5: delta fetch + cache) async function fetchAndDecryptChannel(keyHex, channelHashByte, channelName, opts) { opts = opts || {}; var keyBytes = ChannelDecrypt.hexToBytes(keyHex); // M5: Check cache first — serve cached messages immediately var cacheKey = channelName || String(channelHashByte); var cached = ChannelDecrypt.getCache(cacheKey); var cachedMsgs = cached ? cached.messages : []; var lastTs = cached ? cached.lastTimestamp : ''; var cachedCount = cached ? (cached.count || 0) : 0; // If we have cached messages and caller wants instant render, return them first if (cachedMsgs.length > 0 && !opts.forceFullDecrypt) { // Signal caller to render cache immediately, then do delta fetch if (opts.onCacheHit) opts.onCacheHit(cachedMsgs); } // Fetch packets from API — get all payload_type=5 (GRP_TXT/CHAN) var rp = RegionFilter.getRegionParam(); var qs = (rp ? '®ion=' + encodeURIComponent(rp) : ''); var data; try { data = await api('/packets?limit=1000&payloadType=5' + qs, { ttl: 10000 }); } catch (e) { return { messages: cachedMsgs, error: 'Failed to fetch packets: ' + e.message, fromCache: cachedMsgs.length > 0 }; } var packets = data.packets || []; // Filter for GRP_TXT (encrypted) packets matching our channel hash byte var candidates = []; for (var i = 0; i < packets.length; i++) { var p = packets[i]; var dj; try { dj = typeof p.decoded_json === 'string' ? JSON.parse(p.decoded_json) : p.decoded_json; } catch (e) { continue; } if (!dj) continue; if (dj.type === 'CHAN' && dj.channel === channelName) { candidates.push({ type: 'already_decrypted', decoded: dj, packet: p }); } else if (dj.type === 'GRP_TXT' && dj.encryptedData && dj.mac) { if (dj.channelHash === channelHashByte) { candidates.push({ type: 'encrypted', decoded: dj, packet: p }); } } } // M5: Cache invalidation — if total candidate count changed, re-decrypt everything var totalCandidates = candidates.length; var needFullDecrypt = (totalCandidates !== cachedCount) || opts.forceFullDecrypt; // M5: Delta fetch — only decrypt packets newer than lastTs if (!needFullDecrypt && cachedMsgs.length > 0 && lastTs) { // Filter candidates to only those newer than cached lastTimestamp var newCandidates = candidates.filter(function (c) { var ts = c.packet.first_seen || c.packet.timestamp || ''; return ts > lastTs; }); if (newCandidates.length === 0) { // Nothing new — return cache as-is return { messages: cachedMsgs, fromCache: true }; } // Decrypt only new candidates var newDecrypted = await decryptCandidates(keyBytes, newCandidates); if (newDecrypted.wrongKey) { return { messages: cachedMsgs, wrongKey: true }; } // Merge: cached + new, deduplicate by packetHash, sort chronologically var merged = deduplicateAndMerge(cachedMsgs, newDecrypted.messages); var newLastTs = merged.length ? merged[merged.length - 1].timestamp : lastTs; ChannelDecrypt.setCache(cacheKey, merged, newLastTs, totalCandidates); return { messages: merged, deltaCount: newDecrypted.messages.length }; } if (candidates.length === 0) { return { messages: cachedMsgs, empty: true }; } // Full decrypt var result = await decryptCandidates(keyBytes, candidates); if (result.wrongKey) { return { messages: result.messages, wrongKey: true }; } var decrypted = result.messages; // Sort chronologically (oldest first) decrypted.sort(function (a, b) { var ta = a.timestamp || ''; var tb = b.timestamp || ''; return ta.localeCompare(tb); }); // M5: Cache results var newLastTimestamp = decrypted.length ? decrypted[decrypted.length - 1].timestamp : ''; ChannelDecrypt.setCache(cacheKey, decrypted, newLastTimestamp, totalCandidates); return { messages: decrypted }; } /** Decrypt an array of candidate packets. Returns { messages, wrongKey }. */ async function decryptCandidates(keyBytes, candidates) { // Sort newest first for progressive rendering candidates.sort(function (a, b) { var ta = a.packet.first_seen || a.packet.timestamp || ''; var tb = b.packet.first_seen || b.packet.timestamp || ''; return tb.localeCompare(ta); }); var decrypted = []; var macFailCount = 0; var macCheckCount = 0; for (var j = 0; j < candidates.length; j++) { var c = candidates[j]; if (c.type === 'already_decrypted') { var d = c.decoded; var sender = d.sender || 'Unknown'; var text = d.text || ''; var ci = text.indexOf(': '); if (ci > 0 && ci < 50 && text.substring(0, ci) === sender) { text = text.substring(ci + 2); } decrypted.push({ sender: sender, text: text, timestamp: c.packet.first_seen || c.packet.timestamp, sender_timestamp: d.sender_timestamp || null, packetHash: c.packet.hash, packetId: c.packet.id, hops: d.path_len || 0, snr: c.packet.snr || null, observers: c.packet.observer_name ? [c.packet.observer_name] : [], repeats: 1 }); continue; } macCheckCount++; var result = await ChannelDecrypt.decryptPacket(keyBytes, c.decoded.mac, c.decoded.encryptedData); if (result) { macFailCount = 0; decrypted.push({ sender: result.sender, text: result.message, timestamp: c.packet.first_seen || c.packet.timestamp, sender_timestamp: result.timestamp || null, packetHash: c.packet.hash, packetId: c.packet.id, hops: 0, snr: c.packet.snr || null, observers: c.packet.observer_name ? [c.packet.observer_name] : [], repeats: 1 }); } else { macFailCount++; if (macCheckCount >= 10 && macFailCount >= macCheckCount) { return { messages: decrypted, wrongKey: true }; } } } return { messages: decrypted, wrongKey: false }; } /** Merge cached and new messages, deduplicate by packetHash, sort chronologically. */ function deduplicateAndMerge(cached, newMsgs) { var seen = {}; var merged = []; // Add cached first for (var i = 0; i < cached.length; i++) { var key = cached[i].packetHash || ('idx:' + i); if (!seen[key]) { seen[key] = true; merged.push(cached[i]); } } // Add new for (var j = 0; j < newMsgs.length; j++) { var key2 = newMsgs[j].packetHash || ('new:' + j); if (!seen[key2]) { seen[key2] = true; merged.push(newMsgs[j]); } } merged.sort(function (a, b) { var ta = a.timestamp || ''; var tb = b.timestamp || ''; return ta.localeCompare(tb); }); return merged; } function init(app, routeParam) { var _initUrlParams = getHashParams(); var _pendingNode = _initUrlParams.get('node'); app.innerHTML = `
💬 Channels
Loading channels…
Select a channel
Choose a channel from the sidebar to view messages
`; RegionFilter.init(document.getElementById('chRegionFilter')); // #1034 PR1: encrypted-channels visibility now driven by sectioned sidebar. // Always include encrypted channels in the API call; the renderer groups them. var showEncrypted = true; try { localStorage.setItem('channels-show-encrypted', 'true'); } catch (e) { /* quota */ } regionChangeHandler = RegionFilter.onChange(function () { loadChannels(true).then(async function () { if (!selectedHash) return; await refreshMessages({ regionSwitch: true, forceNoCache: true }); }); }); // #1034 PR1: Add Channel modal wiring (replaces inline form) var modalEl = document.getElementById('chAddChannelModal'); function openAddModal() { if (!modalEl) return; modalEl.classList.remove('hidden'); modalEl.removeAttribute('hidden'); var first = document.getElementById('chGenerateName'); if (first) try { first.focus(); } catch (e) { /* noop */ } } function closeAddModal() { if (!modalEl) return; modalEl.classList.add('hidden'); modalEl.setAttribute('hidden', ''); var err = document.getElementById('chPskError'); if (err) { err.style.display = 'none'; err.textContent = ''; } } var addBtn = document.getElementById('chAddChannelBtn'); if (addBtn) addBtn.addEventListener('click', openAddModal); if (modalEl) { modalEl.addEventListener('click', function (e) { // Close on overlay backdrop click or any [data-action=ch-modal-close] var closeEl = e.target.closest('[data-action="ch-modal-close"]'); if (closeEl || e.target === modalEl) { e.preventDefault(); closeAddModal(); } }); document.addEventListener('keydown', function (e) { if (e.key === 'Escape' && !modalEl.classList.contains('hidden')) { closeAddModal(); } }); } // #1087 Bug 4: dedicated Share modal wiring. // Polish follow-up: focus trap on open + restore focus on close (a11y). var shareModalEl = document.getElementById('chShareModal'); var _shareModalTrigger = null; var _shareModalKeyHandler = null; // QR capacity bound: qrcode(0,'M') auto-detects smallest version, but // very long display labels can overflow. URL = scheme(~30) + 32-char // secret + encoded(name). Cap encoded label budget to keep total URL // comfortably under the version-10 ECC-M payload (~213 bytes). var SHARE_LABEL_MAX = 64; function _truncateForQr(name) { if (!name) return ''; var s = String(name); // Encode first, then trim — encoded length is what QR sees. var enc = encodeURIComponent(s); if (enc.length <= SHARE_LABEL_MAX) return s; // Walk back until encoded fits; preserves UTF-8 boundaries via // encodeURIComponent re-check on each shrink. while (s.length > 0 && encodeURIComponent(s).length > SHARE_LABEL_MAX) { s = s.slice(0, -1); } return s; } function _trapShareModalFocus() { if (!shareModalEl) return; var focusable = shareModalEl.querySelectorAll( 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'); if (!focusable.length) return; var first = focusable[0], last = focusable[focusable.length - 1]; _shareModalKeyHandler = function (e) { if (e.key !== 'Tab') return; if (e.shiftKey) { if (document.activeElement === first) { e.preventDefault(); last.focus(); } } else { if (document.activeElement === last) { e.preventDefault(); first.focus(); } } }; shareModalEl.addEventListener('keydown', _shareModalKeyHandler); } // Open the share modal in NORMAL (key present) mode. For the // "key not found" path, callers use openShareModalError() — both // routes use this same modal so users never see a native alert(). function openShareModal(displayName, channelName, keyHex) { if (!shareModalEl) return; _shareModalTrigger = document.activeElement; var safeName = _truncateForQr(displayName); var title = document.getElementById('chShareModalTitle'); if (title) title.textContent = 'Share: ' + safeName; var qrHolder = document.getElementById('chShareQr'); var keyField = document.getElementById('chShareKey'); var fieldsWrap = shareModalEl.querySelectorAll('.ch-share-field-group'); for (var i = 0; i < fieldsWrap.length; i++) fieldsWrap[i].hidden = false; if (keyField) keyField.value = keyHex; if (qrHolder) { qrHolder.innerHTML = ''; if (window.ChannelQR && typeof window.ChannelQR.generate === 'function') { // #1087 Bug 2: pass the user-facing displayName, NOT the // internal `psk:` channelName lookup key. // #1101: qrOnly=true — render JUST the QR image. The Share // modal has its own dedicated hex key field + Copy button // BELOW the QR; an inline URL line + Copy Key button inside // the QR box was redundant and visually overlapping. window.ChannelQR.generate(safeName, keyHex, qrHolder, { qrOnly: true }); } } shareModalEl.classList.remove('hidden'); shareModalEl.removeAttribute('hidden'); _trapShareModalFocus(); var closeBtn = document.getElementById('chShareModalClose'); if (closeBtn) try { closeBtn.focus(); } catch (e) { /* noop */ } } // Polish: replace native alert() for missing-key share with the // dedicated modal in error mode (no QR/fields, just the message). function openShareModalError(displayName, message) { if (!shareModalEl) return; _shareModalTrigger = document.activeElement; var title = document.getElementById('chShareModalTitle'); if (title) title.textContent = 'Share: ' + displayName; var qrHolder = document.getElementById('chShareQr'); if (qrHolder) { qrHolder.innerHTML = ''; var msg = document.createElement('div'); msg.className = 'ch-share-error'; msg.setAttribute('role', 'alert'); msg.textContent = message; qrHolder.appendChild(msg); } var fieldsWrap = shareModalEl.querySelectorAll('.ch-share-field-group'); for (var i = 0; i < fieldsWrap.length; i++) fieldsWrap[i].hidden = true; shareModalEl.classList.remove('hidden'); shareModalEl.removeAttribute('hidden'); _trapShareModalFocus(); var closeBtn = document.getElementById('chShareModalClose'); if (closeBtn) try { closeBtn.focus(); } catch (e) { /* noop */ } } function closeShareModal() { if (!shareModalEl) return; shareModalEl.classList.add('hidden'); shareModalEl.setAttribute('hidden', ''); if (_shareModalKeyHandler) { shareModalEl.removeEventListener('keydown', _shareModalKeyHandler); _shareModalKeyHandler = null; } // Restore focus to the trigger that opened the modal (a11y). if (_shareModalTrigger && typeof _shareModalTrigger.focus === 'function') { try { _shareModalTrigger.focus(); } catch (e) { /* noop */ } } _shareModalTrigger = null; } if (shareModalEl) { shareModalEl.addEventListener('click', function (e) { var copyBtn = e.target.closest && e.target.closest('[data-share-copy]'); if (copyBtn) { e.preventDefault(); // #1101: only the hex key is copyable from the share modal; // the URL field was removed, so the data-share-copy attribute // is informational only — the source is always #chShareKey. var src = document.getElementById('chShareKey'); if (src) { try { src.select(); } catch (e2) {} var doneCopy = function () { var orig = copyBtn.textContent; copyBtn.textContent = '✓ Copied'; setTimeout(function () { copyBtn.textContent = orig; }, 1200); }; if (navigator.clipboard && navigator.clipboard.writeText) { navigator.clipboard.writeText(src.value).then(doneCopy, doneCopy); } else { try { document.execCommand('copy'); } catch (e2) {} doneCopy(); } } return; } var closeEl = e.target.closest('[data-action="ch-share-modal-close"]'); if (closeEl || e.target === shareModalEl) { e.preventDefault(); closeShareModal(); } }); document.addEventListener('keydown', function (e) { if (e.key === 'Escape' && !shareModalEl.classList.contains('hidden')) { closeShareModal(); } }); } // Section 1: Generate PSK var genBtn = document.getElementById('chGenerateBtn'); if (genBtn) genBtn.addEventListener('click', async function () { var nameEl = document.getElementById('chGenerateName'); var label = nameEl ? (nameEl.value || '').trim() : ''; // 16 random bytes -> 32-char hex var bytes = crypto.getRandomValues(new Uint8Array(16)); var keyHex = ChannelDecrypt.bytesToHex(bytes); var channelName = 'psk:' + keyHex.substring(0, 8); // #1087 Bug 3: persist via canonical helper synchronously. if (!persistAddedChannel(channelName, keyHex, label)) { showAddStatus('Failed to save channel — storage full', 'error'); return; } var qrOut = document.getElementById('qr-output'); if (qrOut) { qrOut.innerHTML = ''; // Render QR + URL + Copy Key inline. if (window.ChannelQR && typeof window.ChannelQR.generate === 'function') { // #1087 Bug 2: pass the user label (not psk:). window.ChannelQR.generate(label || channelName, keyHex, qrOut); } else { qrOut.textContent = 'Key generated: ' + keyHex; } } mergeUserChannels(); renderChannelList(); showAddStatus('Generated channel ' + (label || channelName), 'success'); }); // Section 2: Add PSK var pskBtn = document.getElementById('chPskAddBtn'); if (pskBtn) pskBtn.addEventListener('click', async function () { var keyEl = document.getElementById('chPskKey'); var nameEl = document.getElementById('chPskName'); var errEl = document.getElementById('chPskError'); var raw = keyEl ? (keyEl.value || '').trim() : ''; var label = nameEl ? (nameEl.value || '').trim() : ''; if (!isHexKey(raw)) { if (errEl) { errEl.textContent = 'Key must be 32 hex characters (0–9, a–f).'; errEl.style.display = ''; } return; } if (errEl) { errEl.textContent = ''; errEl.style.display = 'none'; } closeAddModal(); if (keyEl) keyEl.value = ''; if (nameEl) nameEl.value = ''; await addUserChannel(raw.toLowerCase(), label); }); // Section 2 (cont.): Scan QR — populates #chPskKey + #chPskName // from a scanned meshcore://channel/add?... URL. Wiring added in // PR #1034/PR3 against window.ChannelQR (public/channel-qr.js). var scanBtn = document.getElementById('scan-qr-btn'); if (scanBtn) scanBtn.addEventListener('click', async function () { var errEl = document.getElementById('chPskError'); if (!window.ChannelQR || typeof window.ChannelQR.scan !== 'function') { if (errEl) { errEl.textContent = 'QR scanning is unavailable in this browser.'; errEl.style.display = ''; } return; } try { var result = await window.ChannelQR.scan(); if (!result) return; // user cancelled var keyEl = document.getElementById('chPskKey'); var nameEl = document.getElementById('chPskName'); if (keyEl && result.secret) keyEl.value = result.secret; if (nameEl && result.name) nameEl.value = result.name; if (errEl) { errEl.textContent = ''; errEl.style.display = 'none'; } } catch (err) { if (errEl) { errEl.textContent = 'Scan failed: ' + (err && err.message ? err.message : 'unknown error'); errEl.style.display = ''; } } }); // Section 3: Monitor Hashtag var tagBtn = document.getElementById('chHashtagBtn'); if (tagBtn) tagBtn.addEventListener('click', async function () { var tagEl = document.getElementById('chHashtagName'); var raw = tagEl ? (tagEl.value || '').trim() : ''; if (!raw) return; // Strip a leading '#' if the user typed one — the prefix is implicit. if (raw.charAt(0) === '#') raw = raw.substring(1); if (!raw) return; closeAddModal(); if (tagEl) tagEl.value = ''; await addUserChannel('#' + raw, ''); }); loadObserverRegions(); loadChannels().then(async function () { // Also load user-added encrypted channels into the sidebar. // mergeUserChannels() mutates `channels` (marks userAdded, appends // PSK-only entries) AFTER loadChannels() already rendered — so we // MUST re-render here, otherwise the My Channels section never // appears on first load when the route has no specific channel // hash (regression caught by test-channel-issue-1111-e2e.js, case 2). mergeUserChannels(); renderChannelList(); if (routeParam) await selectChannel(routeParam); if (_pendingNode && _pendingNode.length < 200) await showNodeDetail(_pendingNode); }); // #89: Sidebar resize handle (function () { var sidebar = app.querySelector('.ch-sidebar'); var handle = app.querySelector('.ch-sidebar-resize'); var saved = localStorage.getItem('channels-sidebar-width'); if (saved) { var w = parseInt(saved, 10); if (w >= 180 && w <= 600) { sidebar.style.width = w + 'px'; sidebar.style.minWidth = w + 'px'; } } var dragging = false, startX, startW; handle.addEventListener('mousedown', function (e) { dragging = true; startX = e.clientX; startW = sidebar.getBoundingClientRect().width; e.preventDefault(); }); document.addEventListener('mousemove', function (e) { if (!dragging) return; var w = Math.max(180, Math.min(600, startW + e.clientX - startX)); sidebar.style.width = w + 'px'; sidebar.style.minWidth = w + 'px'; }); document.addEventListener('mouseup', function () { if (!dragging) return; dragging = false; localStorage.setItem('channels-sidebar-width', parseInt(sidebar.style.width, 10)); }); })(); // #90: Theme change observer — re-render messages on theme toggle var _themeObserver = new MutationObserver(function (muts) { for (var i = 0; i < muts.length; i++) { if (muts[i].attributeName === 'data-theme') { if (selectedHash) renderMessages(); break; } } }); _themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] }); // Event delegation for data-action buttons app.addEventListener('click', function (e) { var btn = e.target.closest('[data-action]'); if (!btn) return; var action = btn.dataset.action; if (action === 'ch-close-node') closeNodeDetail(); if (action === 'ch-back') { // Mobile slide-back: return to the channel list view. selectedHash = null; messages = []; history.replaceState(null, '', '#/channels'); document.querySelector('.ch-layout')?.classList.remove('ch-detail-open'); var headerT = document.querySelector('#chHeader .ch-header-text'); if (headerT) headerT.textContent = 'Select a channel'; var msgEl = document.getElementById('chMessages'); if (msgEl) msgEl.innerHTML = '
Choose a channel from the sidebar to view messages
'; renderChannelList(); } }); // Event delegation for channel selection (touch-friendly) var chListEl = document.getElementById('chList'); // Keyboard accessibility for the role="button" remove/share spans // (Enter/Space). Single .closest() call with a combined selector. chListEl.addEventListener('keydown', function (e) { if (e.key !== 'Enter' && e.key !== ' ' && e.key !== 'Spacebar') return; var rb = e.target.closest && e.target.closest('[data-remove-channel],[data-share-channel]'); if (!rb) return; e.preventDefault(); e.stopPropagation(); // Re-dispatch as a click so the existing click handler runs. rb.click(); }); chListEl.addEventListener('click', (e) => { // #1087 Bug 2 + Bug 4: Share/reshare opens a DEDICATED share modal // (not the Add Channel modal) and resolves the user's display // label via ChannelDecrypt.getLabel — never the raw `psk:` // lookup key. const shareBtn = e.target.closest('[data-share-channel]'); if (shareBtn) { e.stopPropagation(); var shareHash = shareBtn.getAttribute('data-share-channel'); if (!shareHash) return; var sCh = channels.find(function (c) { return c.hash === shareHash; }); var channelName = shareHash.startsWith('user:') ? shareHash.substring(5) : (sCh && sCh.name) || shareHash; var keys = ChannelDecrypt.getStoredKeys(); var keyHex = keys[channelName]; // Resolve display label: explicit user label > channel.userLabel // > strip the psk: prefix > raw channelName. var labels = (typeof ChannelDecrypt.getLabels === 'function') ? ChannelDecrypt.getLabels() : {}; var labelFromStore = (typeof ChannelDecrypt.getLabel === 'function') ? ChannelDecrypt.getLabel(channelName) : (labels[channelName] || ''); var displayName = labelFromStore || (sCh && sCh.userLabel) || (channelName.indexOf('psk:') === 0 ? 'Private Channel' : channelName); if (!keyHex) { openShareModalError(displayName, 'No stored key found for "' + displayName + '" — cannot share.'); return; } if (typeof openShareModal === 'function') { openShareModal(displayName, channelName, keyHex); } return; } // M4: Remove channel button const removeBtn = e.target.closest('[data-remove-channel]'); if (removeBtn) { e.stopPropagation(); var channelHash = removeBtn.getAttribute('data-remove-channel'); if (!channelHash) return; // The localStorage key is the channel name. For user:-prefixed entries // strip the prefix; for server-known channels look up the channel // object so we use its display name (the hash itself isn't the key). var ch = channels.find(function (c) { return c.hash === channelHash; }); var chName = channelHash.startsWith('user:') ? channelHash.substring(5) : (ch && ch.name) || channelHash; if (!confirm('Remove channel "' + chName + '"?\n\nThis will permanently remove the key from this browser and clear cached messages. You will need to re-enter the key to decrypt this channel again.')) return; ChannelDecrypt.removeKey(chName); if (channelHash.startsWith('user:')) { // Pure user-added channel — drop from the list entirely. channels = channels.filter(function (c) { return c.hash !== channelHash; }); if (selectedHash === channelHash) { selectedHash = null; messages = []; history.replaceState(null, '', '#/channels'); var msgEl2 = document.getElementById('chMessages'); if (msgEl2) msgEl2.innerHTML = '
Choose a channel from the sidebar to view messages
'; var header2 = document.getElementById('chHeader'); if (header2) header2.querySelector('.ch-header-text').textContent = 'Select a channel'; } } else if (ch) { // Server-known channel: keep the row, just unmark as user-added so // the ✕ disappears until they re-add a key. ch.userAdded = false; // If this was the selected channel, clear decrypted messages since // the key is gone — they can't be re-decrypted without re-adding it. if (selectedHash === channelHash) { messages = []; var msgEl2 = document.getElementById('chMessages'); if (msgEl2) msgEl2.innerHTML = '
Key removed — add a key to decrypt messages
'; } } renderChannelList(); return; } // Color clear button — remove color without opening picker (#681) const clearBtn = e.target.closest('.ch-color-clear'); if (clearBtn && window.ChannelColors) { e.stopPropagation(); var clearCh = clearBtn.getAttribute('data-channel'); if (clearCh) { window.ChannelColors.remove(clearCh); renderChannelList(); } return; } // Color dot click — open picker, don't select channel const dot = e.target.closest('.ch-color-dot'); if (dot && window.ChannelColorPicker) { e.stopPropagation(); var ch = dot.getAttribute('data-channel'); if (ch) ChannelColorPicker.show(ch, e.clientX, e.clientY); return; } const item = e.target.closest('.ch-item[data-hash], .ch-row[data-hash]'); if (item) selectChannel(item.dataset.hash); }); const msgEl = document.getElementById('chMessages'); msgEl.addEventListener('scroll', () => { const atBottom = msgEl.scrollHeight - msgEl.scrollTop - msgEl.clientHeight < 60; autoScroll = atBottom; document.getElementById('chScrollBtn').classList.toggle('hidden', atBottom); }); document.getElementById('chScrollBtn').addEventListener('click', scrollToBottom); // Event delegation for node clicks and hovers (click + touchend for mobile reliability) function handleNodeTap(e) { const el = e.target.closest('[data-node]'); if (el) { e.preventDefault(); const name = decodeURIComponent(atob(el.dataset.node)); showNodeDetail(name); } else if (selectedNode && !e.target.closest('.ch-node-panel')) { closeNodeDetail(); } } // Keyboard support for data-node elements (Bug #82) msgEl.addEventListener('keydown', function (e) { if (e.key === 'Enter' || e.key === ' ') { const el = e.target.closest('[data-node]'); if (el) { e.preventDefault(); const name = decodeURIComponent(atob(el.dataset.node)); showNodeDetail(name); } } }); msgEl.addEventListener('click', handleNodeTap); // touchend fires more reliably on mobile for non-button elements let touchMoved = false; msgEl.addEventListener('touchstart', () => { touchMoved = false; }, { passive: true }); msgEl.addEventListener('touchmove', () => { touchMoved = true; }, { passive: true }); msgEl.addEventListener('touchend', (e) => { if (touchMoved) return; const el = e.target.closest('[data-node]'); if (el) { e.preventDefault(); const name = decodeURIComponent(atob(el.dataset.node)); showNodeDetail(name); } else if (selectedNode && !e.target.closest('.ch-node-panel')) { closeNodeDetail(); } }); let hoverTimeout = null; msgEl.addEventListener('mouseover', (e) => { const el = e.target.closest('[data-node]'); if (el) { clearTimeout(hoverTimeout); const name = decodeURIComponent(atob(el.dataset.node)); showNodeTooltip(e, name); } }); msgEl.addEventListener('mouseout', (e) => { const el = e.target.closest('[data-node]'); if (el) { hoverTimeout = setTimeout(hideNodeTooltip, 100); } }); // #86: Show tooltip on focus for keyboard users msgEl.addEventListener('focusin', (e) => { const el = e.target.closest('[data-node]'); if (el) { clearTimeout(hoverTimeout); const name = decodeURIComponent(atob(el.dataset.node)); showNodeTooltip(e, name); } }); msgEl.addEventListener('focusout', (e) => { const el = e.target.closest('[data-node]'); if (el) { hoverTimeout = setTimeout(hideNodeTooltip, 100); } }); function processWSBatch(msgs, selectedRegions) { var dominated = msgs.filter(function (m) { return m.type === 'message' || (m.type === 'packet' && m.data?.decoded?.header?.payloadTypeName === 'GRP_TXT'); }); if (!dominated.length) return; var channelListDirty = false; var messagesDirty = false; var seenHashes = new Set(); for (var i = 0; i < dominated.length; i++) { var m = dominated[i]; if (!shouldProcessWSMessageForRegion(m, selectedRegions, observerIataById, observerIataByName)) continue; var payload = m.data?.decoded?.payload; if (!payload) continue; var channelName = payload.channel || 'unknown'; // For live-decrypted user-added (PSK) channels, decryptLivePSKBatch // also stamps payload.channelKey ("user:") so we route the // message to the correct sidebar row and to the open chat view. // Falls back to channelName for server-known CHAN packets. var channelKey = payload.channelKey || channelName; var rawText = payload.text || ''; var sender = payload.sender || null; var displayText = rawText; // Parse "sender: message" format if (rawText && !sender) { var colonIdx = rawText.indexOf(': '); if (colonIdx > 0 && colonIdx < 50) { sender = rawText.slice(0, colonIdx); displayText = rawText.slice(colonIdx + 2); } } else if (rawText && sender) { var colonIdx2 = rawText.indexOf(': '); if (colonIdx2 > 0 && colonIdx2 < 50) { displayText = rawText.slice(colonIdx2 + 2); } } if (!sender) sender = 'Unknown'; var ts = new Date().toISOString(); var pktHash = m.data?.hash || m.data?.packet?.hash || null; var pktId = m.data?.id || null; var snr = m.data?.snr ?? m.data?.packet?.snr ?? payload.SNR ?? null; var observer = m.data?.packet?.observer_name || m.data?.observer || null; // Update channel list entry — only once per unique packet hash var isFirstObservation = pktHash && !seenHashes.has(pktHash + ':' + channelKey); if (pktHash) seenHashes.add(pktHash + ':' + channelKey); var ch = channels.find(function (c) { return c.hash === channelKey; }); if (ch) { if (isFirstObservation) ch.messageCount = (ch.messageCount || 0) + 1; ch.lastActivityMs = Date.now(); ch.lastSender = sender; ch.lastMessage = truncate(displayText, 100); channelListDirty = true; } else if (isFirstObservation) { // New channel we haven't seen channels.push({ hash: channelKey, name: channelName, messageCount: 1, lastActivityMs: Date.now(), lastSender: sender, lastMessage: truncate(displayText, 100), }); channelListDirty = true; } // If this message is for the selected channel, append to messages if (selectedHash && channelKey === selectedHash) { // Deduplicate by packet hash — same message seen by multiple observers var existing = pktHash ? messages.find(function (msg) { return msg.packetHash === pktHash; }) : null; if (existing) { existing.repeats = (existing.repeats || 1) + 1; if (observer && existing.observers && existing.observers.indexOf(observer) === -1) { existing.observers.push(observer); } } else { messages.push({ sender: sender, text: displayText, timestamp: ts, sender_timestamp: payload.sender_timestamp || null, packetId: pktId, packetHash: pktHash, repeats: 1, observers: observer ? [observer] : [], hops: payload.path_len || 0, snr: snr, }); } messagesDirty = true; } } if (channelListDirty) { channels.sort(function (a, b) { return (b.lastActivityMs || 0) - (a.lastActivityMs || 0); }); renderChannelList(); } if (messagesDirty) { renderMessages(); // Update header count var ch2 = channels.find(function (c) { return c.hash === selectedHash; }); var header = document.getElementById('chHeader'); if (header && ch2) { header.querySelector('.ch-header-text').textContent = (ch2.name || 'Channel ' + selectedHash) + ' — ' + messages.length + ' messages'; } var msgEl = document.getElementById('chMessages'); if (msgEl && autoScroll) scrollToBottom(); else { document.getElementById('chScrollBtn')?.classList.remove('hidden'); var liveEl = document.getElementById('chAriaLive'); if (liveEl) liveEl.textContent = 'New message received'; } } } function handleWSBatch(msgs) { var selectedRegions = getSelectedRegionsSnapshot(); processWSBatch(msgs, selectedRegions); } // Pre-pass: rewrite encrypted GRP_TXT live packets into decrypted form // when a stored PSK key matches their channel hash byte (#1029 — live // PSK decrypt). Without this, users viewing a PSK-decrypted channel // had to refresh the page to see new messages. async function decryptLivePSKBatch(msgs) { if (typeof ChannelDecrypt === 'undefined' || typeof ChannelDecrypt.tryDecryptLive !== 'function') { return; } // Quick scan: do any messages look like encrypted GRP_TXT? var anyEncrypted = false; for (var i = 0; i < msgs.length; i++) { var p = msgs[i] && msgs[i].data && msgs[i].data.decoded && msgs[i].data.decoded.payload; if (p && p.type === 'GRP_TXT' && p.encryptedData && p.mac) { anyEncrypted = true; break; } } if (!anyEncrypted) return; var keyMap; try { keyMap = await ChannelDecrypt.buildKeyMap(); } catch (e) { return; } if (!keyMap || keyMap.size === 0) return; for (var j = 0; j < msgs.length; j++) { var m = msgs[j]; var payload = m && m.data && m.data.decoded && m.data.decoded.payload; if (!payload || payload.type !== 'GRP_TXT' || !payload.encryptedData || !payload.mac) continue; var dec; try { dec = await ChannelDecrypt.tryDecryptLive(payload, keyMap); } catch (e) { dec = null; } if (!dec) continue; // Rewrite payload into a CHAN-like shape so processWSBatch picks it // up as a real message instead of an encrypted blob. Keep the original // hash byte for any downstream consumer that wants it. payload.channel = dec.channelName; // For user-added PSK channels the sidebar entry & selectedHash use a // "user:" key (see addUserChannel). Stamp the canonical key on // the payload so processWSBatch routes the live message to the // correct sidebar row and to the open chat view instead of dropping // it / creating a duplicate plain entry. Falls back to the raw name // for non-user channels (server-known CHAN paths still work). var userKey = 'user:' + dec.channelName; var hasUserCh = false; for (var ck = 0; ck < channels.length; ck++) { if (channels[ck].hash === userKey) { hasUserCh = true; break; } } payload.channelKey = hasUserCh ? userKey : dec.channelName; payload.sender = dec.sender; payload.text = dec.sender ? (dec.sender + ': ' + dec.text) : dec.text; payload.decryptedLocally = true; if (m.data.decoded.header) { // Leave payloadTypeName as GRP_TXT — processWSBatch already // accepts both 'message' and GRP_TXT-typed packet messages. } } } wsHandler = debouncedOnWS(function (msgs) { var selectedRegions = getSelectedRegionsSnapshot(); var prior = selectedHash; decryptLivePSKBatch(msgs).then(function () { // Bump unread for live-decrypted channels the user is NOT viewing. // Done here (not inside processWSBatch) so the count reflects ONLY // newly-decrypted live packets, not historical-fetch path. var bumped = false; for (var i = 0; i < msgs.length; i++) { var p = msgs[i] && msgs[i].data && msgs[i].data.decoded && msgs[i].data.decoded.payload; if (!p || !p.decryptedLocally) continue; // Use the canonical sidebar key stamped by decryptLivePSKBatch so // the comparison against `prior` (= selectedHash) actually matches // for user-added (user:*-prefixed) channels. var chKey = p.channelKey || p.channel; if (!chKey || chKey === prior) continue; var ch = channels.find(function (c) { return c.hash === chKey || c.name === chKey || c.hash === ('user:' + chKey); }); if (ch) { ch.unread = (ch.unread || 0) + 1; bumped = true; } } processWSBatch(msgs, selectedRegions); if (bumped) renderChannelList(); }); }); window._channelsHandleWSBatchForTest = handleWSBatch; window._channelsProcessWSBatchForTest = processWSBatch; // #1367: Re-render the channel list when the viewport crosses the // mobile/desktop boundary so the layout swaps between flat .ch-row // and sectioned .ch-item without a navigation. var _chMobileMQ = null; try { _chMobileMQ = window.matchMedia('(max-width: 767px)'); } catch (e) { /* noop */ } if (_chMobileMQ && typeof _chMobileMQ.addEventListener === 'function') { _chMobileMQ.addEventListener('change', function () { renderChannelList(); }); } // Tick relative timestamps every 1s — iterates channels array, updates DOM text only timeAgoTimer = setInterval(function () { var now = Date.now(); for (var i = 0; i < channels.length; i++) { var ch = channels[i]; if (!ch.lastActivityMs) continue; var text = formatSecondsAgo(Math.floor((now - ch.lastActivityMs) / 1000)); var el = document.querySelector('.ch-item-time[data-channel-hash="' + ch.hash + '"]'); if (el) el.textContent = text; // #1367: mobile rows live in a flat list; update those too. var rowEl = document.querySelector('.ch-row[data-hash="' + ch.hash + '"] .ch-row-time'); if (rowEl) rowEl.textContent = text; } }, 1000); } var timeAgoTimer = null; function destroy() { if (wsHandler) offWS(wsHandler); wsHandler = null; if (timeAgoTimer) clearInterval(timeAgoTimer); timeAgoTimer = null; if (regionChangeHandler) RegionFilter.offChange(regionChangeHandler); regionChangeHandler = null; channels = []; messages = []; selectedHash = null; selectedNode = null; hideNodeTooltip(); const panel = document.getElementById('chNodePanel'); if (panel) panel.remove(); } async function loadChannels(silent) { try { const rp = RegionFilter.getRegionParam(); var showEnc = localStorage.getItem('channels-show-encrypted') === 'true'; var params = []; if (rp) params.push('region=' + encodeURIComponent(rp)); if (showEnc) params.push('includeEncrypted=true'); const qs = params.length ? '?' + params.join('&') : ''; const data = await api('/channels' + qs, { ttl: CLIENT_TTL.channels }); channels = (data.channels || []).map(ch => { ch.lastActivityMs = ch.lastActivity ? new Date(ch.lastActivity).getTime() : 0; return ch; }).sort((a, b) => (b.lastActivityMs || 0) - (a.lastActivityMs || 0)); renderChannelList(); reconcileSelectionAfterChannelRefresh(); } catch (e) { if (!silent) { const el = document.getElementById('chList'); if (el) el.innerHTML = `
Failed to load channels
`; } } } // #1041: single source of truth for the user-facing placeholder shown // when a PSK channel has no user-supplied label. Hoisted so the helper // and any future call sites stay in sync (i18n / branding-friendly). const PRIVATE_CHANNEL_LABEL = 'Private Channel'; // Display name for a channel — handles PSK channels where the raw // "psk:" key prefix shouldn't be shown to users. Falls back to // userLabel, then a friendly placeholder, then a caller-supplied // fallback, then `Channel `. // // `fallback` lets row rendering preserve its existing "Unknown" / // "Channel " semantics for encrypted-but-not-user-added channels // without duplicating the psk:* check. function channelDisplayName(ch, fallback) { if (!ch) return ''; const name = ch.name || ''; if (ch.userLabel) return ch.userLabel; if (name.indexOf('psk:') === 0) return PRIVATE_CHANNEL_LABEL; if (name) return name; if (fallback) return fallback; return 'Channel ' + (typeof formatHashHex === 'function' ? formatHashHex(ch.hash) : ch.hash); } // #1034 PR1: render a single channel row (used by all sidebar sections). function renderChannelRow(ch) { const isEncrypted = ch.encrypted === true; const isUserAdded = ch.userAdded === true; // #1041: route through channelDisplayName so the psk:* → "Private // Channel" rule lives in one place. Pass an `encryptedFallback` so // rows for non-user-added encrypted channels keep showing "Unknown" // (their existing behavior) when there's no name at all. const encryptedFallback = isEncrypted ? 'Unknown' : ''; const name = channelDisplayName(ch, encryptedFallback); const color = isEncrypted && !isUserAdded ? 'var(--text-muted, #6b7280)' : getChannelColor(ch.hash); const time = ch.lastActivityMs ? formatSecondsAgo(Math.floor((Date.now() - ch.lastActivityMs) / 1000)) : ''; // Preview: show last sender+message when we have one. Otherwise show // nothing rather than "0 messages" — the count is misleading for // user-added (PSK) channels where messageCount only reflects // server-known activity, not actually-decrypted messages. let preview; if (ch.lastSender && ch.lastMessage) { preview = `${ch.lastSender}: ${truncate(ch.lastMessage, 28)}`; } else if (isEncrypted && !isUserAdded) { preview = `0x${formatHashHex(ch.hash)}`; } else if (typeof ch.messageCount === 'number' && ch.messageCount > 0) { preview = `${ch.messageCount} messages`; } else { preview = ''; } const sel = selectedHash === ch.hash ? ' selected' : ''; const encClass = isUserAdded ? ' ch-user-added' : (isEncrypted ? ' ch-encrypted' : ''); const badgeIcon = isUserAdded ? '🔓' : (isEncrypted ? '🔒' : null); const abbr = badgeIcon || (name.startsWith('#') ? name.slice(0, 3) : name.slice(0, 2).toUpperCase()); const chColor = window.ChannelColors ? window.ChannelColors.get(ch.hash) : null; const dotStyle = chColor ? ` style="background:${chColor}"` : ''; const borderStyle = chColor ? ` style="border-left:3px solid ${chColor}"` : ''; // #1033: must NOT be a `; } // #1367: mobile chat-app row renderer. Full-width 80px rows with a // hash-colored avatar, bold name, ellipsized last-message preview, // and right-aligned relative timestamp. No inline action chips. function isMobileChannels() { try { return window.matchMedia('(max-width: 767px)').matches; } catch (e) { return false; } } function avatarTextForChannel(ch) { const name = ch && ch.name ? String(ch.name) : ''; if (name.charAt(0) === '#') return name.slice(0, 3); // "#wa" if (ch && ch.encrypted && !ch.userAdded) return '🔒'; if (ch && ch.userAdded) return '🔑'; // Fallback: 2-char uppercase abbreviation. return name.replace(/[^A-Za-z0-9]/g, '').slice(0, 2).toUpperCase() || String(ch && ch.hash || '?').slice(0, 2).toUpperCase(); } function renderChannelRowMobile(ch) { const isEncrypted = ch.encrypted === true; const isUserAdded = ch.userAdded === true; const encryptedFallback = isEncrypted ? 'Unknown' : ''; const name = channelDisplayName(ch, encryptedFallback); const color = (isEncrypted && !isUserAdded) ? 'var(--text-muted, #6b7280)' : getChannelColor(ch.hash); const time = ch.lastActivityMs ? formatSecondsAgo(Math.floor((Date.now() - ch.lastActivityMs) / 1000)) : ''; let preview = ''; if (ch.lastSender && ch.lastMessage) { preview = ch.lastSender + ': ' + ch.lastMessage; } else if (isEncrypted && !isUserAdded) { preview = '0x' + formatHashHex(ch.hash); } else if (typeof ch.messageCount === 'number' && ch.messageCount > 0) { preview = ch.messageCount + ' messages'; } const abbr = avatarTextForChannel(ch); const sel = selectedHash === ch.hash ? ' selected' : ''; return ''; } // #1034 PR1: sectioned sidebar — My Channels / Network / Encrypted (N). function renderChannelList() { const el = document.getElementById('chList'); if (!el) return; if (channels.length === 0) { el.innerHTML = '
No channels found
'; return; } // #1367: mobile gets a flat chat-app list (no sections, no inline actions). if (isMobileChannels()) { const sortByActivity = (a, b) => (b.lastActivityMs || 0) - (a.lastActivityMs || 0); const sorted = channels.slice().sort(sortByActivity); el.innerHTML = sorted.map(renderChannelRowMobile).join(''); return; } const sortByActivity = (a, b) => (b.lastActivityMs || 0) - (a.lastActivityMs || 0); const sortByCount = (a, b) => (b.messageCount || 0) - (a.messageCount || 0); const mine = channels.filter(c => c.userAdded === true).sort(sortByActivity); const network = channels.filter(c => c.userAdded !== true && c.encrypted !== true).sort(sortByActivity); const encrypted = channels.filter(c => c.userAdded !== true && c.encrypted === true).sort(sortByCount); // Encrypted section collapsed by default; user toggle persisted in localStorage. const collapsed = localStorage.getItem('ch-encrypted-collapsed') !== 'false'; const sections = []; if (mine.length > 0) { sections.push( `
My Channels 🖥️ (this browser)
${mine.map(renderChannelRow).join('')}
` ); } sections.push( `
Network
${network.length ? network.map(renderChannelRow).join('') : '
No public channels reported by the server.
'}
` ); sections.push( `
${encrypted.length ? encrypted.map(renderChannelRow).join('') : '
No unkeyed encrypted channels seen.
'}
` ); el.innerHTML = sections.join(''); // Toggle expand/collapse for the Encrypted section. const toggle = document.getElementById('chEncryptedToggle'); if (toggle) { toggle.addEventListener('click', function () { const wasCollapsed = localStorage.getItem('ch-encrypted-collapsed') !== 'false'; const next = wasCollapsed ? 'false' : 'true'; try { localStorage.setItem('ch-encrypted-collapsed', next); } catch (e) { /* quota */ } renderChannelList(); }); } } async function selectChannel(hash, decryptOpts) { const rp = RegionFilter.getRegionParam() || ''; const request = beginMessageRequest(hash, rp); selectedHash = hash; // Clear unread badge on the channel we're about to view (#1029). var __selCh = channels.find(function (c) { return c.hash === hash; }); if (__selCh && __selCh.unread) { __selCh.unread = 0; } history.replaceState(null, '', `#/channels/${encodeURIComponent(hash)}`); // #1367: mobile slide-in — flip the layout into detail mode so CSS // can swap the visible pane. Desktop is a no-op (rule matches mobile). document.querySelector('.ch-layout')?.classList.add('ch-detail-open'); renderChannelList(); const ch = channels.find(c => c.hash === hash); // #1041: never show raw "psk:" prefixes in the header — use the // user-supplied label or "Private Channel". const name = ch ? channelDisplayName(ch) : `Channel ${formatHashHex(hash)}`; const header = document.getElementById('chHeader'); header.querySelector('.ch-header-text').textContent = `${name} — ${ch?.messageCount || 0} messages`; const msgEl = document.getElementById('chMessages'); // Shared helper: fetch, decrypt, and render messages for a channel key (M5: cache-first) async function decryptAndRender(keyHex, channelHashByte, channelName) { msgEl.innerHTML = '
Decrypting messages…
'; var result = await fetchAndDecryptChannel(keyHex, channelHashByte, channelName, { onCacheHit: function (cachedMsgs) { // M5: Render cached messages immediately while delta fetch runs messages = cachedMsgs; if (messages.length > 0) { header.querySelector('.ch-header-text').textContent = name + ' — ' + messages.length + ' messages (cached)'; renderMessages(); scrollToBottom(); } } }); if (isStaleMessageRequest(request)) return { stale: true }; if (result.wrongKey) { msgEl.innerHTML = '
🔒 Key does not match — no messages could be decrypted
'; return { wrongKey: true, messageCount: 0 }; } if (result.error) { msgEl.innerHTML = '
' + escapeHtml(result.error) + '
'; return { error: result.error, messageCount: 0 }; } messages = result.messages || []; if (messages.length === 0) { msgEl.innerHTML = '
No encrypted messages found for this channel
'; } else { header.querySelector('.ch-header-text').textContent = `${name} — ${messages.length} messages (decrypted)`; renderMessages(); scrollToBottom(); } return { messageCount: messages.length }; } // Client-side decryption path (#725 M2) if (decryptOpts && decryptOpts.userKey) { return await decryptAndRender(decryptOpts.userKey, decryptOpts.channelHashByte, decryptOpts.channelName); } // Check if this is a user-added channel that needs decryption var storedKeys = typeof ChannelDecrypt !== 'undefined' ? ChannelDecrypt.getStoredKeys() : {}; if (hash.startsWith('user:')) { var chName = hash.substring(5); if (storedKeys[chName]) { var keyHex = storedKeys[chName]; var keyBytes = ChannelDecrypt.hexToBytes(keyHex); var hashByte = await ChannelDecrypt.computeChannelHash(keyBytes); await decryptAndRender(keyHex, hashByte, chName); return; } } // Also check if an encrypted channel hash matches a stored key if (ch && ch.encrypted) { for (var kn in storedKeys) { var kh = storedKeys[kn]; var kb = ChannelDecrypt.hexToBytes(kh); var hb = await ChannelDecrypt.computeChannelHash(kb); if (String(hb) === String(hash) || String(ch.hash) === String(hb)) { await decryptAndRender(kh, hb, kn); return; } } // #781: No matching key found — show lock message instead of fetching gibberish msgEl.innerHTML = '
🔒 This channel is encrypted and no decryption key is configured
'; return; } // #811: Deep link to a `#`-named channel that's not in the loaded list. // If a stored key matches, decrypt. Otherwise we must distinguish an // encrypted-no-key channel (show lock) from an unencrypted channel that // simply isn't in the toggle-off list (#825 — must fall through to REST). if (hash.charAt(0) === '#') { if (storedKeys[hash]) { var keyHex2 = storedKeys[hash]; var keyBytes2 = ChannelDecrypt.hexToBytes(keyHex2); var hashByte2 = await ChannelDecrypt.computeChannelHash(keyBytes2); await decryptAndRender(keyHex2, hashByte2, hash); return; } // #825: confirm encrypted-ness via an encrypted-included channel list // before assuming a lock state. Conservative on error — fall through. // Show a loading affordance so cold deep links don't display stale content // for the duration of the metadata RTT (cached 15s thereafter). msgEl.innerHTML = '
Loading messages…
'; try { var rpInc = RegionFilter.getRegionParam(); var paramsInc = ['includeEncrypted=true']; if (rpInc) paramsInc.push('region=' + encodeURIComponent(rpInc)); var allCh = await api('/channels?' + paramsInc.join('&'), { ttl: CLIENT_TTL.channels }); if (isStaleMessageRequest(request)) return; var foundCh = (allCh.channels || []).find(function (c) { return c.hash === hash; }); if (foundCh && foundCh.encrypted === true) { msgEl.innerHTML = '
🔒 This channel is encrypted and no decryption key is configured
'; return; } // Unencrypted (or unknown) — fall through to the REST fetch below. } catch (e) { // ignore — fall through to REST fetch } } msgEl.innerHTML = '
Loading messages…
'; try { const regionQs = rp ? '®ion=' + encodeURIComponent(rp) : ''; const data = await api(`/channels/${encodeURIComponent(hash)}/messages?limit=200${regionQs}`, { ttl: CLIENT_TTL.channelMessages }); if (isStaleMessageRequest(request)) return; messages = data.messages || []; if (messages.length === 0 && rp) { msgEl.innerHTML = '
Channel not available in selected region
'; } else { renderMessages(); scrollToBottom(); } } catch (e) { if (isStaleMessageRequest(request)) return; msgEl.innerHTML = `
Failed to load messages: ${e.message}
`; } } async function refreshMessages(opts) { if (!selectedHash) return; // Skip refresh for encrypted channels — no messages to fetch var selCh = channels.find(function (c) { return c.hash === selectedHash; }); if (selCh && selCh.encrypted) return; opts = opts || {}; const msgEl = document.getElementById('chMessages'); if (!msgEl) return; const wasAtBottom = msgEl.scrollHeight - msgEl.scrollTop - msgEl.clientHeight < 60; try { const requestHash = selectedHash; const rp = RegionFilter.getRegionParam() || ''; const request = beginMessageRequest(requestHash, rp); const regionQs = (rp ? '®ion=' + encodeURIComponent(rp) : ''); const data = await api(`/channels/${encodeURIComponent(requestHash)}/messages?limit=200${regionQs}`, { ttl: CLIENT_TTL.channelMessages, bust: !!opts.forceNoCache }); if (isStaleMessageRequest(request)) return; const newMsgs = data.messages || []; if (opts.regionSwitch && rp && newMsgs.length === 0) { messages = []; msgEl.innerHTML = '
Channel not available in selected region
'; document.getElementById('chScrollBtn')?.classList.add('hidden'); return; } // #92: Use message ID/hash for change detection instead of count + timestamp var _getLastId = function (arr) { var m = arr.length ? arr[arr.length - 1] : null; return m ? (m.id || m.packetId || m.timestamp || '') : ''; }; if (newMsgs.length === messages.length && _getLastId(newMsgs) === _getLastId(messages)) return; var prevLen = messages.length; messages = newMsgs; renderMessages(); if (wasAtBottom) scrollToBottom(); else { document.getElementById('chScrollBtn')?.classList.remove('hidden'); var liveEl = document.getElementById('chAriaLive'); if (liveEl) liveEl.textContent = Math.max(1, newMsgs.length - prevLen) + ' new messages'; } } catch {} } function renderMessages() { const msgEl = document.getElementById('chMessages'); if (!msgEl) return; if (messages.length === 0) { msgEl.innerHTML = '
No messages in this channel yet
'; return; } msgEl.innerHTML = messages.map(msg => { const sender = msg.sender || 'Unknown'; const senderColor = getSenderColor(sender); const senderLetter = sender.replace(/[^\w]/g, '').charAt(0).toUpperCase() || '?'; let rawBody = msg.text || ''; // Detect a leading @TARGET reply prefix and split it out so we can // style it in the sender color (#1367 detail-view spec). let replyTarget = ''; const replyMatch = rawBody.match(/^@([A-Za-z0-9_\-]{1,32})\s+/); if (replyMatch) { replyTarget = replyMatch[1]; rawBody = rawBody.slice(replyMatch[0].length); } let displayText = highlightMentions(rawBody); if (replyTarget) { displayText = '@' + escapeHtml(replyTarget) + ' ' + displayText; } const tsDate = msg.timestamp ? new Date(msg.timestamp) : null; const time = tsDate ? tsDate.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : ''; const date = tsDate ? tsDate.toLocaleDateString() : ''; const meta = []; meta.push(date + ' ' + time); if (msg.repeats > 1) meta.push(`${msg.repeats}× heard`); if (msg.observers?.length > 1) meta.push(`${msg.observers.length} observers`); if (msg.hops > 0) meta.push(`${msg.hops} hops`); if (msg.snr !== null && msg.snr !== undefined) meta.push(`SNR ${msg.snr}`); const safeId = btoa(encodeURIComponent(sender)); // #1367: emit BOTH the new chat-app class names (.ch-message / // .ch-message-bubble / .ch-message-meta) and the legacy .ch-msg* // names so existing tests/themes don't regress. return `
${senderLetter}
${displayText}
${meta.join(' · ')}${msg.packetHash ? ` · View packet →` : ''}
`; }).join(''); } function scrollToBottom() { const msgEl = document.getElementById('chMessages'); if (msgEl) { msgEl.scrollTop = msgEl.scrollHeight; autoScroll = true; document.getElementById('chScrollBtn')?.classList.add('hidden'); } } window._channelsSetStateForTest = function (state) { if (!state) return; if (Array.isArray(state.channels)) channels = state.channels; if (Array.isArray(state.messages)) messages = state.messages; if (Object.prototype.hasOwnProperty.call(state, 'selectedHash')) selectedHash = state.selectedHash; }; window._channelsSetObserverRegionsForTest = function (byId, byName) { observerIataById = byId || {}; observerIataByName = byName || {}; }; window._channelsSelectChannelForTest = selectChannel; window._channelsRefreshMessagesForTest = refreshMessages; window._channelsLoadChannelsForTest = loadChannels; window._channelsBeginMessageRequestForTest = beginMessageRequest; window._channelsIsStaleMessageRequestForTest = isStaleMessageRequest; window._channelsReconcileSelectionForTest = reconcileSelectionAfterChannelRefresh; window._channelsGetStateForTest = function () { return { channels: channels, messages: messages, selectedHash: selectedHash }; }; window._channelsShouldProcessWSMessageForRegion = shouldProcessWSMessageForRegion; registerPage('channels', { init, destroy }); })();