mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-07-28 07:59:32 +00:00
## Summary Fixes #1498. Roots out the actual WS-vs-REST race that has made `test-channels-ws-batch-e2e.js` flaky on master for ~2 weeks. ## Root cause `selectChannel()` and `refreshMessages()` unconditionally replace the in-memory `messages` array with the REST response. Any WebSocket-pushed messages appended between `selectedHash` assignment (when the chat view opens) and the REST resolution were silently stomped. The flaky test was a real-world manifestation: when the synthetic `processWSBatch` injection happened to land BEFORE the in-flight `/channels/<hash>/messages` fetch resolved, the (effectively empty) fixture REST response wiped it out. This is a production bug too — real users would lose any live message that arrived during channel load. ## Why the three prior PRs missed it - **#1499** — added a 500ms `waitForTimeout` before injection. Often enough to let the REST fetch resolve first, but not under any added load. - **#1502** — skipped the test instead of diagnosing. - **#1511** — re-enabled with a "wait by hash, not index" predicate. That fixed the symptom of `messages[length-1]` being some unrelated packet, but did nothing for the underlying race where the WS-pushed message gets wiped entirely by the REST replacement. None of the three PRs reproduced the failure locally. The hypothesis "closure over stale messages" in the test comment was never substantiated. ## Fix Stamp WS-pushed messages with `_fromWS=true` and add a `mergeWsAppendedIntoRest()` helper that preserves WS-pushed messages whose `packetHash` isn't already present in the REST response. Applied to all three REST replacement sites: - `selectChannel()` REST path - `decryptAndRender()` (encrypted channel path) - `refreshMessages()` (background poll) ## Tests Added `test-channels-ws-race-1498-e2e.js`. Deterministically forces the race by stubbing `fetch` to delay the `/channels/<hash>/messages` response 800ms, injects a WS message during the delay, asserts it survives the late REST resolution. - Red commit (`9dfc4b08`): test added against unfixed master HEAD → fails with `WS message stomped by REST fetch — messages after fetch: {"present":false,"count":0,"hashes":[]}`. - Green commit (`8f336591`): applies the fix → passes. Verified the red commit actually fails when the production change is reverted (TDD discipline check). ## Local repro stats Used the instrumented frontend (`public-instrumented/`) which exposes the race more reliably than the raw `public/` build (slower JS load widens the WS-vs-REST window). - Before fix: 29/30 pass (1 reproduced "injected message not found" failure — identical to CI). The new race test: 0/50 pass. - After fix: original `test-channels-ws-batch-e2e.js` — **50/50 pass**. New `test-channels-ws-race-1498-e2e.js` — **50/50 pass**. ## CI Wired the new race test into `.github/workflows/deploy.yml` right after the existing `test-channels-ws-batch-e2e.js` invocation. ## Preflight `bash ~/.openclaw/skills/pr-preflight/scripts/run-all.sh origin/master` → all gates pass (PII, branch scope, red commit, CSS vars, LIKE-on-JSON, sync migration, all warnings). Browser verified: the fix was validated end-to-end against the local fixture server (`http://localhost:13581`) using the headless Chromium the CI uses. E2E assertion added: `test-channels-ws-race-1498-e2e.js` (deterministic race regression). --------- Co-authored-by: bot <bot@local> Co-authored-by: corescope-bot <bot@corescope.local>
2148 lines
99 KiB
JavaScript
2148 lines
99 KiB
JavaScript
/* === CoreScope — channels.js === */
|
||
'use strict';
|
||
|
||
(function () {
|
||
let channels = [];
|
||
let selectedHash = null;
|
||
let messages = [];
|
||
let wsHandler = null;
|
||
|
||
// #1498: messages appended via the live WebSocket are stamped with
|
||
// _fromWS so a subsequent REST replacement (selectChannel /
|
||
// refreshMessages) can merge them in instead of stomping them.
|
||
// mergeWsAppendedIntoRest() preserves any WS-pushed messages whose
|
||
// packetHash is not already present in the REST response.
|
||
//
|
||
// Takes currentMsgs explicitly (rather than reading the module-global
|
||
// `messages`) so the helper is unit-testable in isolation.
|
||
//
|
||
// Eviction policy (round-1 review finding #1):
|
||
// - REST contains the same packetHash → REST version wins, survivor
|
||
// dropped (_fromWS flag effectively cleared because REST entry
|
||
// has no stamp).
|
||
// - Survivor with packetHash NOT in REST → preserved as survivor.
|
||
// - Survivor older than MAX_WS_SURVIVOR_MS → dropped regardless
|
||
// (defensive cap so a WS message REST never returns can't survive
|
||
// forever in the array).
|
||
// - Survivor with null/undefined packetHash (finding #3): preserved
|
||
// too (no hash → no way to dedup against REST → REST can't
|
||
// possibly include it). The max-age cap evicts it eventually.
|
||
// - Ordering: REST first, survivors appended at the end. Caller's
|
||
// ordering convention is oldest→newest, and survivors arrived
|
||
// AFTER the REST snapshot, so end-of-array is the correct slot.
|
||
var MAX_WS_SURVIVOR_MS = 5 * 60 * 1000; // 5 minutes
|
||
function mergeWsAppendedIntoRest(currentMsgs, restMsgs) {
|
||
if (!Array.isArray(restMsgs)) return [];
|
||
if (!Array.isArray(currentMsgs) || currentMsgs.length === 0) return restMsgs.slice();
|
||
var restHashes = new Set();
|
||
for (var i = 0; i < restMsgs.length; i++) {
|
||
var h = restMsgs[i] && restMsgs[i].packetHash;
|
||
if (h) restHashes.add(h);
|
||
}
|
||
var now = Date.now();
|
||
var survivors = [];
|
||
for (var j = 0; j < currentMsgs.length; j++) {
|
||
var m = currentMsgs[j];
|
||
if (!m || !m._fromWS) continue;
|
||
// Drop survivors past max age (defensive eviction).
|
||
if (m._wsAt && (now - m._wsAt) > MAX_WS_SURVIVOR_MS) continue;
|
||
// If packetHash present and REST contains it, REST wins (drop).
|
||
if (m.packetHash && restHashes.has(m.packetHash)) continue;
|
||
// Hash absent OR not in REST → preserve.
|
||
survivors.push(m);
|
||
}
|
||
// Always return a fresh array — never alias restMsgs — so callers
|
||
// can mutate freely without leaking changes back to the input.
|
||
return survivors.length ? restMsgs.concat(survivors) : restMsgs.slice();
|
||
}
|
||
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 = '<div class="ch-empty">Choose a channel from the sidebar to view messages</div>';
|
||
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 = `<div class="ch-tooltip-name">${escapeHtml(node.name)}</div>
|
||
<div class="ch-tooltip-role">${role}</div>
|
||
<div class="ch-tooltip-meta">Last seen: ${lastSeen}</div>
|
||
<div class="ch-tooltip-key mono">${(node.public_key || '').slice(0, 16)}…</div>`;
|
||
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 = `<div class="ch-node-panel-header">
|
||
<strong>${escapeHtml(name)}</strong>
|
||
<button class="ch-node-close" data-action="ch-close-node" aria-label="Close">✕</button>
|
||
</div>
|
||
<div class="ch-node-panel-body">
|
||
<div class="ch-node-field" style="color:var(--text-muted)">No node record found — this sender has only been seen in channel messages, not via adverts.</div>
|
||
</div>`;
|
||
_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 = `<div class="ch-node-panel-header">
|
||
<strong>${escapeHtml(n.name || 'Unknown')}</strong>
|
||
<button class="ch-node-close" data-action="ch-close-node" aria-label="Close">✕</button>
|
||
</div>
|
||
<div class="ch-node-panel-body">
|
||
<div class="ch-node-field"><span class="ch-node-label">Role</span> ${role}</div>
|
||
<div class="ch-node-field"><span class="ch-node-label">Last Seen</span> ${lastSeen}</div>
|
||
<div class="ch-node-field"><span class="ch-node-label">Adverts</span> ${n.advert_count || 0}</div>
|
||
${n.lat && n.lon ? `<div class="ch-node-field"><span class="ch-node-label">Location</span> ${Number(n.lat).toFixed(4)}, ${Number(n.lon).toFixed(4)}</div>` : ''}
|
||
<div class="ch-node-field mono" style="font-size:11px;word-break:break-all"><span class="ch-node-label">Key</span> ${n.public_key}</div>
|
||
${adverts.length ? `<div class="ch-node-adverts"><span class="ch-node-label">Recent Adverts</span>
|
||
${adverts.slice(0, 5).map(a => `<div class="ch-node-advert">${timeAgo(a.timestamp)} · SNR ${a.snr != null ? a.snr + 'dB' : '?'}</div>`).join('')}
|
||
</div>` : ''}
|
||
<a href="#/nodes/${n.public_key}" class="ch-node-link">View full node detail →</a>
|
||
</div>`;
|
||
_focusTrapCleanup = trapFocus(panel);
|
||
panel.querySelector('.ch-node-close')?.focus();
|
||
} catch (e) {
|
||
panel.innerHTML = `<div class="ch-node-panel-header"><strong>${escapeHtml(name)}</strong><button class="ch-node-close" data-action="ch-close-node">✕</button></div><div class="ch-node-panel-body ch-empty">Failed to load</div>`;
|
||
_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, '>').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 '<span class="ch-mention ch-sender-link" tabindex="0" role="button" data-node="' + safeId + '">@' + name + '</span>';
|
||
});
|
||
}
|
||
|
||
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:<hex8>" — 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 = `<div class="ch-layout">
|
||
<div class="ch-sidebar" aria-label="Channel list">
|
||
<div class="ch-sidebar-header">
|
||
<div class="ch-sidebar-title"><span class="ch-icon">💬</span> Channels</div>
|
||
<div id="chRegionFilter" class="region-filter-container ch-header-region"></div>
|
||
<button type="button" id="chAddChannelBtn" class="ch-add-channel-btn"
|
||
aria-label="Add channel" title="Add a channel — generate, paste a key, or monitor a hashtag">+ Add</button>
|
||
</div>
|
||
<div id="chAddStatus" class="ch-add-status" style="display:none"></div>
|
||
<div class="ch-channel-list" id="chList" role="listbox" aria-label="Channels">
|
||
<div class="ch-loading">Loading channels…</div>
|
||
</div>
|
||
<div class="ch-sidebar-resize" aria-hidden="true"></div>
|
||
</div>
|
||
<!-- #1034 PR1: Add Channel modal -->
|
||
<div id="chAddChannelModal" class="modal-overlay ch-modal-overlay hidden" role="dialog" aria-modal="true" aria-labelledby="chModalTitle" hidden>
|
||
<div class="modal ch-modal" role="document">
|
||
<button type="button" class="modal-close ch-modal-close" id="chModalClose" data-action="ch-modal-close" aria-label="Close">✕</button>
|
||
<h3 id="chModalTitle">Add Channel</h3>
|
||
<div class="ch-modal-callout" role="note">
|
||
⚠️ Channels are saved to <strong>THIS browser only</strong>. They won't appear on other devices or browsers, and clearing browser data will remove them.
|
||
</div>
|
||
|
||
<section class="ch-modal-section" aria-labelledby="chSecGenTitle">
|
||
<h4 id="chSecGenTitle" class="ch-modal-section-title">Generate PSK Channel</h4>
|
||
<p class="ch-modal-section-hint">Create a new private channel with a random key. Share the QR code with others to add it.</p>
|
||
<div class="ch-modal-row">
|
||
<input type="text" id="chGenerateName" class="ch-modal-input" placeholder="Channel name (e.g. My Crew)" aria-label="Channel name" spellcheck="false">
|
||
<button type="button" id="chGenerateBtn" class="btn-primary">Generate & Show QR</button>
|
||
</div>
|
||
<div id="qr-output" class="ch-qr-output" aria-live="polite"></div>
|
||
</section>
|
||
|
||
<section class="ch-modal-section" aria-labelledby="chSecPskTitle">
|
||
<h4 id="chSecPskTitle" class="ch-modal-section-title">Add Private Channel (PSK)</h4>
|
||
<p class="ch-modal-section-hint">Paste a 32-character hex key someone shared with you, or scan their QR code.</p>
|
||
<div class="ch-modal-row">
|
||
<input type="text" id="chPskKey" class="ch-modal-input ch-modal-input--mono"
|
||
placeholder="32-char hex key (0-9, a-f)"
|
||
pattern="[0-9a-fA-F]{32}"
|
||
maxlength="32"
|
||
aria-label="32-character hex PSK key" spellcheck="false" autocomplete="off">
|
||
<button type="button" id="scan-qr-btn" class="ch-modal-btn-secondary" title="Scan a meshcore:// channel QR with your camera">📷 Scan QR</button>
|
||
</div>
|
||
<div class="ch-modal-row">
|
||
<input type="text" id="chPskName" class="ch-modal-input" placeholder="Display name (optional)" aria-label="Optional display name" spellcheck="false">
|
||
<button type="button" id="chPskAddBtn" class="btn-primary">Add</button>
|
||
</div>
|
||
<div id="chPskError" class="ch-modal-error" style="display:none" role="alert"></div>
|
||
</section>
|
||
|
||
<section class="ch-modal-section" aria-labelledby="chSecTagTitle">
|
||
<h4 id="chSecTagTitle" class="ch-modal-section-title">Monitor Hashtag Channel</h4>
|
||
<p class="ch-modal-section-hint">Decrypt traffic on a public hashtag channel by deriving the key from its name.</p>
|
||
<div class="ch-modal-row ch-hashtag-row">
|
||
<span class="ch-hashtag-prefix" aria-hidden="true">#</span>
|
||
<input type="text" id="chHashtagName" class="ch-modal-input"
|
||
placeholder="meshcore"
|
||
aria-label="Hashtag channel name (without #)" spellcheck="false" autocomplete="off">
|
||
<button type="button" id="chHashtagBtn" class="btn-primary">Monitor</button>
|
||
</div>
|
||
<div class="ch-modal-warn">⚠ Case-sensitive — <code>#meshcore</code> ≠ <code>#MeshCore</code></div>
|
||
</section>
|
||
|
||
<div class="ch-modal-footer">
|
||
🔒 Keys stay in your browser — CoreScope is a passive observer that monitors and decrypts traffic but cannot transmit over RF. Use ✕ to remove individual channels.
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- #1087 Bug 4: dedicated Share modal — separate from the Add
|
||
Channel modal above. Add = INPUT (paste/scan/generate). Share
|
||
= OUTPUT (display existing key as QR + URL + copyable text).
|
||
Reusing the Add modal for Share confused intent and let the
|
||
QR section bleed into the Add submit flow. -->
|
||
<div id="chShareModal" class="modal-overlay ch-modal-overlay hidden" role="dialog" aria-modal="true" aria-labelledby="chShareModalTitle" hidden>
|
||
<div class="modal ch-modal ch-share-modal" role="document">
|
||
<button type="button" class="modal-close ch-modal-close" id="chShareModalClose" data-action="ch-share-modal-close" aria-label="Close">✕</button>
|
||
<h3 id="chShareModalTitle" class="ch-share-modal-title">Share Channel</h3>
|
||
<div class="ch-share-modal-body">
|
||
<div id="chShareQr" class="ch-share-qr" aria-live="polite"></div>
|
||
<div class="ch-share-field-group">
|
||
<label class="ch-share-label" for="chShareKey">Hex Key</label>
|
||
<div class="ch-share-row">
|
||
<input type="text" id="chShareKey" data-share-field="key" class="ch-modal-input ch-modal-input--mono" readonly aria-label="Channel hex key">
|
||
<button type="button" class="ch-modal-btn-secondary" data-share-copy="key" aria-label="Copy hex key">📋 Copy</button>
|
||
</div>
|
||
</div>
|
||
<div class="ch-modal-warn" role="note">
|
||
⚠ Privacy: only share with trusted people. Anyone with this key can read all messages on this channel.
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="ch-main" role="region" aria-label="Channel messages">
|
||
<div class="ch-main-header" id="chHeader">
|
||
<button type="button" class="ch-back" data-action="ch-back"
|
||
aria-label="Back to channel list" title="Back">‹</button>
|
||
<span class="ch-header-text">Select a channel</span>
|
||
</div>
|
||
<div class="ch-messages" id="chMessages">
|
||
<div class="ch-empty">Choose a channel from the sidebar to view messages</div>
|
||
</div>
|
||
<span id="chAriaLive" class="sr-only" aria-live="polite"></span>
|
||
<button class="ch-scroll-btn hidden" id="chScrollBtn">↓ New messages</button>
|
||
</div>
|
||
</div>`;
|
||
|
||
RegionFilter.init(document.getElementById('chRegionFilter'));
|
||
|
||
// #1409: Do NOT force-enable encrypted-channel visibility on init. The
|
||
// operator-facing toggle (read at the includeEncrypted gate in
|
||
// loadChannels) drives whether the API returns the 246+ encrypted
|
||
// placeholders. Default is OFF (hidden); a future user-facing toggle
|
||
// writes the flag explicitly.
|
||
|
||
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:<hex8>` 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:<hex8>).
|
||
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);
|
||
});
|
||
|
||
// #1454 — customizer flips the "show encrypted channels" toggle, which
|
||
// writes localStorage and fires this event. Re-fetch the list live so
|
||
// the operator sees the change without a page reload.
|
||
window.addEventListener('mc-channels-show-encrypted-changed', function () {
|
||
loadChannels(true);
|
||
});
|
||
|
||
// #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 = '<div class="ch-empty">Choose a channel from the sidebar to view messages</div>';
|
||
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:<hex8>`
|
||
// 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 = '<div class="ch-empty">Choose a channel from the sidebar to view messages</div>';
|
||
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 = '<div class="ch-empty">Key removed — add a key to decrypt messages</div>';
|
||
}
|
||
}
|
||
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;
|
||
|
||
// #1468: drop CHAN messages with no decoded channel name instead of
|
||
// synthesizing a literal "unknown" row that renders as a fake channel
|
||
// in the sidebar. Server-side (#1373/#1377) already filters these from
|
||
// /api/channels; the live WebSocket router was the remaining offender.
|
||
if (!payload.channel) continue;
|
||
|
||
var channelName = payload.channel;
|
||
// For live-decrypted user-added (PSK) channels, decryptLivePSKBatch
|
||
// also stamps payload.channelKey ("user:<name>") 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);
|
||
}
|
||
// #1498 round-1 finding #2: a WS-arriving observer update on a
|
||
// REST-loaded message must be stamped so the next REST tick
|
||
// doesn't stomp it. Without this, the new observer disappears.
|
||
existing._fromWS = true;
|
||
existing._wsAt = Date.now();
|
||
} 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,
|
||
// #1498: mark as WS-pushed so a later REST replacement
|
||
// (selectChannel / refreshMessages) can merge instead of
|
||
// stomp. Without this flag the REST response wipes any
|
||
// live messages that landed during the in-flight fetch.
|
||
_fromWS: true,
|
||
_wsAt: Date.now(),
|
||
});
|
||
}
|
||
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:<name>" 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 = `<div class="ch-empty">Failed to load channels</div>`;
|
||
}
|
||
}
|
||
}
|
||
|
||
// #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:<hex8>" key prefix shouldn't be shown to users. Falls back to
|
||
// userLabel, then a friendly placeholder, then a caller-supplied
|
||
// fallback, then `Channel <hash>`.
|
||
//
|
||
// `fallback` lets row rendering preserve its existing "Unknown" /
|
||
// "Channel <hash>" 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 <button> — outer .ch-item is itself a <button>;
|
||
// nested <button> is invalid HTML5 and the parser orphans everything
|
||
// after it. Use <span role="button">; keydown handler on #chList
|
||
// (Enter/Space) keeps it keyboard-accessible.
|
||
// Icon button factory — used for the per-row remove/share controls.
|
||
// Both share the .ch-icon-btn base class (touch target, opacity); a
|
||
// modifier class (.ch-remove-btn / .ch-share-btn) supplies size + color.
|
||
function iconBtn(modClass, dataAttr, hash, name, glyph, title, ariaVerb, extraAttrs) {
|
||
return ' <span class="ch-icon-btn ' + modClass + '" role="button" tabindex="0"'
|
||
+ ' ' + dataAttr + '="' + escapeHtml(hash) + '"'
|
||
+ (extraAttrs || '')
|
||
+ ' title="' + title + '"'
|
||
+ ' aria-label="' + ariaVerb + ' ' + escapeHtml(name) + '">' + glyph + '</span>';
|
||
}
|
||
const removeBtn = isUserAdded
|
||
? iconBtn('ch-remove-btn', 'data-remove-channel', ch.hash, name, '✕',
|
||
'Remove channel and clear saved key', 'Remove', '')
|
||
: '';
|
||
const shareBtn = isUserAdded
|
||
? iconBtn('ch-share-btn', 'data-share-channel', ch.hash, name, '📤 Share',
|
||
'Share channel key (QR + URL)', 'Share', ' aria-haspopup="dialog"')
|
||
: '';
|
||
const userBadge = isUserAdded ? ' <span class="ch-user-badge" title="You added this key" aria-label="Your key">🔑</span>' : '';
|
||
const unreadBadge = (ch.unread && ch.unread > 0)
|
||
? ' <span class="ch-unread-badge" data-unread-channel="' + escapeHtml(ch.hash) + '" title="' + ch.unread + ' new" aria-label="' + ch.unread + ' unread">' + (ch.unread > 99 ? '99+' : ch.unread) + '</span>'
|
||
: '';
|
||
|
||
return `<button class="ch-item${sel}${encClass}" data-hash="${ch.hash}"${borderStyle} type="button" role="option" aria-selected="${selectedHash === ch.hash ? 'true' : 'false'}" aria-label="${escapeHtml(name)}"${isEncrypted ? ' data-encrypted="true"' : ''}${isUserAdded ? ' data-user-added="true"' : ''}>
|
||
<div class="ch-badge" style="background:${color}" aria-hidden="true">${badgeIcon ? badgeIcon : escapeHtml(abbr)}</div>
|
||
<div class="ch-item-body">
|
||
<div class="ch-item-top">
|
||
<span class="ch-item-name">${escapeHtml(name)}</span>${userBadge}${unreadBadge}
|
||
<span class="ch-color-dot" data-channel="${escapeHtml(ch.hash)}"${dotStyle} title="Change channel color" aria-label="Change color for ${escapeHtml(name)}"></span>${chColor ? '<span class="ch-color-clear" data-channel="' + escapeHtml(ch.hash) + '" title="Clear color" aria-label="Clear color for ' + escapeHtml(name) + '">✕</span>' : ''}
|
||
<span class="ch-item-time" data-channel-hash="${ch.hash}">${time}</span>${shareBtn}${removeBtn}
|
||
</div>
|
||
<div class="ch-item-preview">${escapeHtml(preview)}</div>
|
||
</div>
|
||
</button>`;
|
||
}
|
||
|
||
// #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 '<button type="button" class="ch-row' + sel + '" data-hash="' + escapeHtml(ch.hash) +
|
||
'" role="option" aria-selected="' + (selectedHash === ch.hash ? 'true' : 'false') +
|
||
'" aria-label="' + escapeHtml(name) + '">' +
|
||
'<div class="ch-avatar ch-row-avatar" style="background:' + color +
|
||
'" aria-hidden="true">' + escapeHtml(abbr) + '</div>' +
|
||
'<div class="ch-row-body">' +
|
||
'<div class="ch-row-line1">' +
|
||
'<span class="ch-row-name">' + escapeHtml(name) + '</span>' +
|
||
'<span class="ch-row-time">' + escapeHtml(time) + '</span>' +
|
||
'</div>' +
|
||
'<div class="ch-row-preview">' + escapeHtml(preview) + '</div>' +
|
||
'</div>' +
|
||
'</button>';
|
||
}
|
||
|
||
// #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 = '<div class="ch-empty">No channels found</div>'; 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(
|
||
`<div class="ch-section ch-section-mychannels" data-section="mychannels">
|
||
<div class="ch-section-header">My Channels <span class="ch-section-locality" title="Saved only in this browser on this device">🖥️ (this browser)</span></div>
|
||
${mine.map(renderChannelRow).join('')}
|
||
</div>`
|
||
);
|
||
}
|
||
sections.push(
|
||
`<div class="ch-section ch-section-network" data-section="network">
|
||
<div class="ch-section-header">Network</div>
|
||
${network.length ? network.map(renderChannelRow).join('') : '<div class="ch-section-empty">No public channels reported by the server.</div>'}
|
||
</div>`
|
||
);
|
||
sections.push(
|
||
`<div class="ch-section ch-section-encrypted" data-section="encrypted" data-encrypted-collapsed="${collapsed ? 'true' : 'false'}">
|
||
<button type="button" class="ch-section-header ch-section-toggle" id="chEncryptedToggle" aria-expanded="${collapsed ? 'false' : 'true'}" aria-controls="chEncryptedBody">
|
||
<span class="ch-section-caret" aria-hidden="true">${collapsed ? '▸' : '▾'}</span>
|
||
Encrypted (${encrypted.length})
|
||
</button>
|
||
<div class="ch-section-body" id="chEncryptedBody"${collapsed ? ' hidden' : ''}>
|
||
${encrypted.length ? encrypted.map(renderChannelRow).join('') : '<div class="ch-section-empty">No unkeyed encrypted channels seen.</div>'}
|
||
</div>
|
||
</div>`
|
||
);
|
||
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);
|
||
// #1498: clear messages BEFORE flipping selectedHash so any WS-pushed
|
||
// messages from the previously-viewed channel can't survive into the
|
||
// new channel's view via mergeWsAppendedIntoRest(). Messages don't
|
||
// carry channel context, so the merge can't distinguish them itself.
|
||
messages = [];
|
||
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:<hex>" 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 = '<div class="ch-loading">Decrypting messages…</div>';
|
||
var result = await fetchAndDecryptChannel(keyHex, channelHashByte, channelName, {
|
||
onCacheHit: function (cachedMsgs) {
|
||
// M5: Render cached messages immediately while delta fetch runs.
|
||
// #1498 round-1 finding #4: this site is a REST replacement
|
||
// path too — it must merge any WS-pushed messages instead of
|
||
// stomping them, same as the other two sites below.
|
||
messages = mergeWsAppendedIntoRest(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 = '<div class="ch-empty ch-wrong-key">🔒 Key does not match — no messages could be decrypted</div>';
|
||
return { wrongKey: true, messageCount: 0 };
|
||
}
|
||
if (result.error) {
|
||
msgEl.innerHTML = '<div class="ch-empty">' + escapeHtml(result.error) + '</div>';
|
||
return { error: result.error, messageCount: 0 };
|
||
}
|
||
// #1498: merge WS-pushed messages that landed during the decrypt fetch.
|
||
messages = mergeWsAppendedIntoRest(messages, result.messages || []);
|
||
if (messages.length === 0) {
|
||
msgEl.innerHTML = '<div class="ch-empty">No encrypted messages found for this channel</div>';
|
||
} 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 = '<div class="ch-empty">🔒 This channel is encrypted and no decryption key is configured</div>';
|
||
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 = '<div class="ch-loading">Loading messages…</div>';
|
||
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 = '<div class="ch-empty">🔒 This channel is encrypted and no decryption key is configured</div>';
|
||
return;
|
||
}
|
||
// Unencrypted (or unknown) — fall through to the REST fetch below.
|
||
} catch (e) {
|
||
// ignore — fall through to REST fetch
|
||
}
|
||
}
|
||
|
||
msgEl.innerHTML = '<div class="ch-loading">Loading messages…</div>';
|
||
|
||
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;
|
||
// #1498: merge so any WS-pushed messages that arrived while the
|
||
// REST fetch was in flight aren't stomped.
|
||
messages = mergeWsAppendedIntoRest(messages, data.messages || []);
|
||
if (messages.length === 0 && rp) {
|
||
msgEl.innerHTML = '<div class="ch-empty">Channel not available in selected region</div>';
|
||
} else {
|
||
renderMessages();
|
||
scrollToBottom();
|
||
}
|
||
} catch (e) {
|
||
if (isStaleMessageRequest(request)) return;
|
||
msgEl.innerHTML = `<div class="ch-empty">Failed to load messages: ${e.message}</div>`;
|
||
}
|
||
}
|
||
|
||
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 = '<div class="ch-empty">Channel not available in selected region</div>';
|
||
document.getElementById('chScrollBtn')?.classList.add('hidden');
|
||
return;
|
||
}
|
||
// #92: Use message ID/hash for change detection instead of count + timestamp.
|
||
// #1498 round-1 finding #5: REST returns oldest→newest, and the
|
||
// merge appends survivors at the END (newest position), so the
|
||
// last element of `messages` is the newest item — same convention
|
||
// for REST and for the merged array. _getLastId remains correct.
|
||
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;
|
||
// #1498: merge WS-pushed messages so a refresh that races a live
|
||
// packet doesn't wipe it.
|
||
messages = mergeWsAppendedIntoRest(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 = '<div class="ch-empty">No messages in this channel yet</div>'; 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 = '<span class="ch-reply-target" style="color:' + senderColor + '">@' +
|
||
escapeHtml(replyTarget) + '</span> ' + 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 `<div class="ch-msg ch-message">
|
||
<div class="ch-avatar ch-tappable" style="background:${senderColor}" tabindex="0" role="button" data-node="${safeId}">${senderLetter}</div>
|
||
<div class="ch-msg-content ch-message-content">
|
||
<div class="ch-msg-sender ch-message-sender ch-sender-link ch-tappable" style="color:${senderColor}" tabindex="0" role="button" data-node="${safeId}">${escapeHtml(sender)}</div>
|
||
<div class="ch-msg-bubble ch-message-bubble">${displayText}</div>
|
||
<div class="ch-msg-meta ch-message-meta">${meta.join(' · ')}${msg.packetHash ? ` · <a href="#/packets/${msg.packetHash}" class="ch-analyze-link">View packet →</a>` : ''}</div>
|
||
</div>
|
||
</div>`;
|
||
}).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._channelsMergeWsAppendedIntoRestForTest = mergeWsAppendedIntoRest;
|
||
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 });
|
||
})();
|