Files
meshcore-analyzer/public/channels.js
T
e04c7113cb feat: integrate hashtag channels from meshcore-channels catalogue (#1323) (#1656)
Fixes #1323

## Summary

Adds a small in-memory cache of the community-maintained
hashtag-channels
catalogue (`marcelverdult/meshcore-channels`) and exposes it as
`GET /api/known-channels?region=XX` plus a collapsed sidebar section on
the Channels view ("Known channels (catalogue)") with a one-click
"+ Add" button per row.

Per triage (#1323): new `cmd/server/known_channels_cache.go`, new
`GET /api/known-channels?region=…`, frontend section in
`public/channels.js`. No new DB tables — cache is in-memory only.

## What changed

- `cmd/server/known_channels_cache.go` — `knownChannelsCache` with an
  atomic snapshot pointer, 24h default refresh, 30s HTTP timeout, 4 MB
  body cap, custom `User-Agent`. Fail-soft: a failed refresh leaves the
  last-known snapshot in place. Background goroutine started from
  `main.go` after the neighbor-graph recomputer; never blocks startup.
- `cmd/server/known_channels_route.go` — `GET
/api/known-channels?region=`
  serves the cached snapshot off the atomic pointer (never blocks on
  upstream). Region filter is case-insensitive ISO 3166-1 alpha-2.
  Empty/missing cache returns 200 with an empty entries list (fail-soft
  for the UI).
- `cmd/server/config.go` — `KnownChannelsURL` +
`KnownChannelsRefreshMs`.
- `config.example.json` — example values + `_comment_knownChannels`.
- `public/channels.js` — new collapsed sidebar section "Known channels
  (catalogue)" that lazy-fetches `/api/known-channels` on first render
  and renders rows with a "+ Add" button. The button calls the existing
  `addUserChannel(name)` path, so adding catalogue channels reuses the
  full save-key + decrypt flow that user-typed hashtags already use.
- `cmd/server/known_channels_cache_test.go` — failing-first tests:
  - `TestKnownChannelsParseFixture` asserts the parser populates
    `GeneratedAt`/`License` and region-stamps every entry while skipping
    empty countries.
  - `TestKnownChannelsRouteRegionFilter` asserts the route returns 200
    with exactly the filtered subset for `?region=be`.
  - `TestKnownChannelsFailSoftOn500` asserts a failed upstream fetch
    leaves the prior snapshot in place and bumps `failCount`.

## Upstream pinning

The default URL is pinned to the specific file
`channels-by-country.json`
on `main`:

>
https://raw.githubusercontent.com/marcelverdult/meshcore-channels/main/channels-by-country.json

Shape (verified 2026-05-24):

```json
{
  "generated_at": "...",
  "license": "CC0-1.0",
  "countries": { "be": [{"channel": "#antwerpen", "description": "..."}], ... }
}
```

## Test plan

```
cd cmd/server && go test -run 'TestKnownChannels' -count=1 .
ok  	github.com/corescope/server	0.008s
```

Red commit: 5c43cff3 (all three tests fail on assertions, build clean).
Green commit: 54a1080e (parser + cache + route implemented, all three
pass).

## TDD evidence (red → green)

- **Red commit `5c43cff3427afd8aa2f3cce20c31058190aebc37`** — tests
added
  with stub implementations that compile but return zero/empty so each
  test fails on an assertion (not a compile/import error). `go test -run
  TestKnownChannels` output captured in the commit message.
- **Green commit `54a1080e45fd2e10da2caa156f376bf4d0212976`** — parser,
  cache, route, main-wiring, frontend section land; all three tests
  pass.

## Frontend verification

Browser verified: http://analyzer-stg.00id.net/#/channels (with the
`/api/known-channels` response stubbed in DevTools to simulate the cache
being populated on staging, which is still on master and doesn't have
the new endpoint yet).

E2E assertion added: cmd/server/known_channels_cache_test.go:71 —
asserts the route returns 200 and the response body's `entries` length
matches the filtered subset.

## Limitations / follow-ups (not in scope of this PR)

- The catalogue only ships PSK keys for a small subset of entries (the
  upstream schema makes `key` optional). For entries WITHOUT a `key`,
  the "+ Add" button still wires through `addUserChannel("#name")` —
  which derives the standard public-channel key from the name (the same
  path used today when a user types `#foo` into the Add Channel modal).
  For entries WITH a `key`, a follow-up PR can pass the key through to
  `addUserChannel` so the UX matches "paste-a-PSK". Today the key is
  shown in the JSON payload but not yet wired into the FE button.
- No deduplication against the in-memory `/api/channels` list — the
  catalogue section is intentionally separate so the user sees which
  channels exist worldwide even if their server hasn't seen traffic.
- No per-section region selector yet — the section shows the full
  catalogue regardless of the page-level region filter. Future work:
  add a dropdown.

## Preflight

```
═══ Preflight clean. ═══
```

cross-stack: justified — issue #1323 spans `cmd/server` (cache + route)
and `public/channels.js` (sidebar surface); same feature, both halves
required.

---------

Co-authored-by: Kpa-clawbot <bot@corescope.local>
2026-06-11 07:38:36 -07:00

2305 lines
110 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* === 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] || '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-circle-fill"/></svg>') + ' ' + (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"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-x"/></svg></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] || '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-circle-fill"/></svg>') + ' ' + (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"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-x"/></svg></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"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-x"/></svg></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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
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 close button appears // EMOJI-OK: prior glyph reference — 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 ? '&region=' + 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"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-chats"/></svg></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"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-x"/></svg></button>
<h3 id="chModalTitle">Add Channel</h3>
<div class="ch-modal-callout" role="note">
<span class="status-warn"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-warning"/></svg></span> 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 &amp; 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"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-camera"/></svg> 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"><span class="status-warn"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-warning"/></svg></span> Case-sensitive — <code>#meshcore</code> ≠ <code>#MeshCore</code></div>
</section>
<div class="ch-modal-footer">
<span class=""><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-lock"/></svg></span> Keys stay in your browser — CoreScope is a passive observer that monitors and decrypts traffic but cannot transmit over RF. Use the close button 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"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-x"/></svg></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"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-clipboard-text"/></svg> Copy</button>
</div>
</div>
<div class="ch-modal-warn" role="note">
<span class="status-warn"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-warning"/></svg></span> 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.innerHTML = '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-check"/></svg> 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 (09, af).'; 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 close button disappears until they re-add a key. // EMOJI-OK: prior glyph reference
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 ? '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-lock-open"/></svg>' : (isEncrypted ? '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-lock"/></svg>' : 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, '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-x"/></svg>',
'Remove channel and clear saved key', 'Remove', '')
: '';
const shareBtn = isUserAdded
? iconBtn('ch-share-btn', 'data-share-channel', ch.hash, name, '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-share-network"/></svg> 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"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-key"/></svg></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) + '"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-x"/></svg></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 '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-lock"/></svg>';
if (ch && ch.userAdded) return '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-key"/></svg>';
// 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);
// abbr may be a Phosphor sprite (HTML) or plain text — detect & emit raw vs escaped.
const isHtmlAbbr = typeof abbr === 'string' && abbr.startsWith('<svg');
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">' + (isHtmlAbbr ? abbr : 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).
// #1323: Known-channels (catalogue) section — community-maintained
// hashtag-channels list fetched from /api/known-channels. Lazy: the
// first render shows a "Loading…" placeholder and kicks off the fetch;
// when it completes the section is re-rendered with the entries. The
// section is collapsed by default (persisted via localStorage). Click
// "+ Add" on a row to invoke the existing addUserChannel() path, which
// saves the key + decrypts.
var __knownChannels = null; // null = not fetched; [] = fetched empty
var __knownChannelsLoading = false; // single-flight guard
var __knownChannelsError = null;
var __knownChannelsErrorAt = 0; // ms timestamp of last error (for backoff)
var KNOWN_CHANNELS_ERROR_BACKOFF_MS = 60000;
function loadKnownChannels() {
if (__knownChannelsLoading) return;
// If we already have a snapshot (success or empty), don't refetch.
if (__knownChannels !== null && !__knownChannelsError) return;
// Sticky-error backoff: once an error has been recorded, wait
// KNOWN_CHANNELS_ERROR_BACKOFF_MS before allowing another attempt.
// This lets transient upstream failures recover without forcing a
// full page reload.
if (__knownChannelsError) {
var now = Date.now();
if (now - __knownChannelsErrorAt < KNOWN_CHANNELS_ERROR_BACKOFF_MS) return;
// Clear and retry.
__knownChannelsError = null;
__knownChannels = null;
}
__knownChannelsLoading = true;
// Note: region filter intentionally NOT applied — catalogue is small
// and the user may want to browse other regions even if they're
// viewing one. Future work: per-section region selector.
var url = '/api/known-channels';
fetch(url).then(function (r) { return r.ok ? r.json() : Promise.reject(new Error('HTTP ' + r.status)); })
.then(function (snap) {
__knownChannels = (snap && Array.isArray(snap.entries)) ? snap.entries : [];
__knownChannelsError = null;
__knownChannelsErrorAt = 0;
__knownChannelsLoading = false;
renderChannelList();
})
.catch(function (err) {
__knownChannelsError = String(err && err.message || err);
__knownChannelsErrorAt = Date.now();
__knownChannels = [];
__knownChannelsLoading = false;
renderChannelList();
});
}
function renderKnownChannelsSection() {
var collapsed = localStorage.getItem('ch-known-collapsed') !== 'false';
var count = (__knownChannels && Array.isArray(__knownChannels)) ? __knownChannels.length : 0;
// Lazy-render: if collapsed, emit an empty body — the rows are only
// built when the user expands (toggle handler populates in place).
// Avoids burning DOM for a 1000+ entry catalogue that's never seen.
var bodyInner = '';
if (!collapsed) {
bodyInner = renderKnownChannelsBodyInner();
}
return '' +
'<div class="ch-section ch-section-catalogue" data-section="catalogue">' +
'<button type="button" class="ch-section-header ch-section-toggle" id="chCatalogueToggle" aria-expanded="' + (collapsed ? 'false' : 'true') + '" aria-controls="chCatalogueBody">' +
'<span class="ch-section-caret" aria-hidden="true">' + (collapsed ? '▸' : '▾') + '</span>' +
' Known channels (catalogue)' + (count ? ' (' + count + ')' : '') +
'</button>' +
'<div class="ch-section-body" id="chCatalogueBody"' + (collapsed ? ' hidden' : '') + '>' +
bodyInner +
'</div>' +
'</div>';
}
// Renders the inner HTML for the catalogue body (rows or placeholder).
// Kicks off the fetch on first access. Pure HTML string — no DOM writes.
function renderKnownChannelsBodyInner() {
if (__knownChannels === null) {
setTimeout(loadKnownChannels, 0);
return '<div class="ch-section-empty">Loading catalogue…</div>';
}
if (__knownChannelsError) {
return '<div class="ch-section-empty">Catalogue unavailable</div>';
}
if (__knownChannels.length === 0) {
return '<div class="ch-section-empty">No catalogue entries</div>';
}
return __knownChannels.map(renderKnownChannelRow).join('');
}
function renderKnownChannelRow(entry) {
// entry: {channel, description, region, regionName, key?}
var chName = String(entry.channel || '').toLowerCase();
var safeName = chName.replace(/[<>&"]/g, function (c) { return ({'<':'&lt;','>':'&gt;','&':'&amp;','"':'&quot;'})[c]; });
var desc = String(entry.description || '').replace(/[<>&"]/g, function (c) { return ({'<':'&lt;','>':'&gt;','&':'&amp;','"':'&quot;'})[c]; });
var region = String(entry.region || '').toUpperCase();
return '' +
'<div class="ch-channel ch-channel-catalogue" data-known-channel="' + safeName + '">' +
'<div class="ch-channel-info">' +
'<div class="ch-channel-name">' + safeName + '</div>' +
'<div class="ch-channel-sub">' + region + (desc ? ' · ' + desc : '') + '</div>' +
'</div>' +
'<button type="button" class="ch-known-add-btn" data-action="ch-known-add" data-channel="' + safeName + '" title="Add to my channels">+ Add</button>' +
'</div>';
}
// Delegated click handler for catalogue "+ Add" buttons + toggle.
// Bound per-#chList-element (marker stored on the DOM node itself) so a
// future #chList recreation re-binds automatically instead of leaving a
// dead listener pinned to a destroyed node.
function bindKnownChannelsHandlers() {
var list = document.getElementById('chList');
if (!list) return;
if (list.dataset.knownChannelsBound === '1') return;
list.addEventListener('click', function (e) {
var t = e.target;
if (!t) return;
if (t.id === 'chCatalogueToggle' || (t.closest && t.closest('#chCatalogueToggle'))) {
// Toggle in place: flip the body's hidden attr and caret, no
// full renderChannelList() rebuild. On first expand, populate
// the body lazily so collapsed catalogues never pay DOM cost.
var wasCollapsed = localStorage.getItem('ch-known-collapsed') !== 'false';
var nowCollapsed = !wasCollapsed;
try { localStorage.setItem('ch-known-collapsed', nowCollapsed ? 'true' : 'false'); } catch (er) {}
var body = document.getElementById('chCatalogueBody');
var btn = document.getElementById('chCatalogueToggle');
if (body) {
if (nowCollapsed) {
body.setAttribute('hidden', '');
} else {
body.removeAttribute('hidden');
// Populate if empty (lazy first-expand).
if (!body.firstChild || body.innerHTML === '') {
body.innerHTML = renderKnownChannelsBodyInner();
}
}
}
if (btn) {
btn.setAttribute('aria-expanded', nowCollapsed ? 'false' : 'true');
var caret = btn.querySelector('.ch-section-caret');
if (caret) caret.textContent = nowCollapsed ? '▸' : '▾';
}
return;
}
var addBtn = (t.dataset && t.dataset.action === 'ch-known-add') ? t : (t.closest && t.closest('[data-action="ch-known-add"]'));
if (addBtn) {
e.preventDefault();
var name = addBtn.getAttribute('data-channel');
if (name && typeof addUserChannel === 'function') {
addUserChannel(name, '');
}
}
});
list.dataset.knownChannelsBound = '1';
}
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"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-monitor"/></svg> (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>`
);
// #1323: Known channels (catalogue) section — community-maintained
// hashtag list, fetched once per page-load from /api/known-channels
// and rendered with a one-click "Add to my channels" button.
sections.push(renderKnownChannelsSection());
el.innerHTML = sections.join('');
bindKnownChannelsHandlers();
// 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"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-lock"/></svg> 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"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-lock"/></svg> 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"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-lock"/></svg> 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 ? '&region=' + 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 ? '&region=' + 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 });
})();