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();