mirror of
https://github.com/l5yth/potato-mesh.git
synced 2026-08-06 08:53:41 +02:00
web: sort channels by activity not index (#711)
* web: sort channels by activity not index * web: address review comments
This commit is contained in:
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user