web: do not merge channels by ID in frontend (#637)

* web: do not merge channels by ID in frontend

* web: address review comments

* web: address review comments
This commit is contained in:
l5y
2026-02-14 14:56:25 +01:00
committed by GitHub
parent b45629f13c
commit e32b098be4
2 changed files with 143 additions and 79 deletions
@@ -62,6 +62,22 @@ function buildModel(overrides = {}) {
});
}
function findChannelByLabel(model, label) {
return model.channels.find(channel => channel.label === label);
}
function assertChannelMessages(model, { label, id, index, messageIds }) {
const channel = findChannelByLabel(model, label);
assert.ok(channel);
if (id instanceof RegExp) {
assert.match(channel.id, id);
} else {
assert.equal(channel.id, id);
}
assert.equal(channel.index, index);
assert.deepEqual(channel.entries.map(entry => entry.message.id), messageIds);
}
test('buildChatTabModel returns sorted nodes and channel buckets', () => {
const model = buildModel();
assert.equal(model.logEntries.length, 3);
@@ -75,12 +91,13 @@ test('buildChatTabModel returns sorted nodes and channel buckets', () => {
['recent-node', 'iso-node', 'encrypted']
);
assert.equal(model.channels.length, 5);
assert.equal(model.channels.length, 6);
assert.deepEqual(model.channels.map(channel => channel.label), [
'EnvDefault',
'Fallback',
'MediumFast',
'ShortFast',
'1',
'BerlinMesh'
]);
@@ -106,11 +123,16 @@ test('buildChatTabModel returns sorted nodes and channel buckets', () => {
assert.equal(presetChannel.id, 'channel-0-shortfast');
assert.deepEqual(presetChannel.entries.map(entry => entry.message.id), ['primary-preset']);
const unnamedSecondaryChannel = channelByLabel['1'];
assert.equal(unnamedSecondaryChannel.index, 1);
assert.equal(unnamedSecondaryChannel.id, 'channel-1');
assert.deepEqual(unnamedSecondaryChannel.entries.map(entry => entry.message.id), ['iso-ts']);
const secondaryChannel = channelByLabel.BerlinMesh;
assert.equal(secondaryChannel.index, 1);
assert.equal(secondaryChannel.id, 'channel-secondary-berlinmesh');
assert.equal(secondaryChannel.entries.length, 2);
assert.deepEqual(secondaryChannel.entries.map(entry => entry.message.id), ['iso-ts', 'recent-alt']);
assert.match(secondaryChannel.id, /^channel-secondary-1-berlinmesh-[a-z0-9]+$/);
assert.equal(secondaryChannel.entries.length, 1);
assert.deepEqual(secondaryChannel.entries.map(entry => entry.message.id), ['recent-alt']);
});
test('buildChatTabModel skips channel buckets when there are no messages', () => {
@@ -272,7 +294,7 @@ test('buildChatTabModel ignores plaintext log-only entries', () => {
assert.equal(encryptedEntries[0]?.message?.id, 'enc');
});
test('buildChatTabModel merges secondary channels with matching labels regardless of index', () => {
test('buildChatTabModel keeps secondary channels distinct by index even with matching labels', () => {
const primaryId = 'primary';
const secondaryFirstId = 'secondary-one';
const secondarySecondId = 'secondary-two';
@@ -289,62 +311,129 @@ test('buildChatTabModel merges secondary channels with matching labels regardles
});
const meshChannels = model.channels.filter(channel => channel.label === label);
assert.equal(meshChannels.length, 2);
assert.equal(meshChannels.length, 3);
const primaryChannel = meshChannels.find(channel => channel.index === 0);
assert.ok(primaryChannel);
assert.equal(primaryChannel.entries.length, 1);
assert.equal(primaryChannel.entries[0]?.message?.id, primaryId);
const secondaryChannel = meshChannels.find(channel => channel.index > 0);
assert.ok(secondaryChannel);
assert.equal(secondaryChannel.id, 'channel-secondary-meshtown');
assert.equal(secondaryChannel.index, 3);
assert.deepEqual(secondaryChannel.entries.map(entry => entry.message.id), [secondaryFirstId, secondarySecondId]);
const secondaryFirstChannel = meshChannels.find(channel => channel.index === 7);
assert.ok(secondaryFirstChannel);
assert.match(secondaryFirstChannel.id, /^channel-secondary-7-meshtown-[a-z0-9]+$/);
assert.deepEqual(secondaryFirstChannel.entries.map(entry => entry.message.id), [secondaryFirstId]);
const secondarySecondChannel = meshChannels.find(channel => channel.index === 3);
assert.ok(secondarySecondChannel);
assert.match(secondarySecondChannel.id, /^channel-secondary-3-meshtown-[a-z0-9]+$/);
assert.deepEqual(secondarySecondChannel.entries.map(entry => entry.message.id), [secondarySecondId]);
});
test('buildChatTabModel rekeys unnamed secondary buckets when a label later arrives', () => {
const unnamedId = 'unnamed';
const namedId = 'named';
const label = 'SideMesh';
const index = 4;
test('buildChatTabModel keeps unnamed secondary buckets separate when a label later arrives', () => {
const scenarios = [
{
index: 4,
label: 'SideMesh',
messages: [
{ id: 'unnamed', rx_time: NOW - 15, channel: 4 },
{ id: 'named', rx_time: NOW - 10, channel: 4, channel_name: 'SideMesh' }
],
namedId: /^channel-secondary-4-sidemesh-[a-z0-9]+$/,
namedMessages: ['named'],
unnamedMessages: ['unnamed']
},
{
index: 5,
label: 'MeshNorth',
messages: [
{ id: 'named', rx_time: NOW - 12, channel: 5, channel_name: 'MeshNorth' },
{ id: 'unlabeled', rx_time: NOW - 8, channel: 5 }
],
namedId: /^channel-secondary-5-meshnorth-[a-z0-9]+$/,
namedMessages: ['named'],
unnamedMessages: ['unlabeled']
}
];
for (const scenario of scenarios) {
const model = buildChatTabModel({
nodes: [],
messages: scenario.messages,
nowSeconds: NOW,
windowSeconds: WINDOW
});
const secondaryChannels = model.channels.filter(channel => channel.index === scenario.index);
assert.equal(secondaryChannels.length, 2);
assertChannelMessages(model, {
label: scenario.label,
id: scenario.namedId,
index: scenario.index,
messageIds: scenario.namedMessages
});
assertChannelMessages(model, {
label: String(scenario.index),
id: `channel-${scenario.index}`,
index: scenario.index,
messageIds: scenario.unnamedMessages
});
}
});
test('buildChatTabModel keeps same-index channels with different names in separate tabs', () => {
const model = buildChatTabModel({
nodes: [],
messages: [
{ id: unnamedId, rx_time: NOW - 15, channel: index },
{ id: namedId, rx_time: NOW - 10, channel: index, channel_name: label }
{ id: 'public-msg', rx_time: NOW - 12, channel: 1, channel_name: 'PUBLIC' },
{ id: 'berlin-msg', rx_time: NOW - 8, channel: 1, channel_name: 'BerlinMesh' }
],
nowSeconds: NOW,
windowSeconds: WINDOW
});
const secondaryChannels = model.channels.filter(channel => channel.index === index);
assert.equal(secondaryChannels.length, 1);
const [secondaryChannel] = secondaryChannels;
assert.equal(secondaryChannel.id, 'channel-secondary-sidemesh');
assert.equal(secondaryChannel.label, label);
assert.deepEqual(secondaryChannel.entries.map(entry => entry.message.id), [unnamedId, namedId]);
assertChannelMessages(model, {
label: 'PUBLIC',
id: /^channel-secondary-1-public-[a-z0-9]+$/,
index: 1,
messageIds: ['public-msg']
});
assertChannelMessages(model, {
label: 'BerlinMesh',
id: /^channel-secondary-1-berlinmesh-[a-z0-9]+$/,
index: 1,
messageIds: ['berlin-msg']
});
});
test('buildChatTabModel merges unlabeled secondary messages into existing named buckets by index', () => {
const namedId = 'named';
const unlabeledId = 'unlabeled';
const label = 'MeshNorth';
const index = 5;
test('buildChatTabModel keeps same-index slug-colliding labels on distinct tab ids', () => {
const model = buildChatTabModel({
nodes: [],
messages: [
{ id: namedId, rx_time: NOW - 12, channel: index, channel_name: label },
{ id: unlabeledId, rx_time: NOW - 8, channel: index }
{ id: 'foo-space', rx_time: NOW - 10, channel: 1, channel_name: 'Foo Bar' },
{ id: 'foo-dash', rx_time: NOW - 8, channel: 1, channel_name: 'Foo-Bar' }
],
nowSeconds: NOW,
windowSeconds: WINDOW
});
const secondaryChannels = model.channels.filter(channel => channel.index === index);
assert.equal(secondaryChannels.length, 1);
const [secondaryChannel] = secondaryChannels;
assert.equal(secondaryChannel.id, 'channel-secondary-meshnorth');
assert.equal(secondaryChannel.label, label);
assert.deepEqual(secondaryChannel.entries.map(entry => entry.message.id), [namedId, unlabeledId]);
const fooSpaceChannel = findChannelByLabel(model, 'Foo Bar');
const fooDashChannel = findChannelByLabel(model, 'Foo-Bar');
assert.ok(fooSpaceChannel);
assert.ok(fooDashChannel);
assert.match(fooSpaceChannel.id, /^channel-secondary-1-foo-bar-[a-z0-9]+$/);
assert.match(fooDashChannel.id, /^channel-secondary-1-foo-bar-[a-z0-9]+$/);
assert.notEqual(fooSpaceChannel.id, fooDashChannel.id);
});
test('buildChatTabModel falls back to hashed id for unsluggable secondary labels', () => {
const model = buildChatTabModel({
nodes: [],
messages: [{ id: 'hash-fallback', rx_time: NOW - 5, channel: 2, channel_name: '###' }],
nowSeconds: NOW,
windowSeconds: WINDOW
});
const channel = findChannelByLabel(model, '###');
assert.ok(channel);
assert.equal(channel.index, 2);
assert.ok(channel.id.startsWith('channel-secondary-2-'));
assert.ok(channel.id.length > 'channel-secondary-2-'.length);
});
+17 -42
View File
@@ -20,7 +20,7 @@ import { extractModemMetadata } from './node-modem-metadata.js';
* Highest channel index that should be represented within the tab view.
* @type {number}
*/
export const MAX_CHANNEL_INDEX = 9;
export const MAX_CHANNEL_INDEX = 255;
/**
* Discrete event types that can appear in the chat activity log.
@@ -245,28 +245,12 @@ export function buildChatTabModel({
modemPreset,
envFallbackLabel: primaryChannelEnvLabel
});
const nameBucketKey = safeIndex > 0 ? buildSecondaryNameBucketKey(labelInfo) : null;
const nameBucketKey = safeIndex > 0 ? buildSecondaryNameBucketKey(safeIndex, labelInfo) : null;
const primaryBucketKey = safeIndex === 0 && labelInfo.label !== '0' ? buildPrimaryBucketKey(labelInfo.label) : '0';
let bucketKey = safeIndex === 0 ? primaryBucketKey : nameBucketKey ?? String(safeIndex);
const bucketKey = safeIndex === 0 ? primaryBucketKey : nameBucketKey ?? String(safeIndex);
let bucket = channelBuckets.get(bucketKey);
if (!bucket && safeIndex > 0) {
const existingBucketKey = findExistingBucketKeyByIndex(channelBuckets, safeIndex);
if (existingBucketKey) {
bucketKey = existingBucketKey;
bucket = channelBuckets.get(existingBucketKey);
}
}
if (bucket && nameBucketKey && bucket.key !== nameBucketKey) {
channelBuckets.delete(bucket.key);
bucket.key = nameBucketKey;
bucket.id = buildChannelTabId(nameBucketKey);
channelBuckets.set(nameBucketKey, bucket);
bucketKey = nameBucketKey;
}
if (!bucket) {
bucket = {
key: bucketKey,
@@ -569,43 +553,34 @@ function buildPrimaryBucketKey(primaryChannelLabel) {
return '0';
}
function buildSecondaryNameBucketKey(labelInfo) {
function buildSecondaryNameBucketKey(index, labelInfo) {
const label = labelInfo?.label ?? null;
const priority = labelInfo?.priority ?? CHANNEL_LABEL_PRIORITY.INDEX;
if (priority !== CHANNEL_LABEL_PRIORITY.NAME || !label) {
const safeIndex = Number.isFinite(index) ? Math.max(0, Math.trunc(index)) : 0;
if (safeIndex <= 0 || priority !== CHANNEL_LABEL_PRIORITY.NAME || !label) {
return null;
}
const trimmedLabel = label.trim().toLowerCase();
if (!trimmedLabel.length) {
return null;
}
return `secondary::${trimmedLabel}`;
}
function findExistingBucketKeyByIndex(channelBuckets, targetIndex) {
if (!channelBuckets || !Number.isFinite(targetIndex) || targetIndex <= 0) {
return null;
}
const normalizedTarget = Math.trunc(targetIndex);
for (const [key, bucket] of channelBuckets.entries()) {
if (!bucket || !Number.isFinite(bucket.index)) {
continue;
}
if (Math.trunc(bucket.index) !== normalizedTarget) {
continue;
}
if (bucket.index === 0) {
continue;
}
return key;
}
return null;
return `secondary::${safeIndex}::${trimmedLabel}`;
}
function buildChannelTabId(bucketKey) {
if (bucketKey === '0') {
return 'channel-0';
}
const secondaryParts = /^secondary::(\d+)::(.+)$/.exec(String(bucketKey));
if (secondaryParts) {
const secondaryIndex = secondaryParts[1];
const secondaryLabelSlug = slugify(secondaryParts[2]);
const secondaryHash = hashChannelKey(bucketKey);
if (secondaryLabelSlug) {
return `channel-secondary-${secondaryIndex}-${secondaryLabelSlug}-${secondaryHash}`;
}
return `channel-secondary-${secondaryIndex}-${secondaryHash}`;
}
const slug = slugify(bucketKey);
if (slug) {
if (slug !== '0') {