diff --git a/web/public/assets/js/app/__tests__/chat-log-tabs.test.js b/web/public/assets/js/app/__tests__/chat-log-tabs.test.js index 942a050..6c6d33b 100644 --- a/web/public/assets/js/app/__tests__/chat-log-tabs.test.js +++ b/web/public/assets/js/app/__tests__/chat-log-tabs.test.js @@ -72,17 +72,26 @@ test('buildChatTabModel returns sorted nodes and channel buckets', () => { ['recent-node', 'iso-node', 'encrypted'] ); - assert.equal(model.channels.length, 2); - const [channel0, channel1] = model.channels; - assert.equal(channel0.index, 0); - assert.equal(channel0.label, 'MediumFast'); - assert.equal(channel0.entries.length, 2); - assert.deepEqual(channel0.entries.map(entry => entry.message.id), ['no-index', 'recent-default']); + assert.equal(model.channels.length, 3); + const [fallbackChannel, namedPrimaryChannel, secondaryChannel] = model.channels; - assert.equal(channel1.index, 1); - assert.equal(channel1.label, 'BerlinMesh'); - assert.equal(channel1.entries.length, 2); - assert.deepEqual(channel1.entries.map(entry => entry.message.id), ['iso-ts', 'recent-alt']); + assert.equal(fallbackChannel.index, 0); + assert.equal(fallbackChannel.label, 'Fallback'); + assert.equal(fallbackChannel.id, 'channel-0-fallback'); + assert.equal(fallbackChannel.entries.length, 1); + assert.deepEqual(fallbackChannel.entries.map(entry => entry.message.id), ['no-index']); + + assert.equal(namedPrimaryChannel.index, 0); + assert.equal(namedPrimaryChannel.label, 'MediumFast'); + assert.equal(namedPrimaryChannel.id, 'channel-0-mediumfast'); + assert.equal(namedPrimaryChannel.entries.length, 1); + assert.deepEqual(namedPrimaryChannel.entries.map(entry => entry.message.id), ['recent-default']); + + assert.equal(secondaryChannel.index, 1); + assert.equal(secondaryChannel.label, 'BerlinMesh'); + assert.equal(secondaryChannel.id, 'channel-1'); + assert.equal(secondaryChannel.entries.length, 2); + assert.deepEqual(secondaryChannel.entries.map(entry => entry.message.id), ['iso-ts', 'recent-alt']); }); test('buildChatTabModel always includes channel zero bucket', () => { diff --git a/web/public/assets/js/app/chat-log-tabs.js b/web/public/assets/js/app/chat-log-tabs.js index 1cf8f3f..4b39dac 100644 --- a/web/public/assets/js/app/chat-log-tabs.js +++ b/web/public/assets/js/app/chat-log-tabs.js @@ -58,7 +58,7 @@ export const CHAT_LOG_ENTRY_TYPES = Object.freeze({ * }} params Aggregation inputs. * @returns {{ * logEntries: Array<{ ts: number, type: string, nodeId?: string, nodeNum?: number }>, - * channels: Array<{ index: number, label: string, entries: Array<{ ts: number, message: Object }> }> + * channels: Array<{ id: string, index: number, label: string, entries: Array<{ ts: number, message: Object }> }> * }} Sorted tab model data. */ export function buildChatTabModel({ @@ -138,23 +138,24 @@ export function buildChatTabModel({ if (channelIndex != null && channelIndex > maxChannelIndex) { continue; } - const safeIndex = channelIndex != null && channelIndex >= 0 ? channelIndex : 0; - const bucketKey = safeIndex; - let bucket = channelBuckets.get(bucketKey); - if (!bucket) { - bucket = { - index: safeIndex, - label: String(safeIndex), - entries: [], - hasExplicitName: false - }; - channelBuckets.set(bucketKey, bucket); - } - const channelName = normaliseChannelName( message.channel_name ?? message.channelName ?? message.channel_display ?? message.channelDisplay ); - if (channelName && !bucket.hasExplicitName) { + const safeIndex = channelIndex != null && channelIndex >= 0 ? channelIndex : 0; + const bucketKey = buildChannelBucketKey(safeIndex, channelName); + let bucket = channelBuckets.get(bucketKey); + if (!bucket) { + bucket = { + key: bucketKey, + id: buildChannelTabId(bucketKey), + index: safeIndex, + label: channelName || String(safeIndex), + entries: [], + hasExplicitName: Boolean(channelName), + isPrimaryFallback: bucketKey === '0' + }; + channelBuckets.set(bucketKey, bucket); + } else if (channelName && !bucket.hasExplicitName) { bucket.label = channelName; bucket.hasExplicitName = true; } @@ -164,16 +165,32 @@ export function buildChatTabModel({ logEntries.sort((a, b) => a.ts - b.ts); - if (!channelBuckets.has(0)) { - channelBuckets.set(0, { + let hasPrimaryBucket = false; + for (const bucket of channelBuckets.values()) { + if (bucket.index === 0) { + hasPrimaryBucket = true; + break; + } + } + if (!hasPrimaryBucket) { + const bucketKey = '0'; + channelBuckets.set(bucketKey, { + key: bucketKey, + id: buildChannelTabId(bucketKey), index: 0, label: '0', entries: [], - hasExplicitName: false + hasExplicitName: false, + isPrimaryFallback: true }); } - const channels = Array.from(channelBuckets.values()).sort((a, b) => a.index - b.index); + const channels = Array.from(channelBuckets.values()).sort((a, b) => { + if (a.index !== b.index) { + return a.index - b.index; + } + return a.label.localeCompare(b.label); + }); for (const channel of channels) { channel.entries.sort((a, b) => a.ts - b.ts); } @@ -290,6 +307,52 @@ export function normaliseChannelName(value) { return null; } +function buildChannelBucketKey(index, channelName) { + const safeIndex = Number.isFinite(index) ? Math.max(0, Math.trunc(index)) : 0; + if (safeIndex === 0 && channelName) { + return `0::${channelName.toLowerCase()}`; + } + return String(safeIndex); +} + +function buildChannelTabId(bucketKey) { + if (bucketKey === '0') { + return 'channel-0'; + } + const slug = slugify(bucketKey); + if (slug) { + if (slug !== '0') { + return `channel-${slug}`; + } + return `channel-${slug}-${hashChannelKey(bucketKey)}`; + } + return `channel-${hashChannelKey(bucketKey)}`; +} + +function slugify(value) { + if (value == null) return ''; + return String(value) + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/-+/g, '-') + .replace(/^-+|-+$/g, ''); +} + +function hashChannelKey(value) { + const input = String(value ?? ''); + if (!input) { + return '0'; + } + let hash = 0; + for (let i = 0; i < input.length; i += 1) { + hash = (hash * 31 + input.charCodeAt(i)) | 0; + } + if (hash < 0) { + hash = (hash * -1) >>> 0; + } + return hash.toString(36); +} + export const __test__ = { resolveTimestampSeconds, normaliseChannelIndex, diff --git a/web/public/assets/js/app/main.js b/web/public/assets/js/app/main.js index dcf0951..09580fc 100644 --- a/web/public/assets/js/app/main.js +++ b/web/public/assets/js/app/main.js @@ -2743,13 +2743,15 @@ let messagesById = new Map(); }); const channelTabs = filteredChannels.map(channel => ({ - id: `channel-${channel.index}`, + id: channel.id || `channel-${channel.index}`, label: channel.label, content: buildChatFragment({ entries: channel.entries.map(e => ({ ts: e.ts, item: e.message })), renderEntry: entry => createMessageChatEntry(entry.item), emptyLabel: 'No messages on this channel.' - }) + }), + index: channel.index, + isPrimaryFallback: Boolean(channel.isPrimaryFallback) })); const tabs = [ @@ -2758,7 +2760,11 @@ let messagesById = new Map(); ]; const previousActive = chatEl.dataset?.activeTab || null; - const defaultActive = channelTabs.find(tab => tab.id === 'channel-0')?.id || channelTabs[0]?.id || 'log'; + const defaultActive = + channelTabs.find(tab => tab.isPrimaryFallback)?.id || + channelTabs.find(tab => tab.index === 0)?.id || + channelTabs[0]?.id || + 'log'; renderChatTabs({ document, container: chatEl,