diff --git a/public/analytics.js b/public/analytics.js index d2a47e4c..00130471 100644 --- a/public/analytics.js +++ b/public/analytics.js @@ -1117,6 +1117,20 @@ try { localStorage.setItem(CHANNEL_SORT_KEY, JSON.stringify(state)); } catch (e) {} } + // The server sends the channel hash byte as a decimal string ("17"); show it + // as hex (0x11) like the rest of the UI. Non-numeric ids (e.g. "enc_A1B2") + // pass through untouched. + function channelHashNum(h) { + if (typeof h === 'number') return h; + return (typeof h === 'string' && /^\d+$/.test(h)) ? parseInt(h, 10) : NaN; + } + + function formatChannelHash(h) { + var n = channelHashNum(h); + if (!isNaN(n)) return '0x' + n.toString(16).toUpperCase().padStart(2, '0'); + return h == null ? '' : esc(String(h)); + } + function sortChannels(channels, col, dir) { var sorted = channels.slice(); var mult = dir === 'asc' ? 1 : -1; @@ -1127,10 +1141,9 @@ av = (a.name || '').toLowerCase(); bv = (b.name || '').toLowerCase(); return av < bv ? -1 * mult : av > bv ? 1 * mult : 0; case 'hash': - av = typeof a.hash === 'number' ? a.hash : String(a.hash); - bv = typeof b.hash === 'number' ? b.hash : String(b.hash); - if (typeof av === 'number' && typeof bv === 'number') return (av - bv) * mult; - av = String(av).toLowerCase(); bv = String(bv).toLowerCase(); + av = channelHashNum(a.hash); bv = channelHashNum(b.hash); + if (!isNaN(av) && !isNaN(bv)) return (av - bv) * mult; + av = String(a.hash).toLowerCase(); bv = String(b.hash).toLowerCase(); return av < bv ? -1 * mult : av > bv ? 1 * mult : 0; case 'messages': return (a.messages - b.messages) * mult; case 'senders': return (a.senders - b.senders) * mult; @@ -1158,7 +1171,7 @@ : esc(c.displayName || c.name || 'Unknown'); return '' + '' + nameHtml + '' + - '' + (typeof c.hash === 'number' ? '0x' + c.hash.toString(16).toUpperCase().padStart(2, '0') : c.hash) + '' + + '' + formatChannelHash(c.hash) + '' + '' + c.messages + '' + '' + c.senders + '' + '' + timeAgo(c.lastActivity) + '' + diff --git a/tests/unit/test-frontend-helpers.js b/tests/unit/test-frontend-helpers.js index 00812d53..d409dfbd 100644 --- a/tests/unit/test-frontend-helpers.js +++ b/tests/unit/test-frontend-helpers.js @@ -2244,6 +2244,33 @@ console.log('\n=== analytics.js: sortChannels ==='); assert.ok(chatIdx < generalIdx, 'Chat (100 msgs) should come before General (50)'); }); + // #190: server sends the hash byte as a decimal string; the table must show hex. + test('tbodyHtml renders decimal-string hash as hex (#190)', () => { + const data = [ + { name: 'Public', hash: '17', messages: 3, senders: 1, lastActivity: '', encrypted: false }, + { name: 'Wardriving', hash: '129', messages: 2, senders: 1, lastActivity: '', encrypted: false }, + { name: 'Low', hash: '5', messages: 1, senders: 1, lastActivity: '', encrypted: false }, + ]; + const html = tbodyHtml(data, 'messages', 'desc'); + assert.ok(html.includes('>0x11<'), 'hash "17" should render as 0x11'); + assert.ok(html.includes('>0x81<'), 'hash "129" should render as 0x81'); + assert.ok(html.includes('>0x05<'), 'hash "5" should be zero-padded to 0x05'); + assert.ok(!html.includes('>17<') && !html.includes('>129<'), 'decimal must not be displayed'); + assert.ok(html.includes('?ch=17"'), 'deep link keeps the decimal value'); + }); + + test('tbodyHtml leaves non-numeric hash ids untouched and escaped (#190)', () => { + const html = tbodyHtml([{ name: 'X', hash: 'enc_A1B2', messages: 1, senders: 1, lastActivity: '', encrypted: true }], 'messages', 'desc'); + assert.ok(html.includes('>enc_A1B2<')); + const evil = tbodyHtml([{ name: 'X', hash: '', messages: 1, senders: 1, lastActivity: '', encrypted: false }], 'messages', 'desc'); + assert.ok(!evil.includes(''), 'hash must be HTML-escaped'); + }); + + test('sort by hash is numeric for decimal-string hashes (#190)', () => { + const data = [{ hash: '129' }, { hash: '17' }, { hash: '5' }]; + assert.deepStrictEqual(sortChannels(data, 'hash', 'asc').map(c => c.hash), ['5', '17', '129']); + }); + test('sort by string hash values', () => { const data = [ { name: 'A', hash: 'zz', messages: 1, senders: 1, lastActivity: '', encrypted: false },