web: sort channels by activity not index (#711)

* web: sort channels by activity not index

* web: address review comments
This commit is contained in:
l5y
2026-04-06 14:04:47 +02:00
committed by GitHub
parent d8b80c2a97
commit 95aa1de8a8
6 changed files with 311 additions and 21 deletions
@@ -92,13 +92,14 @@ test('buildChatTabModel returns sorted nodes and channel buckets', () => {
);
assert.equal(model.channels.length, 6);
// All channels have 1 message each; ties are broken alphabetically by label.
assert.deepEqual(model.channels.map(channel => channel.label), [
'1',
'BerlinMesh',
'EnvDefault',
'Fallback',
'MediumFast',
'ShortFast',
'1',
'BerlinMesh'
'ShortFast'
]);
const channelByLabel = Object.fromEntries(model.channels.map(channel => [channel.label, channel]));
@@ -454,3 +455,60 @@ test('buildChatTabModel falls back to hashed id for unsluggable secondary labels
assert.ok(channel.id.startsWith('channel-secondary-name-'));
assert.ok(channel.id.length > 'channel-secondary-name-'.length);
});
test('buildChatTabModel sets messageCount equal to entries.length on each channel', () => {
const model = buildChatTabModel({
nodes: [],
messages: [
{ id: 'a', rx_time: NOW - 10, channel: 0, channel_name: 'Primary' },
{ id: 'b', rx_time: NOW - 8, channel: 0, channel_name: 'Primary' },
{ id: 'c', rx_time: NOW - 6, channel: 1, channel_name: 'Secondary' }
],
nowSeconds: NOW,
windowSeconds: WINDOW
});
for (const channel of model.channels) {
assert.equal(channel.messageCount, channel.entries.length);
}
const primary = model.channels.find(channel => channel.label === 'Primary');
assert.ok(primary);
assert.equal(primary.messageCount, 2);
});
test('buildChatTabModel sorts channels by messageCount descending', () => {
// Channel A has 3 messages, Channel B has 1. A must come first.
const model = buildChatTabModel({
nodes: [],
messages: [
{ id: 'b1', rx_time: NOW - 15, channel: 1, channel_name: 'Beta' },
{ id: 'a1', rx_time: NOW - 12, channel: 2, channel_name: 'Alpha' },
{ id: 'a2', rx_time: NOW - 10, channel: 2, channel_name: 'Alpha' },
{ id: 'a3', rx_time: NOW - 8, channel: 2, channel_name: 'Alpha' }
],
nowSeconds: NOW,
windowSeconds: WINDOW
});
assert.equal(model.channels.length, 2);
assert.equal(model.channels[0].label, 'Alpha');
assert.equal(model.channels[0].messageCount, 3);
assert.equal(model.channels[1].label, 'Beta');
assert.equal(model.channels[1].messageCount, 1);
});
test('buildChatTabModel breaks messageCount ties alphabetically', () => {
// Zebra and Apple each have 2 messages; Apple should sort first.
const model = buildChatTabModel({
nodes: [],
messages: [
{ id: 'z1', rx_time: NOW - 20, channel: 1, channel_name: 'Zebra' },
{ id: 'z2', rx_time: NOW - 18, channel: 1, channel_name: 'Zebra' },
{ id: 'ap1', rx_time: NOW - 16, channel: 2, channel_name: 'Apple' },
{ id: 'ap2', rx_time: NOW - 14, channel: 2, channel_name: 'Apple' }
],
nowSeconds: NOW,
windowSeconds: WINDOW
});
assert.equal(model.channels.length, 2);
assert.equal(model.channels[0].label, 'Apple');
assert.equal(model.channels[1].label, 'Zebra');
});
@@ -67,6 +67,10 @@ class MockElement {
this.hidden = false;
this.scrollTop = 0;
this.scrollHeight = 200;
this.scrollLeft = 0;
this.clientWidth = 0;
this.scrollWidth = 0;
this.scrollIntoViewCalls = [];
}
appendChild(node) {
@@ -122,6 +126,14 @@ class MockElement {
handler({});
}
}
scrollIntoView(opts) {
this.scrollIntoViewCalls.push(opts);
}
scrollBy() {
// no-op in tests; presence is enough to avoid guards
}
}
class MockTextNode {
@@ -164,9 +176,13 @@ test('renderChatTabs creates tab markup and selects default active tab', () => {
assert.equal(active, 'channel-0');
assert.equal(container.dataset.activeTab, 'channel-0');
// container now holds [tabListWrapper, panelWrapper]
assert.equal(container.children.length, 2);
const [tabList, panelWrapper] = container.children;
const [tabListWrapper, panelWrapper] = container.children;
// tabListWrapper holds [prevBtn, tabList, nextBtn]
assert.equal(tabListWrapper.children.length, 3);
const [, tabList] = tabListWrapper.children;
assert.equal(tabList.children.length, 3);
assert.equal(panelWrapper.children.length, 3);
assert.equal(panelWrapper.children[1].hidden, false);
@@ -198,7 +214,8 @@ test('renderChatTabs reuses previous active tab when still available', () => {
});
assert.equal(active, 'log');
const [tabList, panels] = container.children;
const [tabListWrapper, panels] = container.children;
const [, tabList] = tabListWrapper.children;
assert.equal(tabList.children[0].getAttribute('aria-selected'), 'true');
assert.equal(panels.children[0].hidden, false);
});
@@ -224,7 +241,8 @@ test('renderChatTabs renders icon img child when tab.iconSrc is provided', () =>
renderChatTabs({ document, container, tabs });
const [tabList] = container.children;
const [tabListWrapper] = container.children;
const [, tabList] = tabListWrapper.children;
const button = tabList.children[0];
// Button has one element child (the icon <img>) and one text node — two childNodes total.
assert.equal(button.children.length, 1, 'should have exactly one element child (icon img)');
@@ -246,9 +264,91 @@ test('renderChatTabs uses textContent when no iconSrc is provided', () => {
renderChatTabs({ document, container, tabs });
const [tabList] = container.children;
const [tabListWrapper] = container.children;
const [, tabList] = tabListWrapper.children;
const button = tabList.children[0];
assert.equal(button.textContent, 'Log');
// No icon child elements
assert.equal(button.children.length, 0);
});
test('renderChatTabs includes prev and next scroll buttons inside the wrapper', () => {
const document = createMockDocument();
const container = new MockElement('div');
renderChatTabs({
document,
container,
tabs: [{ id: 'log', label: 'Log', content: new MockElement('div') }]
});
const [tabListWrapper] = container.children;
const [prevBtn, , nextBtn] = tabListWrapper.children;
assert.equal(prevBtn.getAttribute('aria-hidden'), 'true');
assert.equal(nextBtn.getAttribute('aria-hidden'), 'true');
assert.ok(prevBtn.className.includes('chat-tab-scroll-btn--prev'));
assert.ok(nextBtn.className.includes('chat-tab-scroll-btn--next'));
// Both start hidden (no overflow in test environment)
assert.equal(prevBtn.hidden, true);
assert.equal(nextBtn.hidden, true);
});
test('renderChatTabs scrolls active button into view on tab switch', () => {
const document = createMockDocument();
const container = new MockElement('div');
const tabs = [
{ id: 'log', label: 'Log', content: new MockElement('div') },
{ id: 'ch1', label: 'Channel (5)', content: new MockElement('div') }
];
renderChatTabs({ document, container, tabs, defaultActiveTabId: 'log' });
const [tabListWrapper] = container.children;
const [, tabList] = tabListWrapper.children;
const ch1Button = tabList.children[1];
ch1Button.dispatch('click');
assert.equal(container.dataset.activeTab, 'ch1');
assert.equal(ch1Button.scrollIntoViewCalls.length, 1);
assert.deepEqual(ch1Button.scrollIntoViewCalls[0], { block: 'nearest', inline: 'nearest' });
});
test('renderChatTabs arrow buttons reflect scroll position via scroll event', () => {
const document = createMockDocument();
const container = new MockElement('div');
renderChatTabs({
document,
container,
tabs: [{ id: 'log', label: 'Log', content: new MockElement('div') }]
});
const [tabListWrapper] = container.children;
const [prevBtn, tabList, nextBtn] = tabListWrapper.children;
// Simulate a scrollable list: total width 400, viewport 100, scrolled 50.
tabList.scrollLeft = 50;
tabList.clientWidth = 100;
tabList.scrollWidth = 400;
// Fire the scroll event so updateArrows recalculates.
tabList.dispatch('scroll');
// scrolled past start → prev should be visible
assert.equal(prevBtn.hidden, false);
// not yet at end (50 + 100 = 150 < 400 - 1) → next should be visible
assert.equal(nextBtn.hidden, false);
// Scroll to the very end.
tabList.scrollLeft = 300; // 300 + 100 = 400 >= 400 - 1
tabList.dispatch('scroll');
assert.equal(prevBtn.hidden, false);
assert.equal(nextBtn.hidden, true);
// Scroll back to start.
tabList.scrollLeft = 0;
tabList.dispatch('scroll');
assert.equal(prevBtn.hidden, true);
assert.equal(nextBtn.hidden, false);
});
+16 -9
View File
@@ -83,8 +83,15 @@ function resolveSnapshotList(entry) {
* }} params Aggregation inputs.
* @returns {{
* logEntries: Array<{ ts: number, type: string, nodeId?: string, nodeNum?: number }>,
* channels: Array<{ id: string, index: number, label: string, entries: Array<{ ts: number, message: Object }> }>
* }} Sorted tab model data.
* channels: Array<{
* id: string,
* index: number,
* label: string,
* messageCount: number,
* entries: Array<{ ts: number, message: Object }>
* }>
* }} Tab model data. Channels are sorted by {@code messageCount} descending (7-day activity),
* with alphabetical label ordering as a tiebreaker.
*/
export function buildChatTabModel({
nodes = [],
@@ -299,15 +306,15 @@ export function buildChatTabModel({
logEntries.sort((a, b) => a.ts - b.ts);
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) {
// Sort entries chronologically and record the 7-day message count before sorting channels.
for (const channel of channelBuckets.values()) {
channel.entries.sort((a, b) => a.ts - b.ts);
channel.messageCount = channel.entries.length;
}
// Sort channels by activity (most messages first), then alphabetically on ties.
const channels = Array.from(channelBuckets.values()).sort((a, b) =>
b.messageCount - a.messageCount || a.label.localeCompare(b.label)
);
return { logEntries, channels };
}
+75 -1
View File
@@ -23,6 +23,11 @@
* data (img src does not execute script). The ``label`` field is always
* inserted as a text node.
*
* When the tab list overflows its container, / scroll buttons are
* rendered on either side of the list. They are hidden via the
* {@code hidden} attribute while the corresponding scroll direction is
* not available.
*
* @param {{
* document: Document,
* container: HTMLElement,
@@ -54,14 +59,40 @@ export function renderChatTabs({
}
const fragment = createFragment(document);
// Wrapper holds the scroll buttons + the tab list so the border-bottom
// spans the full width including the arrow buttons.
const tabListWrapper = document.createElement('div');
tabListWrapper.className = 'chat-tablist-wrapper';
const prevBtn = document.createElement('button');
prevBtn.type = 'button';
prevBtn.className = 'chat-tab-scroll-btn chat-tab-scroll-btn--prev';
prevBtn.setAttribute('aria-hidden', 'true');
prevBtn.setAttribute('tabindex', '-1');
prevBtn.textContent = '◀';
prevBtn.hidden = true;
const nextBtn = document.createElement('button');
nextBtn.type = 'button';
nextBtn.className = 'chat-tab-scroll-btn chat-tab-scroll-btn--next';
nextBtn.setAttribute('aria-hidden', 'true');
nextBtn.setAttribute('tabindex', '-1');
nextBtn.textContent = '▶';
nextBtn.hidden = true;
const tabList = document.createElement('div');
tabList.className = 'chat-tablist';
tabList.setAttribute('role', 'tablist');
tabListWrapper.appendChild(prevBtn);
tabListWrapper.appendChild(tabList);
tabListWrapper.appendChild(nextBtn);
const panelWrapper = document.createElement('div');
panelWrapper.className = 'chat-tabpanels';
fragment.appendChild(tabList);
fragment.appendChild(tabListWrapper);
fragment.appendChild(panelWrapper);
const tabElements = [];
@@ -148,6 +179,45 @@ export function renderChatTabs({
container.appendChild(fragment);
}
/**
* Refresh the hidden state of the scroll arrow buttons based on the
* current scroll position of the tab list.
*/
const updateArrows = () => {
const scrollLeft = tabList.scrollLeft || 0;
const clientWidth = tabList.clientWidth || 0;
const scrollWidth = tabList.scrollWidth || 0;
prevBtn.hidden = scrollLeft <= 0;
// Allow 1 px rounding tolerance.
nextBtn.hidden = scrollLeft + clientWidth >= scrollWidth - 1;
};
// Recalculate arrow visibility on scroll and on container resize.
if (typeof tabList.addEventListener === 'function') {
tabList.addEventListener('scroll', updateArrows);
}
if (typeof globalThis !== 'undefined' && typeof globalThis.ResizeObserver === 'function') {
// The observer is intentionally not disconnected: renderChatTabs replaces
// the entire DOM subtree on each call, so the previous tabList element is
// detached and the observer will not fire again after that point.
const ro = new globalThis.ResizeObserver(updateArrows);
ro.observe(tabList);
}
prevBtn.addEventListener('click', () => {
if (typeof tabList.scrollBy === 'function') {
tabList.scrollBy({ left: -150, behavior: 'smooth' });
}
});
nextBtn.addEventListener('click', () => {
if (typeof tabList.scrollBy === 'function') {
tabList.scrollBy({ left: 150, behavior: 'smooth' });
}
});
// Initial arrow state after the DOM is in place.
updateArrows();
const setActiveTab = newId => {
if (!newId) return;
let matched = false;
@@ -163,6 +233,10 @@ export function renderChatTabs({
if (typeof entry.panel.scrollHeight === 'number' && typeof entry.panel.scrollTop === 'number') {
entry.panel.scrollTop = entry.panel.scrollHeight;
}
// Scroll the active tab button into view within the overflow tab list.
if (typeof entry.button.scrollIntoView === 'function') {
entry.button.scrollIntoView({ block: 'nearest', inline: 'nearest' });
}
} else {
entry.button.classList.remove('is-active');
entry.panel.hidden = true;
+1 -1
View File
@@ -3410,7 +3410,7 @@ export function initializeApp(config) {
const channelTabs = filteredChannels.map(channel => ({
id: channel.id || `channel-${channel.index}`,
label: channel.label,
label: `${channel.label} (${channel.messageCount})`,
iconSrc: isMeshtasticProtocol(channel.protocol)
? MESHTASTIC_ICON_SRC
: isMeshcoreProtocol(channel.protocol)
+54 -3
View File
@@ -932,15 +932,32 @@ body.view-chat .page-shell {
min-height: 0;
}
/* Wrapper spans the full width and owns the bottom border so the arrow
buttons are visually part of the tab bar. */
.chat-tablist-wrapper {
display: flex;
align-items: stretch;
overflow: hidden;
border-bottom: 1px solid var(--line);
}
.chat-tablist {
display: flex;
gap: 4px;
padding: 6px 6px 0;
border-bottom: 1px solid var(--line);
flex: 1;
overflow-x: auto;
scroll-behavior: smooth;
scrollbar-width: none; /* Firefox */
}
.chat-tablist::-webkit-scrollbar {
display: none; /* Chrome / Safari */
}
.chat-tab {
flex: 1;
flex: none; /* natural content width — no equal-stretch */
white-space: nowrap;
border: none;
background: transparent;
color: inherit;
@@ -951,6 +968,31 @@ body.view-chat .page-shell {
transition: background-color 120ms ease, color 120ms ease;
}
/* Scroll arrow buttons flanking the tab list */
.chat-tab-scroll-btn {
flex: none;
display: flex;
align-items: center;
justify-content: center;
width: 20px;
background: var(--bg2);
border: none;
cursor: pointer;
color: var(--text-muted, #666);
font-size: 10px;
padding: 0;
z-index: 1;
transition: background-color 120ms ease;
}
.chat-tab-scroll-btn:hover {
background: rgba(0, 0, 0, 0.08);
}
.chat-tab-scroll-btn[hidden] {
display: none;
}
.chat-tab:is(:focus-visible, :hover) {
background: rgba(0, 0, 0, 0.06);
}
@@ -2110,10 +2152,19 @@ body.dark .chat-panel {
color: #eee;
}
body.dark .chat-tablist {
body.dark .chat-tablist-wrapper {
border-bottom-color: rgba(255, 255, 255, 0.18);
}
body.dark .chat-tab-scroll-btn {
background: #1a1a1a;
color: #aaa;
}
body.dark .chat-tab-scroll-btn:hover {
background: rgba(255, 255, 255, 0.1);
}
body.dark .chat-tab {
color: #ddd;
background: transparent;