diff --git a/.eslintrc.json b/.eslintrc.json
index a0c09c18..67087ec0 100644
--- a/.eslintrc.json
+++ b/.eslintrc.json
@@ -266,6 +266,7 @@
"require": "readonly",
"routeLayer": "readonly",
"routeTypeName": "readonly",
+ "scopeCellHtml": "readonly",
"setupPullToReconnect": "readonly",
"syncBadgeColors": "readonly",
"timeAgo": "readonly",
diff --git a/public/packets.js b/public/packets.js
index b5ca9254..90feb101 100644
--- a/public/packets.js
+++ b/public/packets.js
@@ -729,6 +729,31 @@
let regionMap = {};
const TYPE_NAMES = SHORT_BY_ID;
function typeName(t) { return TYPE_NAMES[t] ?? `Type ${t}`; }
+
+ // Column keys that existed before the Scope column (#1852) — the "known"
+ // baseline for a saved array from before packets-visible-cols-known was
+ // ever persisted. A key missing from `saved` is only auto-shown if it's
+ // ALSO missing from `known`; a pre-existing key missing from `saved`
+ // means the user explicitly hid it, and must stay hidden.
+ const LEGACY_COL_KEYS = ['region', 'time', 'hash', 'size', 'type', 'observer', 'path', 'rpt', 'details'];
+
+ // Reconcile a saved column-visibility array (#71) against the current
+ // COL_DEFS, so a since-added column (e.g. "scope") gets shown by default
+ // for returning visitors instead of staying hidden forever — while a
+ // column the user explicitly unchecked (present in `known` but absent
+ // from `saved`) stays hidden. `known` is the column-key set from the last
+ // time prefs were saved; falls back to LEGACY_COL_KEYS when absent
+ // (a visitor whose saved prefs predate persisting it at all).
+ function reconcileVisibleCols(saved, known, colDefs, defaultHidden) {
+ if (!saved) return colDefs.map(c => c.key).filter(k => !defaultHidden.includes(k));
+ const knownSet = new Set(known && known.length ? known : LEGACY_COL_KEYS);
+ const result = saved.slice();
+ for (const c of colDefs) {
+ if (knownSet.has(c.key)) continue;
+ if (!result.includes(c.key) && !defaultHidden.includes(c.key)) result.push(c.key);
+ }
+ return result;
+ }
const isMobile = window.innerWidth <= 1024;
const PACKET_LIMIT = isMobile ? 1000 : 50000;
let savedTimeWindowMin = Number(localStorage.getItem('meshcore-time-window'));
@@ -2021,19 +2046,12 @@
// badge (#1188) renders on mobile. Without observer in scope the user
// can't see who heard the packet at all.
const defaultHidden = isNarrow ? ['region', 'hash', 'path', 'rpt', 'size', 'scope'] : ['region'];
- let visibleCols;
+ let savedCols, knownCols;
try {
- visibleCols = JSON.parse(localStorage.getItem('packets-visible-cols'));
+ savedCols = JSON.parse(localStorage.getItem('packets-visible-cols'));
+ knownCols = JSON.parse(localStorage.getItem('packets-visible-cols-known'));
} catch {}
- if (!visibleCols) {
- visibleCols = COL_DEFS.map(c => c.key).filter(k => !defaultHidden.includes(k));
- } else {
- // A saved preference predates a since-added column (e.g. "scope") —
- // default new columns to visible instead of silently hiding them.
- for (const c of COL_DEFS) {
- if (!visibleCols.includes(c.key) && !defaultHidden.includes(c.key)) visibleCols.push(c.key);
- }
- }
+ let visibleCols = reconcileVisibleCols(savedCols, knownCols, COL_DEFS, defaultHidden);
const colMenu = document.getElementById('colToggleMenu');
const pktTable = document.getElementById('pktTable');
function applyColVisibility() {
@@ -2041,6 +2059,7 @@
pktTable.classList.toggle('hide-col-' + c.key, !visibleCols.includes(c.key));
});
localStorage.setItem('packets-visible-cols', JSON.stringify(visibleCols));
+ localStorage.setItem('packets-visible-cols-known', JSON.stringify(COL_DEFS.map(c => c.key)));
}
colMenu.innerHTML = COL_DEFS.map(c =>
``
@@ -3887,6 +3906,7 @@
window._packetsTestAPI = {
typeName,
obsName,
+ reconcileVisibleCols,
getDetailPreview,
sortGroupChildren,
getPathHopCount,
diff --git a/test-frontend-helpers.js b/test-frontend-helpers.js
index bd998942..6e2bdbf3 100644
--- a/test-frontend-helpers.js
+++ b/test-frontend-helpers.js
@@ -1958,6 +1958,27 @@ console.log('\n=== app.js: isTransportRoute + transportBadge ===');
assert.ok(html.includes('TRANSPORT_FLOOD'), 'should contain route type name in title');
});
test('transportBadge(1) returns empty string', () => assert.strictEqual(transportBadge(1), ''));
+
+ const scopeCellHtml = ctx.scopeCellHtml;
+ test('scopeCellHtml non-transport route returns em-dash', () => {
+ assert.strictEqual(scopeCellHtml(1, 'dk'), '—');
+ assert.strictEqual(scopeCellHtml(2, ''), '—');
+ });
+ test('scopeCellHtml transport route with resolved scope shows the region name', () => {
+ const html = scopeCellHtml(0, 'dk-oj');
+ assert.ok(html.includes('badge-scope'), 'should contain badge-scope class');
+ assert.ok(!html.includes('badge-scope-unknown'), 'resolved scope should not use the unknown modifier');
+ assert.ok(html.includes('dk-oj'), 'should contain the region name');
+ });
+ test('scopeCellHtml transport route with no match shows muted unknown', () => {
+ const html = scopeCellHtml(3, '');
+ assert.ok(html.includes('badge-scope-unknown'), 'should contain the unknown modifier class');
+ assert.ok(html.includes('unknown'), 'should contain the word unknown');
+ });
+ test('scopeCellHtml escapes region names', () => {
+ const html = scopeCellHtml(0, '
');
+ assert.ok(!html.includes('
{
+ assert(typeof api.reconcileVisibleCols === 'function');
+ });
+
+ test('no saved prefs: falls back to all columns minus defaultHidden', () => {
+ const result = api.reconcileVisibleCols(null, null, COL_DEFS, defaultHidden);
+ assert.deepStrictEqual(result, COL_DEFS.map(c => c.key).filter(k => k !== 'region'));
+ });
+
+ test('legacy saved prefs (no known-cols baseline yet) get the scope column backfilled', () => {
+ // Reproduces a real saved localStorage value from before the Scope
+ // column (#1852) existed, and before packets-visible-cols-known was
+ // ever persisted — must not stay permanently hidden.
+ const legacy = ['time', 'hash', 'size', 'type', 'observer', 'path', 'rpt', 'details'];
+ const result = api.reconcileVisibleCols(legacy, null, COL_DEFS, defaultHidden);
+ assert.ok(result.includes('scope'), 'scope column should be backfilled into a legacy saved array');
+ });
+
+ test('a column the user explicitly hid (present in known, absent from saved) stays hidden', () => {
+ const known = ['region', 'time', 'hash', 'size', 'type', 'scope', 'observer', 'path', 'rpt', 'details'];
+ const saved = ['time', 'hash', 'size', 'type', 'scope', 'path', 'rpt', 'details']; // observer unchecked by user
+ const result = api.reconcileVisibleCols(saved, known, COL_DEFS, defaultHidden);
+ assert.ok(!result.includes('observer'), 'user-hidden column must not be resurrected');
+ });
+
+ test('a genuinely new column (absent from known) gets backfilled even with a known-cols baseline', () => {
+ const known = ['region', 'time', 'hash', 'size', 'type', 'observer', 'path', 'rpt', 'details']; // no scope yet
+ const saved = ['time', 'hash', 'size', 'type', 'observer', 'path', 'rpt', 'details'];
+ const result = api.reconcileVisibleCols(saved, known, COL_DEFS, defaultHidden);
+ assert.ok(result.includes('scope'), 'a column absent from the known baseline should be backfilled');
+ });
+
+ test('a column in defaultHidden is not force-added even if missing from saved and known', () => {
+ const legacy = ['time', 'hash', 'type', 'observer', 'path', 'rpt', 'details']; // no size, no scope
+ const narrowDefaultHidden = ['region', 'size', 'scope'];
+ const result = api.reconcileVisibleCols(legacy, null, COL_DEFS, narrowDefaultHidden);
+ assert.ok(!result.includes('scope'), 'columns in defaultHidden should not be backfilled');
+ assert.ok(!result.includes('size'), 'columns in defaultHidden should not be backfilled');
+ });
+}
+
console.log('\n=== packets.js: _invalidateRowCounts / _refreshRowCountsIfDirty (#410) ===');
{
const ctx = loadPacketsSandbox();