From 03e2fe6a72ce641fd854e93f724053c5ff4b83a9 Mon Sep 17 00:00:00 2001 From: l5y <220195275+l5yth@users.noreply.github.com> Date: Fri, 31 Oct 2025 12:24:17 +0100 Subject: [PATCH] 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 --- .../js/app/__tests__/chat-log-tabs.test.js | 105 ++++++++++ .../assets/js/app/__tests__/chat-tabs.test.js | 194 ++++++++++++++++++ web/public/assets/js/app/chat-log-tabs.js | 181 ++++++++++++++++ web/public/assets/js/app/chat-tabs.js | 187 +++++++++++++++++ web/public/assets/js/app/main.js | 163 ++++++++++----- web/public/assets/styles/base.css | 85 +++++++- 6 files changed, 852 insertions(+), 63 deletions(-) create mode 100644 web/public/assets/js/app/__tests__/chat-log-tabs.test.js create mode 100644 web/public/assets/js/app/__tests__/chat-tabs.test.js create mode 100644 web/public/assets/js/app/chat-log-tabs.js create mode 100644 web/public/assets/js/app/chat-tabs.js 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 new file mode 100644 index 0000000..fe059ab --- /dev/null +++ b/web/public/assets/js/app/__tests__/chat-log-tabs.test.js @@ -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); +}); diff --git a/web/public/assets/js/app/__tests__/chat-tabs.test.js b/web/public/assets/js/app/__tests__/chat-tabs.test.js new file mode 100644 index 0000000..427140d --- /dev/null +++ b/web/public/assets/js/app/__tests__/chat-tabs.test.js @@ -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, ''); +}); diff --git a/web/public/assets/js/app/chat-log-tabs.js b/web/public/assets/js/app/chat-log-tabs.js new file mode 100644 index 0000000..bc68eaa --- /dev/null +++ b/web/public/assets/js/app/chat-log-tabs.js @@ -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, + * messages?: Array, + * 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 +}; diff --git a/web/public/assets/js/app/chat-tabs.js b/web/public/assets/js/app/chat-tabs.js new file mode 100644 index 0000000..2ccf80e --- /dev/null +++ b/web/public/assets/js/app/chat-tabs.js @@ -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 }; diff --git a/web/public/assets/js/app/main.js b/web/public/assets/js/app/main.js index aaead16..e6426b9 100644 --- a/web/public/assets/js/app/main.js +++ b/web/public/assets/js/app/main.js @@ -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; } /** diff --git a/web/public/assets/styles/base.css b/web/public/assets/styles/base.css index 5307fdf..7c16ded 100644 --- a/web/public/assets/styles/base.css +++ b/web/public/assets/styles/base.css @@ -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; }