mirror of
https://github.com/l5yth/potato-mesh.git
synced 2026-08-06 17:03:27 +02:00
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:
@@ -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, '');
|
||||
});
|
||||
@@ -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
|
||||
};
|
||||
@@ -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 };
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user