From 95aa1de8a80123ef30df68b5daea196ef7c35d9d Mon Sep 17 00:00:00 2001 From: l5y <220195275+l5yth@users.noreply.github.com> Date: Mon, 6 Apr 2026 14:04:47 +0200 Subject: [PATCH] web: sort channels by activity not index (#711) * web: sort channels by activity not index * web: address review comments --- .../js/app/__tests__/chat-log-tabs.test.js | 64 ++++++++++- .../assets/js/app/__tests__/chat-tabs.test.js | 108 +++++++++++++++++- web/public/assets/js/app/chat-log-tabs.js | 25 ++-- web/public/assets/js/app/chat-tabs.js | 76 +++++++++++- web/public/assets/js/app/main.js | 2 +- web/public/assets/styles/base.css | 57 ++++++++- 6 files changed, 311 insertions(+), 21 deletions(-) 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 5bcb660..25064b4 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 @@ -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'); +}); diff --git a/web/public/assets/js/app/__tests__/chat-tabs.test.js b/web/public/assets/js/app/__tests__/chat-tabs.test.js index 99f529d..43210e9 100644 --- a/web/public/assets/js/app/__tests__/chat-tabs.test.js +++ b/web/public/assets/js/app/__tests__/chat-tabs.test.js @@ -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 ) 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); +}); diff --git a/web/public/assets/js/app/chat-log-tabs.js b/web/public/assets/js/app/chat-log-tabs.js index f2fa7b8..cefd8a6 100644 --- a/web/public/assets/js/app/chat-log-tabs.js +++ b/web/public/assets/js/app/chat-log-tabs.js @@ -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 }; } diff --git a/web/public/assets/js/app/chat-tabs.js b/web/public/assets/js/app/chat-tabs.js index 99e6dde..d58a605 100644 --- a/web/public/assets/js/app/chat-tabs.js +++ b/web/public/assets/js/app/chat-tabs.js @@ -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; diff --git a/web/public/assets/js/app/main.js b/web/public/assets/js/app/main.js index b810a1d..038b0b9 100644 --- a/web/public/assets/js/app/main.js +++ b/web/public/assets/js/app/main.js @@ -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) diff --git a/web/public/assets/styles/base.css b/web/public/assets/styles/base.css index d0122fa..be3330e 100644 --- a/web/public/assets/styles/base.css +++ b/web/public/assets/styles/base.css @@ -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;