Add tabbed chat panel with channel grouping (#404)

* feat: add tabbed chat panel with channel grouping

* Handle ISO-only chat timestamps in dashboard renderer

* Remove redundant chat channel tag
This commit is contained in:
l5y
2025-10-31 12:24:17 +01:00
committed by GitHub
parent 87b4cd79e7
commit 03e2fe6a72
6 changed files with 852 additions and 63 deletions
@@ -0,0 +1,105 @@
/*
* Copyright (C) 2025 l5yth
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import test from 'node:test';
import assert from 'node:assert/strict';
import {
buildChatTabModel,
MAX_CHANNEL_INDEX,
normaliseChannelIndex,
normaliseChannelName,
resolveTimestampSeconds
} from '../chat-log-tabs.js';
const NOW = 1_000_000;
const WINDOW = 60 * 60; // one hour
function fixtureNodes() {
return [
{ id: 'recent-node', first_heard: NOW - 120 },
{ id: 'stale-node', first_heard: NOW - WINDOW - 1 },
{ id: 'iso-node', firstHeard: null, first_heard_iso: new Date((NOW - 30) * 1000).toISOString() }
];
}
function fixtureMessages() {
return [
{ id: 'recent-default', rx_time: NOW - 5, channel: 0, channel_name: ' MediumFast ' },
{ id: 'recent-alt', rx_time: NOW - 10, channel_index: '1', channel_name: ' BerlinMesh ' },
{ id: 'stale', rx_time: NOW - WINDOW - 5, channel: 2 },
{ id: 'encrypted', rx_time: NOW - 20, channel: 3, encrypted: true },
{ id: 'no-index', rx_time: NOW - 15, channel_name: 'Fallback' },
{ id: 'too-high', rx_time: NOW - 25, channel: MAX_CHANNEL_INDEX + 5, channel_name: 'Ignored' },
{ id: 'iso-ts', rxTime: null, rx_iso: new Date((NOW - 40) * 1000).toISOString(), channel: 1 }
];
}
function buildModel(overrides = {}) {
return buildChatTabModel({
nodes: fixtureNodes(),
messages: fixtureMessages(),
nowSeconds: NOW,
windowSeconds: WINDOW,
...overrides
});
}
test('buildChatTabModel returns sorted nodes and channel buckets', () => {
const model = buildModel();
assert.equal(model.logEntries.length, 2);
assert.deepEqual(model.logEntries.map(entry => entry.node.id), ['recent-node', 'iso-node']);
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(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']);
});
test('buildChatTabModel always includes channel zero bucket', () => {
const model = buildChatTabModel({ nodes: [], messages: [], nowSeconds: NOW, windowSeconds: WINDOW });
assert.equal(model.channels.length, 1);
assert.equal(model.channels[0].index, 0);
assert.equal(model.channels[0].entries.length, 0);
});
test('normaliseChannelIndex handles numeric and textual input', () => {
assert.equal(normaliseChannelIndex(2.9), 2);
assert.equal(normaliseChannelIndex(' 7 '), 7);
assert.equal(normaliseChannelIndex('bad'), null);
assert.equal(normaliseChannelIndex(null), null);
});
test('normaliseChannelName trims strings and allows numeric values', () => {
assert.equal(normaliseChannelName(' Berlin '), 'Berlin');
assert.equal(normaliseChannelName(5), '5');
assert.equal(normaliseChannelName(''), null);
assert.equal(normaliseChannelName(undefined), null);
});
test('resolveTimestampSeconds prefers numeric but falls back to ISO parsing', () => {
assert.equal(resolveTimestampSeconds(1234, null), 1234);
const iso = '1970-01-01T00:10:00Z';
assert.equal(resolveTimestampSeconds('not-numeric', iso), 600);
assert.equal(resolveTimestampSeconds('bad', 'invalid'), null);
});
@@ -0,0 +1,194 @@
/*
* Copyright (C) 2025 l5yth
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import test from 'node:test';
import assert from 'node:assert/strict';
import { renderChatTabs } from '../chat-tabs.js';
class MockClassList {
constructor() {
this._values = new Set();
}
add(...names) {
names.forEach(name => {
if (name) this._values.add(name);
});
}
remove(...names) {
names.forEach(name => {
if (name) this._values.delete(name);
});
}
contains(name) {
return this._values.has(name);
}
}
class MockFragment {
constructor() {
this.children = [];
this.isFragment = true;
}
appendChild(node) {
this.children.push(node);
return node;
}
}
class MockElement {
constructor(tagName) {
this.tagName = tagName.toUpperCase();
this.children = [];
this.attributes = new Map();
this.dataset = {};
this.classList = new MockClassList();
this.listeners = new Map();
this.hidden = false;
this.scrollTop = 0;
this.scrollHeight = 200;
}
appendChild(node) {
this.children.push(node);
return node;
}
replaceChildren(...nodes) {
this.children = [];
for (const node of nodes) {
if (!node) continue;
if (node.isFragment && Array.isArray(node.children)) {
this.children.push(...node.children);
} else {
this.children.push(node);
}
}
}
setAttribute(name, value) {
const strValue = String(value);
this.attributes.set(name, strValue);
if (name === 'id') {
this.id = strValue;
}
if (name.startsWith('data-')) {
const key = name
.slice(5)
.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
this.dataset[key] = strValue;
}
}
getAttribute(name) {
return this.attributes.has(name) ? this.attributes.get(name) : null;
}
addEventListener(event, handler) {
this.listeners.set(event, handler);
}
dispatch(event) {
const handler = this.listeners.get(event);
if (handler) {
handler({});
}
}
}
function createMockDocument() {
return {
createElement(tag) {
return new MockElement(tag);
},
createDocumentFragment() {
return new MockFragment();
}
};
}
test('renderChatTabs creates tab markup and selects default active tab', () => {
const document = createMockDocument();
const container = new MockElement('div');
const tabs = [
{ id: 'log', label: 'Log', content: new MockElement('div') },
{ id: 'channel-0', label: 'Default', content: new MockElement('div') },
{ id: 'channel-1', label: 'Alt', content: new MockElement('div') }
];
const active = renderChatTabs({
document,
container,
tabs,
defaultActiveTabId: 'channel-0'
});
assert.equal(active, 'channel-0');
assert.equal(container.dataset.activeTab, 'channel-0');
assert.equal(container.children.length, 2);
const [tabList, panelWrapper] = container.children;
assert.equal(tabList.children.length, 3);
assert.equal(panelWrapper.children.length, 3);
assert.equal(panelWrapper.children[1].hidden, false);
assert.equal(panelWrapper.children[1].scrollTop, panelWrapper.children[1].scrollHeight);
assert.equal(panelWrapper.children[0].hidden, true);
tabList.children[0].dispatch('click');
assert.equal(container.dataset.activeTab, 'log');
assert.equal(panelWrapper.children[0].hidden, false);
assert.equal(panelWrapper.children[1].hidden, true);
});
test('renderChatTabs reuses previous active tab when still available', () => {
const document = createMockDocument();
const container = new MockElement('div');
container.dataset.activeTab = 'log';
const tabs = [
{ id: 'log', label: 'Log', content: new MockElement('div') },
{ id: 'channel-0', label: 'Default', content: new MockElement('div') }
];
const active = renderChatTabs({
document,
container,
tabs,
previousActiveTabId: 'log',
defaultActiveTabId: 'channel-0'
});
assert.equal(active, 'log');
const [tabList, panels] = container.children;
assert.equal(tabList.children[0].getAttribute('aria-selected'), 'true');
assert.equal(panels.children[0].hidden, false);
});
test('renderChatTabs clears container when no tabs exist', () => {
const document = createMockDocument();
const container = new MockElement('div');
container.replaceChildren(new MockElement('span'));
const active = renderChatTabs({ document, container, tabs: [] });
assert.equal(active, null);
assert.equal(container.children.length, 0);
assert.equal(container.dataset.activeTab, '');
});
+181
View File
@@ -0,0 +1,181 @@
/*
* Copyright (C) 2025 l5yth
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/**
* Highest channel index that should be represented within the tab view.
* @type {number}
*/
export const MAX_CHANNEL_INDEX = 9;
/**
* Build a data model describing the content for chat tabs.
*
* Entries outside the recent activity window, encrypted messages, and
* channels above {@link MAX_CHANNEL_INDEX} are filtered out.
*
* @param {{
* nodes?: Array<Object>,
* messages?: Array<Object>,
* nowSeconds: number,
* windowSeconds: number,
* maxChannelIndex?: number
* }} params Aggregation inputs.
* @returns {{
* logEntries: Array<{ ts: number, node: Object }>,
* channels: Array<{ index: number, label: string, entries: Array<{ ts: number, message: Object }> }>
* }} Sorted tab model data.
*/
export function buildChatTabModel({
nodes = [],
messages = [],
nowSeconds,
windowSeconds,
maxChannelIndex = MAX_CHANNEL_INDEX
}) {
const cutoff = (Number.isFinite(nowSeconds) ? nowSeconds : 0) - (Number.isFinite(windowSeconds) ? windowSeconds : 0);
const logEntries = [];
const channelBuckets = new Map();
for (const node of nodes || []) {
if (!node) continue;
const ts = resolveTimestampSeconds(node.first_heard ?? node.firstHeard, node.first_heard_iso ?? node.firstHeardIso);
if (ts == null || ts < cutoff) continue;
logEntries.push({ ts, node });
}
logEntries.sort((a, b) => a.ts - b.ts);
for (const message of messages || []) {
if (!message || message.encrypted) continue;
const ts = resolveTimestampSeconds(message.rx_time ?? message.rxTime, message.rx_iso ?? message.rxIso);
if (ts == null || ts < cutoff) continue;
const rawIndex = message.channel ?? message.channel_index ?? message.channelIndex;
const channelIndex = normaliseChannelIndex(rawIndex);
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) {
bucket.label = channelName;
bucket.hasExplicitName = true;
}
bucket.entries.push({ ts, message });
}
if (!channelBuckets.has(0)) {
channelBuckets.set(0, {
index: 0,
label: '0',
entries: [],
hasExplicitName: false
});
}
const channels = Array.from(channelBuckets.values()).sort((a, b) => a.index - b.index);
for (const channel of channels) {
channel.entries.sort((a, b) => a.ts - b.ts);
}
return { logEntries, channels };
}
/**
* Convert candidate values to timestamp seconds when possible.
*
* @param {*} numeric Numeric timestamp representation.
* @param {*} isoString ISO timestamp fallback.
* @returns {?number} Timestamp in seconds when parsing succeeds.
*/
export function resolveTimestampSeconds(numeric, isoString) {
if (numeric !== null && numeric !== undefined && numeric !== '') {
const numericValue = typeof numeric === 'number' ? numeric : Number(numeric);
if (Number.isFinite(numericValue)) {
return numericValue;
}
}
if (typeof isoString === 'string' && isoString.length) {
const parsed = Date.parse(isoString);
if (Number.isFinite(parsed)) {
return parsed / 1000;
}
}
return null;
}
/**
* Sanitise channel identifiers into bounded integers.
*
* @param {*} value Raw channel index candidate.
* @returns {?number} Non-negative integer when available.
*/
export function normaliseChannelIndex(value) {
if (value == null || value === '') {
return null;
}
if (typeof value === 'number') {
if (!Number.isFinite(value)) return null;
return Math.trunc(value);
}
if (typeof value === 'string') {
const trimmed = value.trim();
if (!trimmed) return null;
const parsed = Number(trimmed);
if (!Number.isFinite(parsed)) return null;
return Math.trunc(parsed);
}
return null;
}
/**
* Normalise channel names to trimmed display strings.
*
* @param {*} value Raw channel name candidate.
* @returns {?string} Cleaned channel label when present.
*/
export function normaliseChannelName(value) {
if (value == null) return null;
if (typeof value === 'string') {
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
}
if (typeof value === 'number' && Number.isFinite(value)) {
return String(value);
}
return null;
}
export const __test__ = {
resolveTimestampSeconds,
normaliseChannelIndex,
normaliseChannelName
};
+187
View File
@@ -0,0 +1,187 @@
/*
* Copyright (C) 2025 l5yth
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/**
* Render an accessible tab interface within ``container``.
*
* @param {{
* document: Document,
* container: HTMLElement,
* tabs: Array<{ id: string, label: string, content: Node|null }>,
* previousActiveTabId?: string|null,
* defaultActiveTabId?: string|null
* }} options Rendering parameters.
* @returns {?string} Identifier of the active tab after rendering.
*/
export function renderChatTabs({
document,
container,
tabs,
previousActiveTabId = null,
defaultActiveTabId = null
}) {
if (!container || !document) {
return null;
}
const validTabs = Array.isArray(tabs) ? tabs.filter(Boolean) : [];
if (validTabs.length === 0) {
if (typeof container.replaceChildren === 'function') {
container.replaceChildren();
} else {
container.innerHTML = '';
}
container.dataset.activeTab = '';
return null;
}
const fragment = createFragment(document);
const tabList = document.createElement('div');
tabList.className = 'chat-tablist';
tabList.setAttribute('role', 'tablist');
const panelWrapper = document.createElement('div');
panelWrapper.className = 'chat-tabpanels';
fragment.appendChild(tabList);
fragment.appendChild(panelWrapper);
const tabElements = [];
const existingActive = container.dataset?.activeTab || null;
const activeCandidateOrder = [existingActive, previousActiveTabId, defaultActiveTabId];
let activeTabId = null;
const idSet = new Set();
for (const tab of validTabs) {
if (!tab || typeof tab.id !== 'string' || tab.id.length === 0) {
continue;
}
const uniqueId = tab.id;
if (idSet.has(uniqueId)) {
continue;
}
idSet.add(uniqueId);
const button = document.createElement('button');
button.type = 'button';
button.className = 'chat-tab';
button.classList.add('chat-tab');
button.setAttribute('role', 'tab');
button.setAttribute('id', `chat-tab-${uniqueId}`);
button.dataset.tabId = uniqueId;
button.textContent = tab.label || '';
button.setAttribute('aria-selected', 'false');
button.setAttribute('tabindex', '-1');
const panel = document.createElement('div');
panel.className = 'chat-tabpanel';
panel.classList.add('chat-tabpanel');
panel.setAttribute('role', 'tabpanel');
panel.setAttribute('id', `chat-panel-${uniqueId}`);
panel.setAttribute('aria-labelledby', button.getAttribute('id'));
panel.hidden = true;
if (tab.content) {
panel.appendChild(tab.content);
}
tabList.appendChild(button);
panelWrapper.appendChild(panel);
tabElements.push({ id: uniqueId, button, panel });
}
if (tabElements.length === 0) {
if (typeof container.replaceChildren === 'function') {
container.replaceChildren();
} else {
container.innerHTML = '';
}
container.dataset.activeTab = '';
return null;
}
for (const candidate of activeCandidateOrder) {
if (candidate && tabElements.some(entry => entry.id === candidate)) {
activeTabId = candidate;
break;
}
}
if (!activeTabId) {
activeTabId = tabElements[0].id;
}
if (typeof container.replaceChildren === 'function') {
container.replaceChildren(fragment);
} else {
container.innerHTML = '';
container.appendChild(fragment);
}
const setActiveTab = newId => {
if (!newId) return;
let matched = false;
for (const entry of tabElements) {
const isActive = entry.id === newId;
entry.button.setAttribute('aria-selected', isActive ? 'true' : 'false');
entry.button.setAttribute('tabindex', isActive ? '0' : '-1');
if (isActive) {
entry.button.classList.add('is-active');
entry.panel.hidden = false;
matched = true;
container.dataset.activeTab = newId;
if (typeof entry.panel.scrollHeight === 'number' && typeof entry.panel.scrollTop === 'number') {
entry.panel.scrollTop = entry.panel.scrollHeight;
}
} else {
entry.button.classList.remove('is-active');
entry.panel.hidden = true;
}
}
if (!matched) {
container.dataset.activeTab = '';
}
};
setActiveTab(activeTabId);
for (const entry of tabElements) {
entry.button.addEventListener('click', () => {
setActiveTab(entry.id);
});
}
return container.dataset.activeTab || null;
}
/**
* Create a DOM fragment with a graceful fallback for test environments.
*
* @param {Document} document Active document instance.
* @returns {{ appendChild: Function }} Fragment-like node.
*/
function createFragment(document) {
if (document && typeof document.createDocumentFragment === 'function') {
return document.createDocumentFragment();
}
const nodes = [];
return {
childNodes: nodes,
appendChild(node) {
nodes.push(node);
return node;
}
};
}
export const __test__ = { createFragment };
+106 -57
View File
@@ -34,10 +34,11 @@ import { createMessageNodeHydrator } from './message-node-hydrator.js';
import {
extractChatMessageMetadata,
formatChatMessagePrefix,
formatChatChannelTag,
formatNodeAnnouncementPrefix
} from './chat-format.js';
import { initializeInstanceSelector } from './instance-selector.js';
import { buildChatTabModel, MAX_CHANNEL_INDEX } from './chat-log-tabs.js';
import { renderChatTabs } from './chat-tabs.js';
/**
* Entry point for the interactive dashboard. Wires up event listeners,
@@ -128,8 +129,6 @@ export function initializeApp(config) {
applyNodeFallback: applyNodeNameFallback,
logger: console,
});
/** @type {string|undefined} */
let lastChatDate;
const NODE_LIMIT = 1000;
const CHAT_LIMIT = 1000;
const CHAT_RECENT_WINDOW_SECONDS = 7 * 24 * 60 * 60;
@@ -2087,20 +2086,23 @@ export function initializeApp(config) {
* @param {number} ts Unix timestamp in seconds.
* @returns {HTMLElement} Divider element.
*/
function maybeCreateDateDivider(ts) {
if (!ts) return null;
const d = new Date(ts * 1000);
const key = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
if (lastChatDate !== key) {
lastChatDate = key;
const midnight = new Date(d);
midnight.setHours(0, 0, 0, 0);
const div = document.createElement('div');
div.className = 'chat-entry-date';
div.textContent = `-- ${formatDate(midnight)} --`;
return div;
}
return null;
function createDateDividerFactory() {
let lastChatDate = null;
return ts => {
if (!ts) return null;
const d = new Date(ts * 1000);
const key = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
if (lastChatDate !== key) {
lastChatDate = key;
const midnight = new Date(d);
midnight.setHours(0, 0, 0, 0);
const div = document.createElement('div');
div.className = 'chat-entry-date';
div.textContent = `-- ${formatDate(midnight)} --`;
return div;
}
return null;
};
}
/**
@@ -2111,7 +2113,12 @@ export function initializeApp(config) {
*/
function createNodeChatEntry(n) {
const div = document.createElement('div');
const ts = formatTime(new Date(n.first_heard * 1000));
const tsSeconds = resolveTimestampSeconds(
n.first_heard ?? n.firstHeard,
n.first_heard_iso ?? n.firstHeardIso
);
const tsDate = tsSeconds != null ? new Date(tsSeconds * 1000) : null;
const ts = tsDate ? formatTime(tsDate) : '--:--:--';
div.className = 'chat-entry-node';
const short = renderShortHtml(n.short_name, n.role, n.long_name, n);
const longName = escapeHtml(n.long_name || '');
@@ -2132,7 +2139,12 @@ export function initializeApp(config) {
*/
function createMessageChatEntry(m) {
const div = document.createElement('div');
const ts = formatTime(new Date(m.rx_time * 1000));
const tsSeconds = resolveTimestampSeconds(
m.rx_time ?? m.rxTime,
m.rx_iso ?? m.rxIso
);
const tsDate = tsSeconds != null ? new Date(tsSeconds * 1000) : null;
const ts = tsDate ? formatTime(tsDate) : '--:--:--';
const short = renderShortHtml(m.node?.short_name, m.node?.role, m.node?.long_name, m.node);
const text = escapeHtml(m.text || '');
const metadata = extractChatMessageMetadata(m);
@@ -2140,11 +2152,8 @@ export function initializeApp(config) {
timestamp: escapeHtml(ts),
frequency: metadata.frequency ? escapeHtml(metadata.frequency) : ''
});
const channelTag = formatChatChannelTag({
channelName: metadata.channelName ? escapeHtml(metadata.channelName) : ''
});
div.className = 'chat-entry-msg';
div.innerHTML = `${prefix} ${short} ${channelTag} ${text}`;
div.innerHTML = `${prefix} ${short} ${text}`;
return div;
}
@@ -2157,45 +2166,85 @@ export function initializeApp(config) {
*/
function renderChatLog(nodes, messages) {
if (!CHAT_ENABLED || !chatEl) return;
const entries = [];
for (const n of nodes || []) {
entries.push({ type: 'node', ts: n.first_heard ?? 0, item: n });
}
for (const m of messages || []) {
if (!m || m.encrypted) continue;
entries.push({ type: 'msg', ts: m.rx_time ?? 0, item: m });
}
const nowSeconds = Math.floor(Date.now() / 1000);
const cutoff = nowSeconds - CHAT_RECENT_WINDOW_SECONDS;
const recentEntries = entries.filter(entry => {
if (entry == null) return false;
const rawTs = entry.ts;
if (rawTs == null) return false;
const ts = typeof rawTs === 'number' ? rawTs : Number(rawTs);
if (!Number.isFinite(ts)) return false;
entry.ts = ts;
return ts >= cutoff;
const { logEntries, channels } = buildChatTabModel({
nodes,
messages,
nowSeconds,
windowSeconds: CHAT_RECENT_WINDOW_SECONDS,
maxChannelIndex: MAX_CHANNEL_INDEX
});
recentEntries.sort((a, b) => {
if (a.ts !== b.ts) return a.ts - b.ts;
return a.type === 'node' && b.type === 'msg' ? -1 : a.type === 'msg' && b.type === 'node' ? 1 : 0;
const logContent = buildChatFragment({
entries: logEntries,
renderEntry: entry => createNodeChatEntry(entry.node),
emptyLabel: 'No recent node announcements.'
});
const frag = document.createDocumentFragment();
lastChatDate = null;
for (const entry of recentEntries) {
const divider = maybeCreateDateDivider(entry.ts);
if (divider) frag.appendChild(divider);
if (entry.type === 'node') {
frag.appendChild(createNodeChatEntry(entry.item));
} else {
frag.appendChild(createMessageChatEntry(entry.item));
const channelTabs = channels.map(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.'
})
}));
const tabs = [
{ id: 'log', label: 'Log', content: logContent },
...channelTabs
];
const previousActive = chatEl.dataset?.activeTab || null;
const defaultActive = channelTabs.find(tab => tab.id === 'channel-0')?.id || channelTabs[0]?.id || 'log';
renderChatTabs({
document,
container: chatEl,
tabs,
previousActiveTabId: previousActive,
defaultActiveTabId: defaultActive
});
}
/**
* Construct a document fragment for chat entries, inserting date dividers
* and optional empty-state labels.
*
* @param {{
* entries: Array<{ ts: number, item: Object }>,
* renderEntry: Function,
* emptyLabel?: string
* }} params Fragment construction parameters.
* @returns {DocumentFragment} Populated fragment.
*/
function buildChatFragment({ entries = [], renderEntry, emptyLabel }) {
const fragment = document.createDocumentFragment();
if (!entries || entries.length === 0) {
if (emptyLabel) {
const empty = document.createElement('p');
empty.className = 'chat-empty';
empty.textContent = emptyLabel;
fragment.appendChild(empty);
}
return fragment;
}
const getDivider = createDateDividerFactory();
const limitedEntries = entries.slice(Math.max(entries.length - CHAT_LIMIT, 0));
for (const entry of limitedEntries) {
if (!entry || typeof entry.ts !== 'number') {
continue;
}
const divider = getDivider(entry.ts);
if (divider) fragment.appendChild(divider);
if (typeof renderEntry === 'function') {
const node = renderEntry(entry);
if (node) {
fragment.appendChild(node);
}
}
}
chatEl.replaceChildren(frag);
while (chatEl.childElementCount > CHAT_LIMIT) {
chatEl.removeChild(chatEl.firstChild);
}
chatEl.scrollTop = chatEl.scrollHeight;
return fragment;
}
/**
+79 -6
View File
@@ -470,9 +470,58 @@ th {
height: 60vh;
border: 1px solid #ddd;
border-radius: 8px;
overflow-y: auto;
padding: 6px;
display: flex;
flex-direction: column;
overflow: hidden;
font-size: 12px;
background: var(--bg2);
}
.chat-tablist {
display: flex;
gap: 4px;
padding: 6px 6px 0;
border-bottom: 1px solid var(--line);
}
.chat-tab {
flex: 1;
border: none;
background: transparent;
color: inherit;
padding: 6px 8px;
border-radius: 6px 6px 0 0;
cursor: pointer;
font-size: 12px;
transition: background-color 120ms ease, color 120ms ease;
}
.chat-tab:is(:focus-visible, :hover) {
background: rgba(0, 0, 0, 0.06);
}
.chat-tab.is-active {
background: var(--bg2);
color: var(--accent);
border-bottom: 2px solid var(--accent);
}
.chat-tabpanels {
flex: 1;
display: flex;
min-height: 0;
}
.chat-tabpanel {
flex: 1;
padding: 6px;
overflow-y: auto;
}
.chat-empty {
margin: 12px 0;
color: var(--muted);
font-style: italic;
}
.chat-entry-node {
@@ -630,7 +679,7 @@ body.dark .filter-clear:hover {
outline-offset: 2px;
}
button {
button:not(.chat-tab) {
padding: 6px 10px;
border: 1px solid #ccc;
background: #fff;
@@ -649,7 +698,7 @@ button {
line-height: 1;
}
button:hover {
button:not(.chat-tab):hover {
background: #f6f6f6;
}
@@ -1101,17 +1150,41 @@ body.dark #chat {
color: #eee;
}
body.dark .chat-tablist {
border-bottom-color: rgba(255, 255, 255, 0.18);
}
body.dark .chat-tab {
color: #ddd;
background: transparent;
border-color: transparent;
}
body.dark .chat-tab:is(:focus-visible, :hover) {
background: rgba(255, 255, 255, 0.1);
}
body.dark .chat-tab.is-active {
background: #222;
color: var(--accent);
border-bottom-color: var(--accent);
}
body.dark .chat-empty {
color: #888;
}
body.dark th {
background: #222;
}
body.dark button {
body.dark button:not(.chat-tab) {
background: #333;
border-color: #444;
color: #eee;
}
body.dark button:hover {
body.dark button:not(.chat-tab):hover {
background: #444;
}