diff --git a/public/analytics.js b/public/analytics.js
index aa9dbae2..499464c8 100644
--- a/public/analytics.js
+++ b/public/analytics.js
@@ -737,6 +737,7 @@
// ===================== CHANNELS =====================
var _channelSortState = null;
var _channelData = null;
+ var _channelRenderGen = 0;
var CHANNEL_SORT_KEY = 'meshcore-channel-sort';
function loadChannelSort() {
@@ -747,6 +748,18 @@
return { col: 'lastActivity', dir: 'desc' };
}
+ // True when the user has explicitly chosen a sort (saved in localStorage).
+ // Used by the grouped analytics view to decide whether to apply its own
+ // default ("messages desc") instead of the global flat-list default.
+ function hasSavedChannelSort() {
+ try {
+ var s = localStorage.getItem(CHANNEL_SORT_KEY);
+ if (!s) return false;
+ var p = JSON.parse(s);
+ return !!(p && p.col && p.dir);
+ } catch (e) { return false; }
+ }
+
function saveChannelSort(state) {
try { localStorage.setItem(CHANNEL_SORT_KEY, JSON.stringify(state)); } catch (e) {}
}
@@ -781,20 +794,107 @@
}
function channelRowHtml(c) {
+ var name = c.displayName || c.name || 'Unknown';
return '
in the row; force the placeholder rendering.
+ copy.displayName = !isNaN(hashNum)
+ ? '🔒 Encrypted (0x' + hashNum.toString(16).toUpperCase().padStart(2, '0') + ')'
+ : '🔒 Encrypted';
+ copy.group = 'encrypted';
+ } else {
+ // Server gave us a real name (rainbow table hit) for an encrypted ch.
+ copy.displayName = rawName;
+ copy.group = 'network';
+ }
+ } else {
+ copy.displayName = rawName || 'Unknown';
+ copy.group = 'network';
+ }
+ out.push(copy);
+ }
+ return out;
+ }
+
+ // Build the (hash byte → key name) map from ChannelDecrypt's stored keys.
+ // Async because computeChannelHash uses subtle.digest. Returns {} if the
+ // module or its keys are unavailable (graceful fallback).
+ async function buildHashKeyMap() {
+ if (typeof ChannelDecrypt === 'undefined' || !ChannelDecrypt.getStoredKeys) return {};
+ var keys = ChannelDecrypt.getStoredKeys();
+ var map = {};
+ var names = Object.keys(keys || {});
+ for (var ni = 0; ni < names.length; ni++) {
+ var name = names[ni];
+ try {
+ var bytes = ChannelDecrypt.hexToBytes(keys[name]);
+ var hb = await ChannelDecrypt.computeChannelHash(bytes);
+ if (typeof hb === 'number') map[hb] = name;
+ } catch (e) { /* skip bad key */ }
+ }
+ return map;
+ }
+
+ function channelTbodyHtml(channels, col, dir, opts) {
var sorted = sortChannels(channels, col, dir);
var parts = [];
- for (var i = 0; i < sorted.length; i++) parts.push(channelRowHtml(sorted[i]));
+ if (opts && opts.grouped) {
+ // Group by .group: mine → network → encrypted. Inside each group keep
+ // the active sort (caller passes col/dir; for the integration we sort
+ // by messages desc by default).
+ var groups = { mine: [], network: [], encrypted: [] };
+ for (var gi = 0; gi < sorted.length; gi++) {
+ var g = sorted[gi].group || (sorted[gi].encrypted ? 'encrypted' : 'network');
+ (groups[g] || (groups[g] = [])).push(sorted[gi]);
+ }
+ var sections = [
+ { key: 'mine', label: '🔑 My Channels' },
+ { key: 'network', label: '📻 Network' },
+ { key: 'encrypted', label: '🔒 Encrypted' },
+ ];
+ for (var si = 0; si < sections.length; si++) {
+ var rows = groups[sections[si].key] || [];
+ if (!rows.length) continue;
+ parts.push(
+ '
'
+ );
+ for (var ri = 0; ri < rows.length; ri++) parts.push(channelRowHtml(rows[ri]));
+ }
+ } else {
+ for (var i = 0; i < sorted.length; i++) parts.push(channelRowHtml(sorted[i]));
+ }
return parts.join('');
}
@@ -825,13 +925,39 @@
var tbody = document.getElementById('channelsTbody');
var thead = document.querySelector('#channelsTable thead');
if (!tbody || !_channelData) return;
- tbody.innerHTML = channelTbodyHtml(_channelData, _channelSortState.col, _channelSortState.dir);
+ tbody.innerHTML = channelTbodyHtml(_channelData, _channelSortState.col, _channelSortState.dir, { grouped: true });
if (thead) thead.outerHTML = channelTheadHtml(_channelSortState.col, _channelSortState.dir);
}
function renderChannels(el, ch) {
- _channelData = ch.channels;
- if (!_channelSortState) _channelSortState = loadChannelSort();
+ // Decorate first so grouping/display name reflect locally-stored PSK keys.
+ // buildHashKeyMap is async; render once with a sync best-effort empty map,
+ // then upgrade once keys resolve. That keeps first paint fast and avoids
+ // blocking on subtle.digest in environments where it's slow.
+ var rawChannels = ch.channels || [];
+ // Resolve the persisted sort first so the default-fallback below doesn't
+ // shadow what the user previously chose. Default for the grouped view is
+ // messages desc (matches the PR description); only used when nothing saved.
+ if (!_channelSortState) {
+ _channelSortState = hasSavedChannelSort()
+ ? loadChannelSort()
+ : { col: 'messages', dir: 'desc' };
+ }
+ var ranOnce = false;
+ // Generation token: if renderChannels is called again before
+ // buildHashKeyMap() resolves, the older promise must not clobber the
+ // newer rawChannels / decoration with stale-key data.
+ var myGen = ++_channelRenderGen;
+ function applyDecorate(map) {
+ if (myGen !== _channelRenderGen) return; // superseded
+ var labels = (typeof ChannelDecrypt !== 'undefined' && ChannelDecrypt.getLabels)
+ ? ChannelDecrypt.getLabels() : {};
+ _channelData = decorateAnalyticsChannels(rawChannels, map, labels);
+ if (ranOnce) updateChannelTable();
+ }
+ applyDecorate({});
+ ranOnce = true;
+ buildHashKeyMap().then(applyDecorate).catch(function () { /* graceful */ });
var timelineHtml = renderChannelTimeline(ch.channelTimeline);
var topSendersHtml = renderTopSenders(ch.topSenders);
@@ -844,7 +970,7 @@
'' +
channelTheadHtml(_channelSortState.col, _channelSortState.dir) +
'' +
- channelTbodyHtml(_channelData, _channelSortState.col, _channelSortState.dir) +
+ channelTbodyHtml(_channelData, _channelSortState.col, _channelSortState.dir, { grouped: true }) +
'' +
'
' +
'' +
@@ -2055,6 +2181,7 @@ function destroy() { _analyticsData = {}; _channelData = null; if (_ngState && _
// Expose for testing
if (typeof window !== 'undefined') {
+ window._analyticsDecorateChannels = decorateAnalyticsChannels;
window._analyticsSortChannels = sortChannels;
window._analyticsLoadChannelSort = loadChannelSort;
window._analyticsSaveChannelSort = saveChannelSort;
diff --git a/public/channels.js b/public/channels.js
index 73769a68..f4f365cf 100644
--- a/public/channels.js
+++ b/public/channels.js
@@ -634,6 +634,8 @@
+ 📊 Channel Analytics →
diff --git a/public/style.css b/public/style.css
index a3e3673c..ff6b11a2 100644
--- a/public/style.css
+++ b/public/style.css
@@ -1204,6 +1204,15 @@ button.ch-item.ch-item-encrypted .ch-badge { filter: grayscale(0.6); }
.ch-add-btn:hover { opacity: 0.85; }
.ch-add-hint { font-size: 11px; color: var(--text-muted); margin-top: 4px; line-height: 1.3; }
.ch-add-status { font-size: 12px; margin-top: 4px; padding: 4px 6px; border-radius: 4px; }
+.ch-analytics-link {
+ display: block;
+ padding: 6px 8px;
+ font-size: 12px;
+ text-decoration: none;
+ color: var(--text-muted);
+ border-bottom: 1px solid var(--border);
+}
+.ch-analytics-link:hover { color: var(--accent); }
.ch-add-status--loading { color: var(--text-muted); }
.ch-add-status--success { color: var(--success, #22c55e); }
.ch-add-status--warn { color: var(--warning, #eab308); }
@@ -1273,6 +1282,18 @@ button.ch-item.ch-item-encrypted .ch-badge { filter: grayscale(0.6); }
.analytics-table th.sort-active { color: var(--accent); }
.analytics-table th .sort-arrow { font-size: 10px; margin-left: 4px; opacity: 0.7; }
.analytics-table td { padding: 8px; border-bottom: 1px solid var(--border); }
+.analytics-table .ch-section-row { background: var(--card-bg); }
+.analytics-table .ch-section-row td.ch-section-header {
+ font-weight: 600;
+ font-size: 12px;
+ letter-spacing: 0.04em;
+ color: var(--text-muted);
+ text-transform: uppercase;
+ padding: 10px 8px 6px;
+ border-bottom: 1px solid var(--border);
+ background: var(--card-bg);
+}
+.analytics-table .ch-section-row:hover { background: var(--card-bg); cursor: default; }
.hash-bars { display: flex; flex-direction: column; gap: 10px; margin-top: 12px; }
.hash-bar-row { display: flex; align-items: center; gap: 12px; }
.hash-bar-label { min-width: 160px; font-size: 13px; }
diff --git a/test-all.sh b/test-all.sh
index 4c8a70ca..4c079541 100755
--- a/test-all.sh
+++ b/test-all.sh
@@ -18,6 +18,7 @@ node test-channel-sidebar-layout.js
node test-channel-modal-ux.js
node test-channel-decrypt-insecure-context.js
node test-channel-qr.js
+node test-analytics-channels-integration.js
echo ""
echo "═══════════════════════════════════════"
diff --git a/test-analytics-channels-integration.js b/test-analytics-channels-integration.js
new file mode 100644
index 00000000..abaac324
--- /dev/null
+++ b/test-analytics-channels-integration.js
@@ -0,0 +1,188 @@
+/**
+ * Analytics → Channels section integration with PSK decrypt UX.
+ *
+ * Bug: the analytics channels list shows nonsense names like "ch185" for
+ * every encrypted channel and ignores the user's locally-decrypted PSK
+ * channels (from ChannelDecrypt.getStoredKeys() + label store).
+ *
+ * Fix:
+ * 1. Replace "chNNN" raw names with "🔒 Encrypted (0xNN)" when the channel
+ * is encrypted and the server only knows its hash byte.
+ * 2. For channels matching a locally-stored PSK key, show the user's
+ * label / key-name instead of the hash-byte placeholder.
+ * 3. Group rendering: My Channels → Network → Encrypted, each sorted by
+ * message count descending.
+ * 4. Add a link from the Channels page to the Analytics page so users can
+ * jump to channel activity stats.
+ */
+'use strict';
+
+const fs = require('fs');
+const path = require('path');
+
+let passed = 0, failed = 0;
+function assert(cond, msg) {
+ if (cond) { passed++; console.log(' ✓ ' + msg); }
+ else { failed++; console.error(' ✗ ' + msg); }
+}
+
+// ── Set up a tiny browser-ish global so analytics.js loads cleanly ──────────
+global.window = global;
+global.document = {
+ documentElement: {},
+ createElement: () => ({ style: {}, addEventListener() {} }),
+ addEventListener() {},
+ removeEventListener() {},
+ querySelector: () => null,
+ querySelectorAll: () => [],
+ getElementById: () => null,
+};
+global.localStorage = {
+ _s: {},
+ getItem(k) { return this._s[k] || null; },
+ setItem(k, v) { this._s[k] = String(v); },
+ removeItem(k) { delete this._s[k]; },
+};
+global.getComputedStyle = () => ({ getPropertyValue: () => '' });
+global.registerPage = () => {};
+global.api = async () => ({});
+global.fetch = async () => ({ ok: true, json: async () => ({}) });
+global.CLIENT_TTL = {};
+global.RegionFilter = { getRegionParam: () => '' };
+global.Storage = function () {};
+global.timeAgo = () => '';
+global.histogram = () => ({ svg: '' });
+
+// Load analytics.js — it self-registers global helpers we test.
+const analyticsSrc = fs.readFileSync(
+ path.join(__dirname, 'public/analytics.js'),
+ 'utf8'
+);
+// Strip top-level `await` / module syntax — analytics.js is plain IIFE so it's
+// fine to eval as-is.
+// eslint-disable-next-line no-eval
+eval(analyticsSrc); // sets window._analyticsDecorateChannels etc.
+
+console.log('\n=== Analytics channels: decorate with PSK keys ===');
+
+const decorate = global._analyticsDecorateChannels;
+assert(typeof decorate === 'function',
+ '_analyticsDecorateChannels exposed for testing');
+
+// Server response sample — mix of cleartext, rainbow-known encrypted, raw "chNNN".
+const sampleChannels = [
+ { hash: 17, name: 'public', messages: 100, senders: 5, encrypted: false },
+ { hash: 217, name: '#test', messages: 200, senders: 8, encrypted: false },
+ { hash: 185, name: 'ch185', messages: 50, senders: 0, encrypted: true },
+ { hash: 64, name: 'ch64', messages: 300, senders: 0, encrypted: true },
+ { hash: 30, name: 'ch30', messages: 75, senders: 0, encrypted: true },
+ { hash: 99, name: '#earthquake', messages: 10, senders: 1, encrypted: false },
+ // Rainbow-table hit on an ENCRYPTED channel: server resolved a real name.
+ { hash: 12, name: 'public-meshcore', messages: 40, senders: 2, encrypted: true },
+ // Encrypted channel with empty name — must not render an empty .
+ { hash: 200, name: '', messages: 5, senders: 0, encrypted: true },
+];
+
+// User has two PSK keys locally: one matches hash=185 (named "Levski"),
+// one matches hash=30 (named "secret-room", with label "Garage").
+const myKeyHashToName = { 185: 'Levski', 30: 'secret-room' };
+const labels = { 'secret-room': 'Garage' };
+
+const out = decorate(sampleChannels, myKeyHashToName, labels);
+assert(Array.isArray(out), 'decorate returns an array');
+assert(out.length === sampleChannels.length, 'decorate keeps every channel');
+
+// Find by original hash (and optionally original name) for assertions.
+// Decoration preserves c.name as-is and writes the user-facing string to
+// c.displayName, so matching on c.name is unambiguous.
+function find(hash, name) {
+ return out.find(c => c.hash === hash && (name == null || c.name === name));
+}
+
+const mine185 = find(185, 'ch185');
+assert(mine185 && mine185.displayName === 'Levski',
+ 'hash 185 + stored key → displayName = "Levski" (not "ch185")');
+assert(mine185 && mine185.group === 'mine',
+ 'hash 185 grouped as "mine"');
+
+const mine30 = find(30, 'ch30');
+assert(mine30 && mine30.displayName === 'Garage',
+ 'hash 30 with stored key + label → displayName = "Garage" (label wins)');
+assert(mine30 && mine30.group === 'mine', 'hash 30 grouped as "mine"');
+
+const ch64 = find(64, 'ch64');
+assert(ch64 && ch64.displayName === '🔒 Encrypted (0x40)',
+ 'unknown encrypted ch64 → "🔒 Encrypted (0x40)" (no nonsense "ch64")');
+assert(ch64 && ch64.group === 'encrypted', 'unknown encrypted grouped as "encrypted"');
+
+const pub = find(17, 'public');
+assert(pub && pub.displayName === 'public', 'cleartext public name preserved');
+assert(pub && pub.group === 'network', 'cleartext public grouped as "network"');
+
+const test = find(217, '#test');
+assert(test && test.group === 'network', 'rainbow-known #test grouped as "network"');
+
+// Rainbow-table hit on an ENCRYPTED channel — actually exercises the
+// "encrypted but server has the real name" branch (was previously dead-untested).
+const rainbow = find(12, 'public-meshcore');
+assert(rainbow && rainbow.encrypted === true,
+ 'rainbow row preserves encrypted=true');
+assert(rainbow && rainbow.displayName === 'public-meshcore',
+ 'rainbow-decoded encrypted row → displayName = real name');
+assert(rainbow && rainbow.group === 'network',
+ 'rainbow-decoded encrypted row → group = "network"');
+
+// Empty-name encrypted: must NOT leak through with displayName = ''.
+const empty = find(200, '');
+assert(empty && empty.displayName === '🔒 Encrypted (0xC8)',
+ 'encrypted with empty name → render as opaque encrypted placeholder');
+assert(empty && empty.group === 'encrypted',
+ 'encrypted with empty name → group = "encrypted"');
+
+// No "chNNN" leaks into displayName for any row.
+const leak = out.find(c => /^ch(\d+|\?)$/.test(c.displayName));
+assert(!leak, 'no displayName matches the raw chNNN placeholder');
+
+console.log('\n=== Grouped table render: order + sort ===');
+
+const tbody = global._analyticsChannelTbodyHtml(out, 'messages', 'desc', {
+ grouped: true,
+});
+assert(typeof tbody === 'string' && tbody.length > 0,
+ 'channelTbodyHtml accepts grouped option and returns html');
+
+// Group headers must appear in order: My Channels, Network, Encrypted.
+const iMine = tbody.indexOf('My Channels');
+const iNet = tbody.indexOf('Network');
+const iEnc = tbody.indexOf('Encrypted');
+assert(iMine >= 0 && iNet > iMine && iEnc > iNet,
+ 'group headers render in order: My Channels → Network → Encrypted');
+
+// Within "mine" section, hash=30 (75 msgs) > hash=185 (50 msgs).
+const i30 = tbody.indexOf('Garage');
+const i185 = tbody.indexOf('Levski');
+assert(i30 > 0 && i185 > i30,
+ 'within "My Channels" sort by messages desc (Garage 75 before Levski 50)');
+
+// Within "network" section, #test (200) > public (100) > #earthquake (10).
+const iT = tbody.indexOf('#test');
+const iP = tbody.indexOf('public');
+const iE = tbody.indexOf('#earthquake');
+assert(iT > 0 && iP > iT && iE > iP,
+ 'within "Network" sort by messages desc (#test → public → #earthquake)');
+
+// Within "encrypted" section, ch64 (300 msgs) appears (only one entry).
+assert(tbody.indexOf('0x40') > iEnc, 'encrypted section contains 0x40');
+
+console.log('\n=== Channels page links to Analytics ===');
+
+const channelsSrc = fs.readFileSync(
+ path.join(__dirname, 'public/channels.js'),
+ 'utf8'
+);
+assert(/#\/analytics/.test(channelsSrc) &&
+ /Channel Analytics|channel analytics/i.test(channelsSrc),
+ 'channels.js sidebar links to #/analytics with "Channel Analytics" text');
+
+console.log('\n' + (failed ? '✗ ' + failed + ' failed, ' : '') + passed + ' passed');
+process.exit(failed ? 1 : 0);